Give the Cesium renderer the contract's listener rules and error channel

Frame-phase and draw-set ready listeners now run in subscription order,
skip one removed during delivery, defer one added during it, and hand a
throw to the renderer's error listeners (or reportError) instead of into
Cesium's render loop. The renderer gains on('error' | 'contextLost' |
'contextRestored'); Cesium cannot restore a lost context, so it never sends
contextRestored. Dispose clears every list. renderer.listeners joins the
Cesium checks.
This commit is contained in:
Sameh Khamis committed 2026-11-05 18:18:28 -08:00
1 parent 3e48e453d0
commit 468590485b
6 files changed
+144 -19

No files matched your search

+2 -1
View File
@@ -24,7 +24,7 @@ import { cesiumCapabilities, createCesiumPorts } from './ports.js';
* The members a transitional renderer has today.
* @typedef {Pick<import('../contract/renderer').Renderer,
* 'id' | 'contractVersion' | 'capabilities' | 'canvas' | 'frame' | 'draw'
* | 'camera' | 'query' | 'capture' | 'resize' | 'destroy'>}
* | 'camera' | 'query' | 'capture' | 'on' | 'resize' | 'destroy'>}
* TransitionalRenderer
*/
@@ -103,6 +103,7 @@ export function createCesiumRendererPlugin({
camera: ports.camera,
query: ports.query,
capture: ports.capture,
on: ports.on,
resize() {
viewer.resize();
},
-1
View File
@@ -347,7 +347,6 @@ const EXPECTED_MISSING = [
'renderer.effects',
'renderer.globe',
'renderer.input',
'renderer.on',
'renderer.quality',
'renderer.readPixels',
'renderer.releaseUnused',
+1
View File
@@ -31,6 +31,7 @@ export const CESIUM_CHECKS = new Set([
'query.projectMany',
'query.rays',
'query.withoutCapability',
'renderer.listeners',
'renderer.whenSettled',
]);
+93 -16
View File
@@ -21,6 +21,61 @@ import {
renderPending,
} from './internals.js';
/**
* Where a listener's throw goes with no `error` listener: the platform's
* `reportError` (or a rethrow on a microtask), as a browser reports an event
* listener's exception, loud, while the frame and the other listeners carry
* on.
*/
function uncaught(error) {
if (typeof globalThis.reportError === 'function')
globalThis.reportError(error);
else
queueMicrotask(() => {
throw error;
});
}
/**
* One subscription list under the contract's listener rules: subscription
* order, one removed during delivery does not run, one added during
* delivery waits for the next event, and a throw goes to `onThrow` while
* the rest still run. Each subscription is its own entry, so a callback
* subscribed twice runs twice.
* @param {(error: unknown) => void} onThrow
*/
export function listenerList(onThrow) {
const entries = [];
return {
get size() {
return entries.length;
},
on(cb) {
const entry = { cb, live: true };
entries.push(entry);
return () => {
entry.live = false;
const i = entries.indexOf(entry);
if (i >= 0) entries.splice(i, 1);
};
},
fire(...args) {
for (const entry of entries.slice()) {
if (!entry.live) continue;
try {
entry.cb(...args);
} catch (error) {
onThrow(error);
}
}
},
clear() {
for (const entry of entries) entry.live = false;
entries.length = 0;
},
};
}
/** The contract's colour as Cesium's. */
export function toColor(color, alpha = 1) {
if (color === undefined || color === null)
@@ -127,7 +182,27 @@ export function createCesiumPorts(widget, { capabilities }) {
const camera = scene.camera;
const sets = new Map();
const frameListeners = { preUpdate: [], preRender: [], postRender: [] };
// An error listener's own throw is uncaught, never reported again.
const rendererListeners = {
error: listenerList(uncaught),
contextLost: listenerList(report),
contextRestored: listenerList(report),
};
/** Hands a listener's throw to the renderer's `error` listeners. */
function report(error) {
if (rendererListeners.error.size === 0) uncaught(error);
else rendererListeners.error.fire(error);
}
// Cesium cannot bring a lost context back, so it never sends
// `contextRestored`.
const contextLost = () => rendererListeners.contextLost.fire();
scene.canvas?.addEventListener?.('webglcontextlost', contextLost);
const frameListeners = {
preUpdate: listenerList(report),
preRender: listenerList(report),
postRender: listenerList(report),
};
let frames = 0;
let rendered = 0;
// Every tick raises preUpdate, drawn or not; the phases of a tick share
@@ -156,7 +231,7 @@ export function createCesiumPorts(widget, { capabilities }) {
frames += 1;
};
const onPhase = (phase) => (_scene, time) => {
if (frameListeners[phase].length === 0) return;
if (frameListeners[phase].size === 0) return;
const t = {
frame: frames,
time: Cesium.JulianDate.toDate(
@@ -177,7 +252,7 @@ export function createCesiumPorts(widget, { capabilities }) {
});
liveTick = t;
}
for (const cb of frameListeners[phase].slice()) cb(t);
frameListeners[phase].fire(t);
};
// Reasons given before a frame starts drawing are answered by it; one
// given while it draws waits for the next, as Cesium's own request does.
@@ -270,11 +345,9 @@ export function createCesiumPorts(widget, { capabilities }) {
const frame = {
on(phase, cb) {
frameListeners[phase].push(cb);
return () => {
const i = frameListeners[phase].indexOf(cb);
if (i >= 0) frameListeners[phase].splice(i, 1);
};
const list = frameListeners[phase];
if (!list) throw new TypeError(`Unknown frame phase: ${phase}`);
return list.on(cb);
},
requestRender(reason = 'unspecified') {
// During a draw the reasons it answers are spoken for: a reason given
@@ -333,7 +406,7 @@ export function createCesiumPorts(widget, { capabilities }) {
function makeSet(kind, spec, build) {
if (sets.has(spec.id)) throw new Error(`a set with id ${spec.id} is live`);
const items = new Map();
const listeners = { ready: [] };
const listeners = { ready: listenerList(report) };
const record = { kind, spec, items, listeners, host: null, pickIds: [] };
// Each build records its items' pick ids, for height queries to see
// through.
@@ -381,12 +454,7 @@ export function createCesiumPorts(widget, { capabilities }) {
scene.requestRender();
},
on(event, cb) {
listeners[event]?.push(cb);
return () => {
const list = listeners[event];
const i = list?.indexOf(cb) ?? -1;
if (i >= 0) list.splice(i, 1);
};
return listeners[event]?.on(cb) ?? (() => {});
},
dispose() {
if (record.host && !record.host.isDestroyed?.())
@@ -702,7 +770,7 @@ export function createCesiumPorts(widget, { capabilities }) {
host.add(model);
model.readyEvent.addEventListener(() => {
settle(false);
for (const cb of record.listeners.ready.slice()) cb(item.id);
record.listeners.ready.fire(item.id);
});
model.errorEvent.addEventListener(() => settle(true));
scene.requestRender();
@@ -1092,6 +1160,11 @@ export function createCesiumPorts(widget, { capabilities }) {
}
return {
/** The renderer's own events: `error`, `contextLost`, `contextRestored`. */
on(event, cb) {
return rendererListeners[event]?.on(cb) ?? (() => {});
},
report,
frame,
draw,
camera: cameraPort,
@@ -1106,6 +1179,10 @@ export function createCesiumPorts(widget, { capabilities }) {
// The tick under way is no longer seen through: it is not claimed.
settleTick(false);
for (const off of removers) off();
scene.canvas?.removeEventListener?.('webglcontextlost', contextLost);
// A listener that disposes the renderer stops the rest of its event.
for (const list of Object.values(rendererListeners)) list.clear();
for (const list of Object.values(frameListeners)) list.clear();
for (const done of [...pendingRenders]) done();
// Late model loads find no host and destroy themselves.
for (const record of sets.values()) record.host = null;
+47 -1
View File
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import test from 'node:test';
import { cesiumCapabilities, createCesiumPorts } from './ports.js';
import {
cesiumCapabilities,
createCesiumPorts,
listenerList,
} from './ports.js';
test('capabilities follow what the scene supports', () => {
assert.deepEqual(cesiumCapabilities({}), ['models']);
@@ -27,6 +31,48 @@ test('a caller can ask for fewer capabilities, never more', () => {
assert.deepEqual(cesiumCapabilities({}, new Set(['sampleHeight'])), []);
});
test('a listener list keeps the listener rules', () => {
const errors = [];
const list = listenerList((error) => errors.push(error));
const seen = [];
let offB = () => {};
list.on(() => {
seen.push('a');
offB();
list.on(() => seen.push('late'));
});
offB = list.on(() => seen.push('b'));
list.on(() => {
throw new Error('listener');
});
const c = () => seen.push('c');
list.on(c);
const offC2 = list.on(c);
list.fire();
assert.deepEqual(seen, ['a', 'c', 'c']);
assert.equal(errors.length, 1);
offC2();
seen.length = 0;
list.fire();
assert.deepEqual(seen, ['a', 'c', 'late']);
list.clear();
assert.equal(list.size, 0);
});
test('a listener that clears its list stops the rest of the event', () => {
const list = listenerList((error) => {
throw error;
});
const seen = [];
list.on(() => {
seen.push('a');
list.clear();
});
list.on(() => seen.push('b'));
list.fire();
assert.deepEqual(seen, ['a']);
});
/** A widget whose scene records what the query port asks of it. */
function fakeWidget(overrides = {}) {
const listeners = new Set();
+1
View File
@@ -265,6 +265,7 @@ export async function createCesiumSceneRenderer({
capture: ports.capture,
whenSettled,
resize: () => widget.resize(),
on: ports.on,
destroy() {
destroyed = true;
for (const finish of [...watching]) finish('ended');