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
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/<code>` 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/<code>`
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
+20 -35
View File
@@ -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];
+3 -5
View File
@@ -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';
+2 -4
View File
@@ -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';
+3 -6
View File
@@ -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';
+1 -2
View File
@@ -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';
+7 -11
View File
@@ -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',