Add the scene host that attaches engine-bound parts in order and detaches them in reverse

This commit is contained in:
German Khokhlov committed 2026-11-03 17:09:53 +04:00
1 parent b2cc00833d
commit f0c2523ac4
2 files changed
+394

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
// The page's scene host: the application is created once per page, and only
// the parts bound to a globe engine (its viewer, canvas, camera and frames)
// detach from the outgoing engine and attach to the incoming one.
//
// A part is `{ name, attachScene(scene, { signal, defer }), detachScene({ handoff }) }`.
// Parts attach in registration order and detach in reverse. `signal` belongs
// to one attachment: it aborts when that attachment detaches, so work a part
// started for one engine never lands on the next. `defer(cleanup)` registers
// a release that runs, in reverse, when the part detaches (before its
// detachScene).
const STATES = Object.freeze([
'detached',
'attaching',
'attached',
'detaching',
]);
export class SceneSwitchingError extends Error {
constructor(message = 'The globe is switching engines') {
super(message);
this.name = 'SceneSwitchingError';
this.code = 'ENGINE_SWITCHING';
}
}
/**
* @param {{onError?: (error: unknown, part: string) => void}} [options]
*/
export function createSceneHost({ onError = null } = {}) {
const parts = [];
const listeners = new Set();
let state = 'detached';
let scene = null;
// The live attachment: its controller and each attached part's cleanups.
let attachment = null;
// The attach or detach under way, so a later call waits for it.
let transition = Promise.resolve();
function publish() {
for (const listener of [...listeners]) {
try {
listener({ state, scene });
} catch (error) {
onError?.(error, 'listener');
}
}
}
async function attachPart(part, target, current) {
const defers = [];
const entry = { part, defers };
current.attached.push(entry);
await part.attachScene?.(target, {
signal: current.controller.signal,
defer(cleanup) {
if (typeof cleanup !== 'function')
throw new TypeError('A scene part defers a function');
defers.push(cleanup);
},
});
current.controller.signal.throwIfAborted();
}
async function detachEntry(entry, options, errors) {
while (entry.defers.length) {
const cleanup = entry.defers.pop();
try {
await cleanup();
} catch (error) {
errors.push(error);
onError?.(error, entry.part.name);
}
}
try {
await entry.part.detachScene?.(options);
} catch (error) {
errors.push(error);
onError?.(error, entry.part.name);
}
}
async function release(current, options) {
current.controller.abort();
const errors = [];
while (current.attached.length)
await detachEntry(current.attached.pop(), options, errors);
return errors;
}
function subscribe(listener) {
if (typeof listener !== 'function')
throw new TypeError('Expected a scene listener');
listeners.add(listener);
return () => listeners.delete(listener);
}
function queue(work) {
const run = transition.then(work, work);
transition = run.catch(() => {});
return run;
}
return Object.freeze({
/**
* Add a part. While a scene is attached the part attaches to it now, and
* the returned promise settles with that attachment.
*/
register(part) {
if (!part || typeof part !== 'object' || typeof part.name !== 'string')
throw new TypeError('A scene part needs a name');
if (parts.includes(part)) return Promise.resolve();
parts.push(part);
if (state !== 'attached' || !attachment) return Promise.resolve();
return attachPart(part, scene, attachment);
},
/** Attach every part to `target`, in order. A failure detaches the parts it attached. */
attach(target) {
if (!target) throw new TypeError('Attach needs a scene');
return queue(async () => {
if (state !== 'detached')
throw new Error(`Cannot attach a scene while ${state}`);
state = 'attaching';
const current = { controller: new AbortController(), attached: [] };
attachment = current;
scene = target;
publish();
try {
for (const part of [...parts])
await attachPart(part, target, current);
} catch (error) {
const errors = await release(current, {
handoff: false,
failed: true,
});
attachment = null;
scene = null;
state = 'detached';
publish();
if (errors.length)
throw new AggregateError(
[error, ...errors],
'A scene failed to attach and to release',
);
throw error;
}
state = 'attached';
publish();
return target;
});
},
/**
* Detach every attached part, in reverse. Detaching nothing is a no-op;
* a part that fails to detach does not stop the others.
*/
detach(options = {}) {
return queue(async () => {
if (state === 'detached' || !attachment) return;
const current = attachment;
state = 'detaching';
publish();
let errors;
try {
errors = await release(current, options);
} finally {
attachment = null;
scene = null;
state = 'detached';
publish();
}
if (errors.length)
throw new AggregateError(errors, 'Scene parts could not detach');
});
},
/** The attached scene, or null. */
current: () => (state === 'attached' ? scene : null),
state: () => state,
parts: () => parts.map(({ name }) => name),
subscribe,
/**
* The attached scene, now or once one attaches. Rejects with a
* SceneSwitchingError when `signal` aborts or `timeoutMs` passes first.
*/
whenAttached({ signal = null, timeoutMs = Infinity } = {}) {
if (state === 'attached') return Promise.resolve(scene);
return new Promise((resolve, reject) => {
let timer = null;
const finish = (error, value) => {
clearTimeout(timer);
unsubscribe();
signal?.removeEventListener('abort', onAbort);
if (error) reject(error);
else resolve(value);
};
const onAbort = () =>
finish(
new SceneSwitchingError('The wait for the globe was cancelled'),
);
const unsubscribe = subscribe(({ state: next, scene: value }) => {
if (next === 'attached') finish(null, value);
});
if (signal?.aborted) return onAbort();
signal?.addEventListener('abort', onAbort, { once: true });
if (Number.isFinite(timeoutMs))
timer = setTimeout(
() => finish(new SceneSwitchingError()),
Math.max(0, timeoutMs),
);
});
},
});
}
export const SCENE_HOST_STATES = STATES;
+180
View File
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSceneHost, SceneSwitchingError } from './sceneHost.js';
function recordingPart(name, log, { failAttach = false } = {}) {
const part = {
name,
signals: [],
async attachScene(scene, { signal, defer }) {
log.push(`attach ${name} ${scene.kind}`);
part.signals.push(signal);
defer(() => log.push(`cleanup ${name}`));
if (failAttach) throw new Error(`${name} failed`);
},
detachScene(options) {
log.push(`detach ${name}${options?.handoff ? ' handoff' : ''}`);
},
};
return part;
}
test('parts attach in order and detach in reverse, cleanups first', async () => {
const log = [];
const host = createSceneHost();
const a = recordingPart('a', log);
const b = recordingPart('b', log);
host.register(a);
host.register(b);
const scene = { kind: 'cesium' };
assert.equal(await host.attach(scene), scene);
assert.equal(host.current(), scene);
assert.equal(host.state(), 'attached');
await host.detach({ handoff: true });
assert.deepEqual(log, [
'attach a cesium',
'attach b cesium',
'cleanup b',
'detach b handoff',
'cleanup a',
'detach a handoff',
]);
assert.equal(host.current(), null);
assert.ok(a.signals[0].aborted, 'the attachment signal aborts on detach');
});
test('a failed part detaches the parts already attached and aborts only that attachment', async () => {
const log = [];
const host = createSceneHost();
const a = recordingPart('a', log);
const b = recordingPart('b', log, { failAttach: true });
const c = recordingPart('c', log);
for (const part of [a, b, c]) host.register(part);
await assert.rejects(host.attach({ kind: 'navara' }), /b failed/);
assert.deepEqual(log, [
'attach a navara',
'attach b navara',
'cleanup b',
'detach b',
'cleanup a',
'detach a',
]);
assert.equal(host.state(), 'detached');
assert.ok(a.signals[0].aborted);
// The working engine attaches again on the same parts.
b.attachScene = async (scene, { defer }) => {
log.push(`attach b ${scene.kind}`);
defer(() => log.push('cleanup b'));
};
log.length = 0;
await host.attach({ kind: 'cesium' });
assert.deepEqual(log, [
'attach a cesium',
'attach b cesium',
'attach c cesium',
]);
assert.equal(a.signals.length, 2);
assert.equal(a.signals[1].aborted, false);
});
test('detach is idempotent and a failing part does not stop the others', async () => {
const log = [];
const host = createSceneHost();
host.register(recordingPart('a', log));
host.register({
name: 'bad',
attachScene() {},
detachScene() {
throw new Error('bad detach');
},
});
await host.detach();
assert.deepEqual(log, []);
await host.attach({ kind: 'cesium' });
await assert.rejects(host.detach(), AggregateError);
assert.ok(log.includes('detach a'));
assert.equal(host.state(), 'detached');
await host.detach();
assert.equal(log.filter((line) => line === 'detach a').length, 1);
});
test('a part registered while attached attaches at once and detaches with the rest', async () => {
const log = [];
const host = createSceneHost();
host.register(recordingPart('a', log));
await host.attach({ kind: 'cesium' });
await host.register(recordingPart('late', log));
assert.deepEqual(log, ['attach a cesium', 'attach late cesium']);
await host.detach();
assert.deepEqual(log.slice(2), [
'cleanup late',
'detach late',
'cleanup a',
'detach a',
]);
assert.deepEqual(host.parts(), ['a', 'late']);
});
test('attach refuses while a scene is attached', async () => {
const host = createSceneHost();
await host.attach({ kind: 'cesium' });
await assert.rejects(host.attach({ kind: 'navara' }), /while attached/);
});
test('whenAttached resolves on the next attachment and rejects on abort or timeout', async () => {
const host = createSceneHost();
const waiting = host.whenAttached();
const scene = { kind: 'navara' };
await host.attach(scene);
assert.equal(await waiting, scene);
assert.equal(await host.whenAttached(), scene);
await host.detach();
const controller = new AbortController();
const aborted = host.whenAttached({ signal: controller.signal });
controller.abort();
await assert.rejects(aborted, (error) => {
assert.ok(error instanceof SceneSwitchingError);
assert.equal(error.code, 'ENGINE_SWITCHING');
return true;
});
await assert.rejects(
host.whenAttached({ timeoutMs: 5 }),
(error) => error.code === 'ENGINE_SWITCHING',
);
});
test('subscribers see every state of a switch', async () => {
const host = createSceneHost();
const states = [];
const off = host.subscribe(({ state }) => states.push(state));
await host.attach({ kind: 'cesium' });
await host.detach();
off();
await host.attach({ kind: 'navara' });
assert.deepEqual(states, ['attaching', 'attached', 'detaching', 'detached']);
});
test('a detach requested during an attach waits for it', async () => {
const log = [];
const host = createSceneHost();
let release;
host.register({
name: 'slow',
async attachScene() {
log.push('attach start');
await new Promise((resolve) => (release = resolve));
log.push('attach end');
},
detachScene() {
log.push('detach');
},
});
const attaching = host.attach({ kind: 'cesium' });
const detaching = host.detach();
await new Promise((resolve) => setImmediate(resolve));
release();
await attaching;
await detaching;
assert.deepEqual(log, ['attach start', 'attach end', 'detach']);
assert.equal(host.state(), 'detached');
});