Shorten the render failure notice's comments and docs

Comments keep only the why: the loop-running flag, the mid-raise events and
the one-time canvas focus. The CURRENT-STATE section lists behaviour and
pitfalls instead of retelling the code, and no longer says every tileset
event is raised outside Scene's error handling.
This commit is contained in:
German Khokhlov committed 2026-11-13 19:19:15 +04:00
1 parent d42b6fe764
commit df41c0affb
7 files changed
+74 -120

No files matched your search

+38 -57
View File
@@ -2445,66 +2445,47 @@ during viewer construction, before any tile builds a draw command.
## Render failure notice ## Render failure notice
When the globe stops drawing, the scene chrome says so instead of leaving a When the globe stops drawing, the scene chrome says so instead of leaving a
black globe behind controls that still respond. `installRenderFailureNotice()` black globe behind working controls. `installRenderFailureNotice()`
(`src/ui/renderFailure.js`) listens for Cesium's `renderError`, after which (`src/ui/renderFailure.js`) listens for Cesium's `renderError` and the canvas'
Cesium stops its render loop, and for the canvas' `webglcontextlost`. It logs `webglcontextlost`, logs the error, and publishes a notice that
the error and publishes a notice to the status store, which `RenderFailure.vue` shows as an alert dialog with focus on its first action.
`RenderFailure.vue` renders as an alert dialog that takes focus. When the The app's viewer uses `showRenderLoopErrors: false`, so Cesium's dead-end
globe draws again, focus goes back to the element that had it, or, if that "Rendering has stopped" panel no longer appears; `applicationViewerOptions()`
element is gone or cannot take focus, to the globe's canvas, which the map's keeps it by default for other callers.
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.
- **Retry** after a render error restarts the render loop. The notice stays - **Retry** restarts the render loop. The notice stays until a frame starts
until a frame starts and ends without a render error (Cesium raises and ends without an error: Cesium raises `postRender` after a failed frame
`postRender` after a failed frame too), and a loop that fails again keeps it. too.
A listener that throws leaves its Cesium event mid-raise, with listeners - **Reload**, after a lost context, reloads the page; the address keeps the
added or removed since only queued, so the `render()` and `resize()` wrapper view.
finishes the events it cut short (`finishRaise()`) before it raises the - **Reset view** opens `appRootUrl()`, without the hash or `/share/<code>`
render error. These are the events Cesium raises outside Scene's error path; the query, such as `?lang=`, is kept.
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/<code>` path the view was restored from; 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 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 the panel runtime's own notice applies. Its timer (`keepPanelRendering()`)
(`keepPanelRendering()`) draws only while Cesium's render loop is on, so it draws only while the render loop is on. An error a timer frame throws is caught
stops after a render error and draws again after Retry. An error a frame of there: it has already been raised as a render error.
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.
## Map Source control ownership ## Map Source control ownership
+20 -35
View File
@@ -4,14 +4,11 @@ import { describeError } from '../app/errors.js';
import { appRootUrl } from '../share/shareLinkPath.js'; import { appRootUrl } from '../share/shareLinkPath.js';
/** /**
* Tell the user when the globe stops drawing, rather than leave a black globe * Show a recoverable notice when the globe stops drawing, instead of a black
* behind controls that still respond. Cesium stops its render loop after a * globe behind working controls. Retry restarts the render loop, or reloads
* render error, and a page whose WebGL context was lost never draws again. * 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.
* Retry restarts the loop, and the notice stays until a frame renders without * Returns a function that removes the notice.
* 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.
*/ */
export function installRenderFailureNotice( export function installRenderFailureNotice(
viewer, viewer,
@@ -27,8 +24,8 @@ export function installRenderFailureNotice(
const canvas = viewer.canvas ?? scene.canvas; const canvas = viewer.canvas ?? scene.canvas;
let notice = null; let notice = null;
let contextLost = false; let contextLost = false;
// The error Scene raised as a render error and, with rethrowRenderErrors, // With rethrowRenderErrors, Scene throws the error it has just raised;
// throws on: the render() wrapper must not raise it a second time. // the render() wrapper must not raise it again.
let raisedByScene = null; let raisedByScene = null;
let frameFailed = false; let frameFailed = false;
let removeFrameListeners = null; let removeFrameListeners = null;
@@ -104,14 +101,10 @@ export function installRenderFailureNotice(
}; };
const removeRenderError = scene.renderError.addEventListener(onRenderError); const removeRenderError = scene.renderError.addEventListener(onRenderError);
// An error thrown outside Scene's own error handling (a preRender or // An error thrown outside Scene's error handling (a frame listener, onTick,
// postRender listener, the clock's onTick, a resize) also stops Cesium's // resize) stops the loop without a renderError. Raise one, so Cesium, an
// render loop, but raises no renderError, and Cesium reports it only in the // inline panel's timer and this notice all see it. First finish the event
// panel the app turns off. Raise it as a render error, so Cesium's handler, // raises it cut short, so listeners added or removed meanwhile apply.
// 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.
const unguard = ['resize', 'render'].map((name) => const unguard = ['resize', 'render'].map((name) =>
reportThrown(viewer.cesiumWidget, name, (error) => { reportThrown(viewer.cesiumWidget, name, (error) => {
frameEvents(viewer).forEach(finishRaise); 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 * Events Cesium raises in a frame outside Scene's error handling, which a
* that a listener's error cuts their raise short: Scene's frame events, the * listener's error can leave mid-raise. Only top-level tilesets are reached.
* 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.
*/ */
function frameEvents(viewer) { function frameEvents(viewer) {
const { scene } = viewer; const { scene } = viewer;
@@ -164,10 +154,8 @@ function frameEvents(viewer) {
} }
/** /**
* Finish the raise a throwing listener cut short. Cesium's `Event.raiseEvent` * Finish a raise a throwing listener cut short. Until a raise ends, Cesium's
* marks the event as raising until every listener returns, and until then it * `Event` only queues added and removed listeners; apply that queue.
* 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.
*/ */
function finishRaise(event) { function finishRaise(event) {
if (!event?._invokingListeners) return; 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 * A render error marks the loop stopped, but a call made while it ran leaves
* the loop ran leaves the loop's next animation frame queued: the loop queues * its next frame queued, and that frame stops it. Keep the loop marked
* it after a call that returns, and a frame an inline panel's timer draws * running, so a Retry or a shown tab before that frame starts no second loop.
* leaves it queued. That frame stops the loop itself, and a Retry or a shown * After a throw, the loop's own catch still marks it stopped.
* 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.
*/ */
function reportThrown(widget, name, report) { function reportThrown(widget, name, report) {
const original = widget?.[name]; const original = widget?.[name];
+3 -5
View File
@@ -1,8 +1,6 @@
// Retry after an error thrown by a scene event listener. Cesium's // Retry after a scene event listener throws. Cesium's Event.raiseEvent has no
// Event.raiseEvent has no try/finally: a throwing listener leaves the event // try/finally, so the event stays mid-raise and only queues listener changes.
// with _invokingListeners = true, so listeners added or removed afterwards are // Drives Cesium's real Event and startRenderLoop.
// only queued (_toAdd/_toRemove) until the next raise of that event completes.
// Drives Cesium's real Event and startRenderLoop (CesiumWidget.prototype).
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
+2 -4
View File
@@ -1,7 +1,5 @@
// Errors thrown in the render loop outside Scene's tryAndCatchError (a // Errors thrown in the render loop outside Scene's tryAndCatchError. Drives
// preRender/postRender listener, clock.onTick, resize). Drives Cesium's real // Cesium's real startRenderLoop through the useDefaultRenderLoop setter.
// startRenderLoop through the CesiumWidget.prototype.useDefaultRenderLoop
// setter.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
+3 -6
View File
@@ -1,9 +1,6 @@
// Retry after an error Scene catches itself (Scene.tryAndCatchError raises // Retry after an error Scene catches itself: the loop is marked stopped but
// renderError and returns). CesiumWidget's renderError handler marks the loop // its next frame is still queued, so an early Retry must not start a second
// stopped, but startRenderLoop still queues the loop's next animation frame. // loop. Drives Cesium's real startRenderLoop and useDefaultRenderLoop setter.
// A Retry that lands before that frame runs must not start a second loop.
// Drives Cesium's real startRenderLoop and useDefaultRenderLoop setter
// (CesiumWidget.prototype).
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
+1 -2
View File
@@ -1,5 +1,4 @@
// src/ui/renderFailureView.test.mjs — the scene chrome renders the render // The scene chrome renders the render failure notice and its actions.
// failure notice installRenderFailureNotice() publishes, with its actions.
import '../testSupport/registerDom.mjs'; import '../testSupport/registerDom.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
+7 -11
View File
@@ -5,8 +5,7 @@ import { msg, t } from '../../../i18n/index.js';
import { useStatusStore } from '../../stores/status.js'; import { useStatusStore } from '../../stores/status.js';
import UiButton from '../../kit/UiButton.vue'; import UiButton from '../../kit/UiButton.vue';
// The notice installRenderFailureNotice() (src/ui/renderFailure.js) publishes // Published by installRenderFailureNotice() (src/ui/renderFailure.js).
// while the globe cannot draw, with the actions it offers.
const { renderFailure: failure } = storeToRefs(useStatusStore()); const { renderFailure: failure } = storeToRefs(useStatusStore());
const panel = ref(null); const panel = ref(null);
const MESSAGES = { const MESSAGES = {
@@ -17,10 +16,8 @@ const MESSAGES = {
'Rendering stopped after an error. Layers and controls still work, but the map is not updating.', '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 // Focus goes to the first action when the notice appears. When the globe
// keyboard user can act on it at once. When the globe draws again, it goes // draws again, it goes back, or to the map, unless the user moved it.
// back where it was, or to the map if that cannot take it, unless the user
// has moved it since.
let focusBefore; let focusBefore;
watch( watch(
() => Boolean(failure.value), () => Boolean(failure.value),
@@ -39,7 +36,7 @@ watch(
if (active && active !== document.body) return; if (active && active !== document.body) return;
if (previous?.isConnected && previous !== document.body) { if (previous?.isConnected && previous !== document.body) {
previous.focus({ preventScroll: true }); 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; if (document.activeElement === previous) return;
} }
focusMap(); focusMap();
@@ -47,10 +44,9 @@ watch(
{ immediate: true }, { immediate: true },
); );
// The globe's canvas, which the map's keys (Enter to finish a drawing, Space // The map's keys (Enter to finish a drawing, Space to talk) read the canvas
// to talk) read as the map. It takes focus only this once: when focus moves // as the map. It stays focusable only until focus leaves or the globe is
// on, or the globe is pressed, it goes back to a canvas that cannot take // pressed; a press leaves focus on the page, as in Cesium.
// focus, and a press leaves focus on the page, as Cesium has it.
function focusMap() { function focusMap() {
const canvas = document.querySelector( const canvas = document.querySelector(
'#cesiumContainer .cesium-widget canvas', '#cesiumContainer .cesium-widget canvas',