diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4be7ef221..a2fa3502d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2445,66 +2445,47 @@ during viewer construction, before any tile builds a draw command. ## Render failure notice When the globe stops drawing, the scene chrome says so instead of leaving a -black globe behind controls that still respond. `installRenderFailureNotice()` -(`src/ui/renderFailure.js`) listens for Cesium's `renderError`, after which -Cesium stops its render loop, and for the canvas' `webglcontextlost`. It logs -the error and publishes a notice to the status store, which -`RenderFailure.vue` renders as an alert dialog that takes focus. When the -globe draws again, focus goes back to the element that had it, or, if that -element is gone or cannot take focus, to the globe's canvas, which the map's -keys (Enter to finish a drawing, Space to talk) read as the map. The canvas -gets `tabindex="-1"` only until focus moves on or the globe is pressed, and a -press leaves focus on the page, as Cesium has it. Focus the user moved -meanwhile stays put. The -application scene creates its viewer with `showRenderLoopErrors: false`, so -Cesium's own "Rendering has stopped" panel, which offers no way back, no longer -appears; `applicationViewerOptions()` and `createApplicationViewer()` keep -Cesium's panel by default for other callers. An error thrown outside Scene's -own error handling (a `preRender` or `postRender` listener, the clock's -`onTick`, a resize) also stops the loop without a `renderError`, so the notice -wraps the widget's `render()` and `resize()` and raises such an error as a -render error before Cesium's loop handles it as before; one Scene has raised -already and throws on (`rethrowRenderErrors`) is not raised again. A lost -context fails every later frame, and those errors are only logged, so the -notice keeps saying why. The notice sits above floating panels such as the -Québec camera viewer, and its buttons are 44 px tall on touch screens. +black globe behind working controls. `installRenderFailureNotice()` +(`src/ui/renderFailure.js`) listens for Cesium's `renderError` and the canvas' +`webglcontextlost`, logs the error, and publishes a notice that +`RenderFailure.vue` shows as an alert dialog with focus on its first action. +The app's viewer uses `showRenderLoopErrors: false`, so Cesium's dead-end +"Rendering has stopped" panel no longer appears; `applicationViewerOptions()` +keeps it by default for other callers. -- **Retry** after a render error restarts the render loop. The notice stays - until a frame starts and ends without a render error (Cesium raises - `postRender` after a failed frame too), and a loop that fails again keeps it. - A listener that throws leaves its Cesium event mid-raise, with listeners - added or removed since only queued, so the `render()` and `resize()` wrapper - finishes the events it cut short (`finishRaise()`) before it raises the - render error. These are the events Cesium raises outside Scene's error - handling (`frameEvents()`): Scene's frame events, the camera's and the - clock's, the globe's tile load progress, and the load events of the scene's - top-level tilesets. So the notice's frame watchers start with the next - frame, a listener removed while the loop is stopped, for a layer turned off, - is not called again, and one turned off and on again keeps running. -- **Reload** after a lost context reloads the page: Cesium cannot draw into a - context it has lost, and the address keeps the view. -- **Reset view** opens the app's root address (`appRootUrl()`), without the - hash or `/share/` path the view was restored from; the query, such as - `?lang=`, is kept. +- **Retry** restarts the render loop. The notice stays until a frame starts + and ends without an error: Cesium raises `postRender` after a failed frame + too. +- **Reload**, after a lost context, reloads the page; the address keeps the + view. +- **Reset view** opens `appRootUrl()`, without the hash or `/share/` + path; the query, such as `?lang=`, is kept. -An inline conversation panel never reloads or navigates its host's page, so it +Pitfalls: + +- An error thrown outside Scene's error handling (a frame listener, `onTick`, + a resize) stops the loop without a `renderError`. The notice wraps the + widget's `render()` and `resize()` and raises it as one, once even with + `rethrowRenderErrors`. +- A throwing listener leaves its Cesium event mid-raise, with listener changes + only queued. The wrapper finishes those raises first (`finishRaise()` over + `frameEvents()`), so a layer turned off or on meanwhile behaves. +- A render error marks the loop stopped while its next frame is still queued. + The wrapper keeps the loop marked running, so a Retry or a shown tab + (`src/app/tools.js`) before that frame starts no second loop. +- After a lost context, later render errors are only logged, so the notice + keeps saying why. The listener neither prevents nor restores the loss. +- When the globe draws again, focus goes back where it was, or to the globe's + canvas, which the map's keys read as the map; the canvas is focusable only + until focus leaves or the globe is pressed. Focus the user moved stays put. +- The notice sits above floating panels such as the Québec camera viewer, and + 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. The panel's timer -(`keepPanelRendering()`) draws only while Cesium's render loop is on, so it -stops after a render error and draws again after Retry. An error a frame of -the timer throws stops there: it has been raised as a render error, and -thrown on it would only reach the host page. - -A render error marks Cesium's loop stopped, but a frame drawn while the loop -ran leaves its next animation frame queued: an error Scene catches itself -returns to the loop, which queues the frame, and a frame the panel's timer -draws leaves it queued. The `render()` and `resize()` wrapper keeps the loop -marked running after such a call, so a Retry before that frame, or a tab shown -again (`src/app/tools.js`), does not start a second loop that would draw every -frame twice. The queued frame stops the loop itself if nothing restarted it. - -The `webglcontextlost` listener does not `preventDefault()` the context loss or -restore the context; it only reports it. +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. ## Map Source control ownership diff --git a/src/ui/renderFailure.js b/src/ui/renderFailure.js index b21e98e45..9226a72dd 100644 --- a/src/ui/renderFailure.js +++ b/src/ui/renderFailure.js @@ -4,14 +4,11 @@ import { describeError } from '../app/errors.js'; import { appRootUrl } from '../share/shareLinkPath.js'; /** - * Tell the user when the globe stops drawing, rather than leave a black globe - * behind controls that still respond. Cesium stops its render loop after a - * render error, and a page whose WebGL context was lost never draws again. - * - * Retry restarts the loop, and the notice stays until a frame renders without - * an error; after a context loss Retry reloads the page. Reset opens the app's - * root address. With `allowReload: false`, Reset is left out, and so is Retry - * after a context loss. Returns a function that removes the notice. + * 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. + * Returns a function that removes the notice. */ export function installRenderFailureNotice( viewer, @@ -27,8 +24,8 @@ export function installRenderFailureNotice( const canvas = viewer.canvas ?? scene.canvas; let notice = null; let contextLost = false; - // The error Scene raised as a render error and, with rethrowRenderErrors, - // throws on: the render() wrapper must not raise it a second time. + // With rethrowRenderErrors, Scene throws the error it has just raised; + // the render() wrapper must not raise it again. let raisedByScene = null; let frameFailed = false; let removeFrameListeners = null; @@ -104,14 +101,10 @@ export function installRenderFailureNotice( }; const removeRenderError = scene.renderError.addEventListener(onRenderError); - // An error thrown outside Scene's own error handling (a preRender or - // postRender listener, the clock's onTick, a resize) also stops Cesium's - // render loop, but raises no renderError, and Cesium reports it only in the - // panel the app turns off. Raise it as a render error, so Cesium's handler, - // an inline panel's timer and this notice all see it. Such an error passes - // here before anything else runs, so finish the raises of frameEvents() it - // cut short first: listeners added or removed while the loop is stopped - // then apply at once, in order. + // An error thrown outside Scene's error handling (a frame listener, onTick, + // resize) stops the loop without a renderError. Raise one, so Cesium, an + // inline panel's timer and this notice all see it. First finish the event + // raises it cut short, so listeners added or removed meanwhile apply. const unguard = ['resize', 'render'].map((name) => reportThrown(viewer.cesiumWidget, name, (error) => { frameEvents(viewer).forEach(finishRaise); @@ -130,11 +123,8 @@ export function installRenderFailureNotice( } /** - * The events Cesium raises in a frame outside Scene's own error handling, so - * that a listener's error cuts their raise short: Scene's frame events, the - * camera's and the clock's, and those raised after the frame renders, the - * globe's tile load progress and the load events of the scene's tilesets. - * Tilesets nested in a collection are not reached. + * Events Cesium raises in a frame outside Scene's error handling, which a + * listener's error can leave mid-raise. Only top-level tilesets are reached. */ function frameEvents(viewer) { const { scene } = viewer; @@ -164,10 +154,8 @@ function frameEvents(viewer) { } /** - * Finish the raise a throwing listener cut short. Cesium's `Event.raiseEvent` - * marks the event as raising until every listener returns, and until then it - * only queues the listeners added or removed. Apply the queue as the raise - * would have, so listeners added or removed from here on take effect at once. + * Finish a raise a throwing listener cut short. Until a raise ends, Cesium's + * `Event` only queues added and removed listeners; apply that queue. */ function finishRaise(event) { if (!event?._invokingListeners) return; @@ -187,15 +175,12 @@ function finishRaise(event) { } /** - * Report an error the widget's `name` method throws, and throw it on. + * Report an error the widget's `name` method throws, and rethrow it. * - * A render error marks Cesium's render loop stopped, but a call made while - * the loop ran leaves the loop's next animation frame queued: the loop queues - * it after a call that returns, and a frame an inline panel's timer draws - * leaves it queued. That frame stops the loop itself, and a Retry or a shown - * tab that comes first must not start a second loop beside it, so the loop - * stays marked running. A call from the loop that throws is still marked - * stopped by the loop's own catch. + * A render error marks the loop stopped, but a call made while it ran leaves + * its next frame queued, and that frame stops it. Keep the loop marked + * running, so a Retry or a shown tab before that frame starts no second loop. + * After a throw, the loop's own catch still marks it stopped. */ function reportThrown(widget, name, report) { const original = widget?.[name]; diff --git a/src/ui/renderFailureEventRetry.test.mjs b/src/ui/renderFailureEventRetry.test.mjs index d237f4ebf..8de8944d0 100644 --- a/src/ui/renderFailureEventRetry.test.mjs +++ b/src/ui/renderFailureEventRetry.test.mjs @@ -1,8 +1,6 @@ -// Retry after an error thrown by a scene event listener. Cesium's -// Event.raiseEvent has no try/finally: a throwing listener leaves the event -// with _invokingListeners = true, so listeners added or removed afterwards are -// only queued (_toAdd/_toRemove) until the next raise of that event completes. -// Drives Cesium's real Event and startRenderLoop (CesiumWidget.prototype). +// Retry after a scene event listener throws. Cesium's Event.raiseEvent has no +// try/finally, so the event stays mid-raise and only queues listener changes. +// Drives Cesium's real Event and startRenderLoop. import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; diff --git a/src/ui/renderFailureLoopErrors.test.mjs b/src/ui/renderFailureLoopErrors.test.mjs index 399b3dc26..f8f3607c6 100644 --- a/src/ui/renderFailureLoopErrors.test.mjs +++ b/src/ui/renderFailureLoopErrors.test.mjs @@ -1,7 +1,5 @@ -// Errors thrown in the render loop outside Scene's tryAndCatchError (a -// preRender/postRender listener, clock.onTick, resize). Drives Cesium's real -// startRenderLoop through the CesiumWidget.prototype.useDefaultRenderLoop -// setter. +// Errors thrown in the render loop outside Scene's tryAndCatchError. Drives +// Cesium's real startRenderLoop through the useDefaultRenderLoop setter. import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; diff --git a/src/ui/renderFailureSecondLoop.test.mjs b/src/ui/renderFailureSecondLoop.test.mjs index 3ced898f4..ba36c1817 100644 --- a/src/ui/renderFailureSecondLoop.test.mjs +++ b/src/ui/renderFailureSecondLoop.test.mjs @@ -1,9 +1,6 @@ -// Retry after an error Scene catches itself (Scene.tryAndCatchError raises -// renderError and returns). CesiumWidget's renderError handler marks the loop -// stopped, but startRenderLoop still queues the loop's next animation frame. -// A Retry that lands before that frame runs must not start a second loop. -// Drives Cesium's real startRenderLoop and useDefaultRenderLoop setter -// (CesiumWidget.prototype). +// Retry after an error Scene catches itself: the loop is marked stopped but +// its next frame is still queued, so an early Retry must not start a second +// loop. Drives Cesium's real startRenderLoop and useDefaultRenderLoop setter. import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; diff --git a/src/ui/renderFailureView.test.mjs b/src/ui/renderFailureView.test.mjs index cca2a88b2..89213bffa 100644 --- a/src/ui/renderFailureView.test.mjs +++ b/src/ui/renderFailureView.test.mjs @@ -1,5 +1,4 @@ -// src/ui/renderFailureView.test.mjs — the scene chrome renders the render -// failure notice installRenderFailureNotice() publishes, with its actions. +// The scene chrome renders the render failure notice and its actions. import '../testSupport/registerDom.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; diff --git a/src/ui/views/panels/RenderFailure.vue b/src/ui/views/panels/RenderFailure.vue index 779d0532a..3a7e180e2 100644 --- a/src/ui/views/panels/RenderFailure.vue +++ b/src/ui/views/panels/RenderFailure.vue @@ -5,8 +5,7 @@ import { msg, t } from '../../../i18n/index.js'; import { useStatusStore } from '../../stores/status.js'; import UiButton from '../../kit/UiButton.vue'; -// The notice installRenderFailureNotice() (src/ui/renderFailure.js) publishes -// while the globe cannot draw, with the actions it offers. +// Published by installRenderFailureNotice() (src/ui/renderFailure.js). const { renderFailure: failure } = storeToRefs(useStatusStore()); const panel = ref(null); const MESSAGES = { @@ -17,10 +16,8 @@ const MESSAGES = { 'Rendering stopped after an error. Layers and controls still work, but the map is not updating.', ), }; -// Focus moves to the first action each time the notice appears, so a -// keyboard user can act on it at once. When the globe draws again, it goes -// back where it was, or to the map if that cannot take it, unless the user -// has moved it since. +// Focus goes to the first action when the notice appears. When the globe +// draws again, it goes back, or to the map, unless the user moved it. let focusBefore; watch( () => Boolean(failure.value), @@ -39,7 +36,7 @@ watch( if (active && active !== document.body) return; if (previous?.isConnected && previous !== document.body) { previous.focus({ preventScroll: true }); - // A control disabled, hidden or made inert since does not take it. + // A control disabled, hidden or made inert meanwhile does not take it. if (document.activeElement === previous) return; } focusMap(); @@ -47,10 +44,9 @@ watch( { immediate: true }, ); -// The globe's canvas, which the map's keys (Enter to finish a drawing, Space -// to talk) read as the map. It takes focus only this once: when focus moves -// on, or the globe is pressed, it goes back to a canvas that cannot take -// focus, and a press leaves focus on the page, as Cesium has it. +// The map's keys (Enter to finish a drawing, Space to talk) read the canvas +// as the map. It stays focusable only until focus leaves or the globe is +// pressed; a press leaves focus on the page, as in Cesium. function focusMap() { const canvas = document.querySelector( '#cesiumContainer .cesium-widget canvas',