mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Keep history entries untouched by unfinished globe picks and restore each visited entry's layers
A picked engine now lives in the switch until it runs; only then is the address written, so a pick superseded by Back or one that fails rewrites no entry. An engine-changing history visit holds the outgoing share link, which neither restores nor writes the visited entry, and the running engine takes the address back once the switch settles, restoring any entry visited while it was held or not yet listening. Handed-off layers keep running where the incoming address agrees with the outgoing one and follow a visited entry where it differs. A failed recovery keeps the error on the loading screen with an engine picker for a retry, and a failing reveal of the new globe no longer blocks the cover removal or later requests.
This commit is contained in:
1 parent
9cc34e324f
commit
a066a2d1ca
10 files changed
+539
-113
No files matched your search
@@ -255,12 +255,91 @@ try {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert.equal(await stored(), 'navara');
|
assert.equal(await stored(), 'navara');
|
||||||
// Back/Forward must hand the session off like the picker: the Contacts
|
await page.goBack({ waitUntil: 'domcontentloaded' });
|
||||||
// subject and its navigation history, the layers it owns and the Director
|
await ready('cesium');
|
||||||
// survive, although the earlier entry carries an older view.
|
assert.equal((await sharpen()).intensityPct, 49);
|
||||||
|
await page.goForward();
|
||||||
|
await ready('navara');
|
||||||
|
assert.equal((await sharpen()).intensityPct, 80);
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.styleManager.setSharpen({
|
||||||
|
enabled: false,
|
||||||
|
intensityPct: 49,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await switchTo('cesium');
|
||||||
|
await switchTo('navara');
|
||||||
|
assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 });
|
||||||
|
// The address carries the running layer set once its debounced write lands.
|
||||||
|
const settledAddress = async () => {
|
||||||
|
let previous;
|
||||||
|
for (;;) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
||||||
|
const current = await page.evaluate(() =>
|
||||||
|
new URLSearchParams(location.hash.slice(1)).get('l'),
|
||||||
|
);
|
||||||
|
if (current === previous) return current;
|
||||||
|
previous = current;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const pick = async (kind) => {
|
||||||
|
await page.select('#globe-engine', kind);
|
||||||
|
await ready(kind);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !document.querySelector('.engine-switch-frame'),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const visit = async (direction, kind) => {
|
||||||
|
await (direction === 'back'
|
||||||
|
? page.goBack({ waitUntil: 'domcontentloaded' })
|
||||||
|
: page.goForward());
|
||||||
|
await ready(kind);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => !document.querySelector('.engine-switch-frame'),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const quakes = () =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const { dataManager } = window.__godsEyeView;
|
||||||
|
return {
|
||||||
|
model: window.__quakeModel === dataManager.layers.get('earthquakes'),
|
||||||
|
enabled: dataManager.getEnabledLayerIds().has('earthquakes'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Like a same-engine Back, each entry brings back its own layer set, on
|
||||||
|
// the same layer models.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__quakeModel =
|
||||||
|
window.__godsEyeView.dataManager.layers.get('earthquakes');
|
||||||
|
});
|
||||||
|
const navaraLayers = await settledAddress();
|
||||||
|
await pick('cesium');
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('earthquakes', false, {
|
||||||
|
origin: 'user',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const cesiumLayers = await settledAddress();
|
||||||
|
assert.notEqual(cesiumLayers, navaraLayers);
|
||||||
|
await visit('back', 'navara');
|
||||||
|
assert.deepEqual(await quakes(), { model: true, enabled: true });
|
||||||
|
assert.equal(await settledAddress(), navaraLayers, 'Back keeps its entry');
|
||||||
|
await visit('forward', 'cesium');
|
||||||
|
assert.deepEqual(await quakes(), { model: true, enabled: false });
|
||||||
|
assert.equal(await settledAddress(), cesiumLayers, 'Forward keeps its entry');
|
||||||
|
await page.evaluate(() =>
|
||||||
|
window.__godsEyeView.dataManager.setEnabled('earthquakes', true, {
|
||||||
|
origin: 'user',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await pick('navara');
|
||||||
|
console.log(
|
||||||
|
"PASS Back/Forward restore each entry's layers on the same models",
|
||||||
|
);
|
||||||
|
// Back/Forward hand the session off like the picker: the Contacts subject
|
||||||
|
// and its navigation history, its layers and the Director survive.
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const { dataManager, styleManager, sceneDirector } = window.__godsEyeView;
|
const { dataManager, styleManager, sceneDirector } = window.__godsEyeView;
|
||||||
window.qaLayersBefore = [...dataManager.getEnabledLayerIds()].sort();
|
|
||||||
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
|
await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' });
|
||||||
const outcome = await styleManager.setContextMode('contacts');
|
const outcome = await styleManager.setContextMode('contacts');
|
||||||
if (!outcome.ok) throw Error(JSON.stringify(outcome));
|
if (!outcome.ok) throw Error(JSON.stringify(outcome));
|
||||||
@@ -297,20 +376,16 @@ try {
|
|||||||
assert.equal(live.mode, 'flights');
|
assert.equal(live.mode, 'flights');
|
||||||
assert.equal(live.subject, '223456789');
|
assert.equal(live.subject, '223456789');
|
||||||
assert.ok(live.history.includes('ais-live-vessels:123456789'));
|
assert.ok(live.history.includes('ais-live-vessels:123456789'));
|
||||||
await page.goBack({ waitUntil: 'domcontentloaded' });
|
await settledAddress();
|
||||||
await ready('cesium');
|
const historyLength = await page.evaluate(() => history.length);
|
||||||
await page.waitForFunction(
|
await pick('cesium');
|
||||||
() => !document.querySelector('.engine-switch-frame'),
|
assert.deepEqual(await session(), live, 'a pick keeps the session');
|
||||||
);
|
await settledAddress();
|
||||||
assert.equal((await sharpen()).intensityPct, 49);
|
await visit('back', 'navara');
|
||||||
assert.deepEqual(await session(), live, 'Back keeps the session');
|
assert.deepEqual(await session(), live, 'Back keeps the session');
|
||||||
await page.goForward();
|
await visit('forward', 'cesium');
|
||||||
await ready('navara');
|
|
||||||
await page.waitForFunction(
|
|
||||||
() => !document.querySelector('.engine-switch-frame'),
|
|
||||||
);
|
|
||||||
assert.equal((await sharpen()).intensityPct, 80);
|
|
||||||
assert.deepEqual(await session(), live, 'Forward keeps the session');
|
assert.deepEqual(await session(), live, 'Forward keeps the session');
|
||||||
|
assert.equal(await page.evaluate(() => history.length), historyLength + 1);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const { dataManager, styleManager } = window.__godsEyeView;
|
const { dataManager, styleManager } = window.__godsEyeView;
|
||||||
@@ -318,26 +393,13 @@ try {
|
|||||||
await dataManager.setEnabled('ais-live-vessels', false, {
|
await dataManager.setEnabled('ais-live-vessels', false, {
|
||||||
origin: 'user',
|
origin: 'user',
|
||||||
});
|
});
|
||||||
return (
|
return result.ok;
|
||||||
result.ok &&
|
|
||||||
JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) ===
|
|
||||||
JSON.stringify(window.qaLayersBefore)
|
|
||||||
);
|
|
||||||
}),
|
}),
|
||||||
'leaving Contacts after Back/Forward restores the layers it took',
|
|
||||||
);
|
);
|
||||||
|
await pick('navara');
|
||||||
console.log(
|
console.log(
|
||||||
'PASS Back/Forward keep Contacts subject, history, layers and Director',
|
'PASS Back/Forward keep Contacts subject, history, layers and Director',
|
||||||
);
|
);
|
||||||
await page.evaluate(() =>
|
|
||||||
window.__godsEyeView.styleManager.setSharpen({
|
|
||||||
enabled: false,
|
|
||||||
intensityPct: 49,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await switchTo('cesium');
|
|
||||||
await switchTo('navara');
|
|
||||||
assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 });
|
|
||||||
await page.goto(base.href, { waitUntil: 'domcontentloaded' });
|
await page.goto(base.href, { waitUntil: 'domcontentloaded' });
|
||||||
await ready('navara');
|
await ready('navara');
|
||||||
await switchTo('cesium');
|
await switchTo('cesium');
|
||||||
|
|||||||
@@ -300,6 +300,9 @@ try {
|
|||||||
console.log(
|
console.log(
|
||||||
'PASS warm Navara failure rolled back to Cesium with the same flight and cockpit',
|
'PASS warm Navara failure rolled back to Cesium with the same flight and cockpit',
|
||||||
);
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__parkedCesium = window.__godsEyeView.engine;
|
||||||
|
});
|
||||||
await page.select('#globe-engine', 'navara');
|
await page.select('#globe-engine', 'navara');
|
||||||
await ready('navara');
|
await ready('navara');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -315,6 +318,73 @@ try {
|
|||||||
'the retry starts a new Navara renderer on the same session',
|
'the retry starts a new Navara renderer on the same session',
|
||||||
);
|
);
|
||||||
console.log('PASS retry after the warm failure started Navara cold');
|
console.log('PASS retry after the warm failure started Navara cold');
|
||||||
|
|
||||||
|
// Both engines fail: the warm Cesium resume and the warm Navara recovery.
|
||||||
|
// No engine runs, but the error screen offers the engine picker, and a
|
||||||
|
// retry starts Cesium cold on the same session without a reload.
|
||||||
|
const lengthBeforeDoubleFailure = await page.evaluate(() => {
|
||||||
|
window.__failResume = true;
|
||||||
|
for (const engine of [window.__godsEyeView.engine, window.__parkedCesium]) {
|
||||||
|
const request = engine.render.request;
|
||||||
|
engine.render.request = function (reason, ...args) {
|
||||||
|
if (reason === 'engine-resume' && window.__failResume) {
|
||||||
|
window.__refusedResumes = (window.__refusedResumes ?? 0) + 1;
|
||||||
|
throw new Error('QA parked renderer failure');
|
||||||
|
}
|
||||||
|
return request.call(this, reason, ...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
window.__startDocument = document;
|
||||||
|
return history.length;
|
||||||
|
});
|
||||||
|
await page.select('#globe-engine', 'cesium');
|
||||||
|
await page.waitForSelector('#loading-screen .engine-switch-retry button', {
|
||||||
|
visible: true,
|
||||||
|
timeout: 90000,
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
await page.evaluate(
|
||||||
|
(length) =>
|
||||||
|
window.__refusedResumes === 2 &&
|
||||||
|
!window.__godsEyeView &&
|
||||||
|
history.length === length &&
|
||||||
|
!document
|
||||||
|
.querySelector('#loading-screen')
|
||||||
|
.classList.contains('hidden') &&
|
||||||
|
/QA parked renderer failure/.test(
|
||||||
|
document.querySelector('#loading-screen .loader-status').textContent,
|
||||||
|
),
|
||||||
|
lengthBeforeDoubleFailure,
|
||||||
|
),
|
||||||
|
'a failed recovery shows its error and the engine picker',
|
||||||
|
);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__failResume = false;
|
||||||
|
});
|
||||||
|
await page.click(
|
||||||
|
'#loading-screen .engine-switch-retry [data-engine="cesium"]',
|
||||||
|
);
|
||||||
|
await ready('cesium');
|
||||||
|
assert.ok(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const { dataManager, styleManager } = window.__godsEyeView;
|
||||||
|
return (
|
||||||
|
window.__startDocument === document &&
|
||||||
|
!document.querySelector('.engine-switch-retry') &&
|
||||||
|
dataManager === window.__handoff.dataManager &&
|
||||||
|
dataManager.layers.get(window.__qaAircraft.layerId) ===
|
||||||
|
window.__handoff.entry &&
|
||||||
|
styleManager.getCockpitState().active &&
|
||||||
|
styleManager.getAircraftTrackingTarget()?.id ===
|
||||||
|
window.__qaAircraft.aircraftId &&
|
||||||
|
new URL(location.href).searchParams.get('engine') === 'cesium'
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
'the retry restores the same session on Cesium',
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
'PASS failed recovery offered the picker; the retry restored the session on Cesium',
|
||||||
|
);
|
||||||
await page.evaluate(() =>
|
await page.evaluate(() =>
|
||||||
window.__godsEyeView.styleManager.controlCockpit('exit'),
|
window.__godsEyeView.styleManager.controlCockpit('exit'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,6 +45,24 @@ function currentLayerOutcome(dataManager, layerId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a layer handed off by an engine switch runs on the incoming engine.
|
||||||
|
* The handoff records the layer's running state and whether the outgoing
|
||||||
|
* address listed it. Where the incoming address agrees with that, the layer
|
||||||
|
* keeps running as it was, operational intents included (a Contacts session
|
||||||
|
* runs layers its address never lists). Where it differs, the person visited
|
||||||
|
* an entry with another layer set (Back/Forward), and the entry wins, as it
|
||||||
|
* does for a same-engine Back.
|
||||||
|
* @param {{enabled?: boolean, durable?: boolean}|undefined} live
|
||||||
|
* @param {boolean} addressEnabled
|
||||||
|
*/
|
||||||
|
export function handoffTargetEnabled(live, addressEnabled) {
|
||||||
|
if (typeof live?.enabled !== 'boolean') return addressEnabled;
|
||||||
|
if (typeof live.durable === 'boolean' && live.durable !== addressEnabled)
|
||||||
|
return addressEnabled;
|
||||||
|
return live.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns durable user layer preferences independently from transient runtime
|
* Owns durable user layer preferences independently from transient runtime
|
||||||
* choreography, and coordinates passive post-registration restoration.
|
* choreography, and coordinates passive post-registration restoration.
|
||||||
@@ -382,7 +400,10 @@ export class LayerStateCoordinator {
|
|||||||
LAYER_STATE_REGISTRY.map(async (entry) => {
|
LAYER_STATE_REGISTRY.map(async (entry) => {
|
||||||
const controller = controllers.get(entry.id);
|
const controller = controllers.get(entry.id);
|
||||||
const live = handoffLayers?.get(entry.id);
|
const live = handoffLayers?.get(entry.id);
|
||||||
const targetEnabled = live?.enabled ?? enabled.has(entry.id);
|
const targetEnabled = handoffTargetEnabled(
|
||||||
|
live,
|
||||||
|
enabled.has(entry.id),
|
||||||
|
);
|
||||||
// A same-document engine choice retains operational layer intents.
|
// A same-document engine choice retains operational layer intents.
|
||||||
// The share still owns durable preferences and later history visits.
|
// The share still owns durable preferences and later history visits.
|
||||||
if (live && this.dataManager.layers.get(entry.id) === live.entry &&
|
if (live && this.dataManager.layers.get(entry.id) === live.entry &&
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { handoffTargetEnabled } from './layerStateCoordinator.js';
|
||||||
|
|
||||||
|
test('a pick keeps every handed-off layer as it runs', () => {
|
||||||
|
// The incoming address is the outgoing one: it agrees everywhere.
|
||||||
|
assert.equal(
|
||||||
|
handoffTargetEnabled({ enabled: true, durable: true }, true),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// A Contacts session runs layers its address never lists.
|
||||||
|
assert.equal(
|
||||||
|
handoffTargetEnabled({ enabled: true, durable: false }, false),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
handoffTargetEnabled({ enabled: false, durable: true }, true),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a visited entry with another layer set wins where it differs', () => {
|
||||||
|
assert.equal(
|
||||||
|
handoffTargetEnabled({ enabled: false, durable: false }, true),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
handoffTargetEnabled({ enabled: true, durable: true }, false),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without a handoff record the address decides', () => {
|
||||||
|
assert.equal(handoffTargetEnabled(undefined, true), true);
|
||||||
|
assert.equal(handoffTargetEnabled({ entry: null }, false), false);
|
||||||
|
// A record without the outgoing address keeps the running state.
|
||||||
|
assert.equal(handoffTargetEnabled({ enabled: true }, false), true);
|
||||||
|
});
|
||||||
+63
-29
@@ -34,27 +34,20 @@ window.addEventListener('pagehide', (event) => {
|
|||||||
.finally(() => renderers.clear());
|
.finally(() => renderers.clear());
|
||||||
});
|
});
|
||||||
|
|
||||||
// Registered before any engine starts, so these run ahead of the running
|
// Registered before any engine starts, so it runs ahead of the session's own
|
||||||
// session's own share-link listeners. A history visit that changes the
|
// share-link listeners. A history visit that changes the engine belongs to
|
||||||
// engine belongs to the switch: the outgoing engine must not start restoring
|
// the switch: the outgoing engine's share link neither restores that entry
|
||||||
// that entry's share (and release the followed subject) just before it hands
|
// (releasing the followed subject just before the handoff) nor writes its
|
||||||
// the session off. The incoming engine reads the entry's view as it starts.
|
// own view onto it. The incoming engine reads the entry as it starts, and
|
||||||
let swallowedHash = null;
|
// settle() hands the address back to whichever engine then runs.
|
||||||
window.addEventListener('popstate', (event) => {
|
window.addEventListener('popstate', () => {
|
||||||
if (!engineSwitch) return;
|
if (!engineSwitch) return;
|
||||||
swallowedHash = null;
|
if (pageEngineKind() !== engineSwitch.state().engine)
|
||||||
if (pageEngineKind() !== engineSwitch.state().engine) {
|
application
|
||||||
event.stopImmediatePropagation();
|
?.getComponents()
|
||||||
swallowedHash = location.hash;
|
.controls?.styleManager?.shareLinkManager?.holdNavigation?.();
|
||||||
}
|
|
||||||
void engineSwitch.reconcile();
|
void engineSwitch.reconcile();
|
||||||
});
|
});
|
||||||
window.addEventListener('hashchange', (event) => {
|
|
||||||
// Back/Forward fires hashchange after popstate for the same entry.
|
|
||||||
if (swallowedHash !== null && location.hash === swallowedHash)
|
|
||||||
event.stopImmediatePropagation();
|
|
||||||
swallowedHash = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
function createApplication(kind, engineSwitch) {
|
function createApplication(kind, engineSwitch) {
|
||||||
layerSession.restoringView = engineSwitch;
|
layerSession.restoringView = engineSwitch;
|
||||||
@@ -83,7 +76,11 @@ async function leave(from) {
|
|||||||
if (components) {
|
if (components) {
|
||||||
cover ??= keepGlobeFrame(components.scene.engine);
|
cover ??= keepGlobeFrame(components.scene.engine);
|
||||||
cockpit = components.controls.styleManager.getCockpitState();
|
cockpit = components.controls.styleManager.getCockpitState();
|
||||||
handOffSession(layerSession);
|
handOffSession(layerSession, {
|
||||||
|
addressLayerIds:
|
||||||
|
components.controls.styleManager.shareLinkManager?.currentLayerState?.()
|
||||||
|
?.enabledLayerIds,
|
||||||
|
});
|
||||||
renderers.retain(from);
|
renderers.retain(from);
|
||||||
}
|
}
|
||||||
await application?.destroy();
|
await application?.destroy();
|
||||||
@@ -127,20 +124,56 @@ async function enter(kind) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function settle() {
|
async function settle() {
|
||||||
const engine = liveComponents()?.scene.engine;
|
const components = liveComponents();
|
||||||
if (cover && engine) await waitForFirstFrame(engine);
|
try {
|
||||||
// A page left without an engine keeps the last handoff for the next try.
|
if (cover && components) await waitForFirstFrame(components.scene.engine);
|
||||||
if (engine) {
|
} finally {
|
||||||
layerSession.handoffLayers = null;
|
cover?.remove();
|
||||||
cockpit = null;
|
cover = null;
|
||||||
}
|
}
|
||||||
cover?.remove();
|
// A page left without an engine keeps the last handoff for the next try.
|
||||||
cover = null;
|
if (!components) return;
|
||||||
|
layerSession.handoffLayers = null;
|
||||||
|
cockpit = null;
|
||||||
|
withdrawEngineRetry();
|
||||||
|
// The running engine owns the address again, including an entry visited
|
||||||
|
// before its share link listened or while it was held.
|
||||||
|
components.controls.styleManager.shareLinkManager?.resumeNavigation?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function present() {
|
function present() {
|
||||||
const { kind, busy } = engineSwitch.state();
|
const { kind, busy } = engineSwitch.state();
|
||||||
display.presentGlobeEngine({ kind, busy, select });
|
display.presentGlobeEngine({ kind, busy, select });
|
||||||
|
for (const button of loading.querySelectorAll('.engine-switch-retry button'))
|
||||||
|
button.disabled = busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No engine runs after a failed recovery. The error stays on the loading
|
||||||
|
// screen with an engine picker, so a new attempt needs no reload.
|
||||||
|
function offerEngineRetry() {
|
||||||
|
const content = loading.querySelector('.loader-content') ?? loading;
|
||||||
|
if (content.querySelector('.engine-switch-retry')) return;
|
||||||
|
const group = document.createElement('div');
|
||||||
|
group.className = 'engine-switch-retry';
|
||||||
|
group.setAttribute('role', 'group');
|
||||||
|
group.setAttribute('aria-label', t('Globe Engine'));
|
||||||
|
for (const [kind, name] of [
|
||||||
|
['cesium', 'Cesium'],
|
||||||
|
['navara', 'Navara'],
|
||||||
|
]) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.dataset.engine = kind;
|
||||||
|
button.textContent = name;
|
||||||
|
button.setAttribute('translate', 'no');
|
||||||
|
button.addEventListener('click', () => select(kind));
|
||||||
|
group.append(button);
|
||||||
|
}
|
||||||
|
content.append(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withdrawEngineRetry() {
|
||||||
|
loading.querySelector('.engine-switch-retry')?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function report(error) {
|
function report(error) {
|
||||||
@@ -198,8 +231,9 @@ createApplication(initialKind, false)
|
|||||||
onChange: present,
|
onChange: present,
|
||||||
onFailure(error, { recovered }) {
|
onFailure(error, { recovered }) {
|
||||||
const style = recovered && liveComponents()?.controls.styleManager;
|
const style = recovered && liveComponents()?.controls.styleManager;
|
||||||
if (style) style._showToast(describeError(error));
|
if (style) return style._showToast(describeError(error));
|
||||||
else report(error);
|
report(error);
|
||||||
|
offerEngineRetry();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
present();
|
present();
|
||||||
|
|||||||
+37
-3
@@ -122,6 +122,9 @@ export class ShareLinkManager {
|
|||||||
// the app's own state, never an incoming share.
|
// the app's own state, never an incoming share.
|
||||||
this._knownHash = null;
|
this._knownHash = null;
|
||||||
this._unwatchNavigation = null;
|
this._unwatchNavigation = null;
|
||||||
|
this._onNavigate = null;
|
||||||
|
// Held while the page leaves this engine for another (holdNavigation).
|
||||||
|
this._navigationHeld = false;
|
||||||
this._restoreAuthority = {
|
this._restoreAuthority = {
|
||||||
visual: 0,
|
visual: 0,
|
||||||
map: 0,
|
map: 0,
|
||||||
@@ -308,6 +311,7 @@ export class ShareLinkManager {
|
|||||||
if (this._destroyed || typeof onNavigate !== 'function') return;
|
if (this._destroyed || typeof onNavigate !== 'function') return;
|
||||||
const target = globalThis.window;
|
const target = globalThis.window;
|
||||||
if (typeof target?.addEventListener !== 'function') return;
|
if (typeof target?.addEventListener !== 'function') return;
|
||||||
|
this._onNavigate = onNavigate;
|
||||||
const handler = () => this._handleLocationChange(onNavigate);
|
const handler = () => this._handleLocationChange(onNavigate);
|
||||||
target.addEventListener('hashchange', handler);
|
target.addEventListener('hashchange', handler);
|
||||||
target.addEventListener('popstate', handler);
|
target.addEventListener('popstate', handler);
|
||||||
@@ -318,8 +322,31 @@ export class ShareLinkManager {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
_handleLocationChange(onNavigate) {
|
/**
|
||||||
|
* The page is leaving this engine: a history visit asked for another one.
|
||||||
|
* The address now belongs to the visited entry, so this manager neither
|
||||||
|
* writes its view there (a pending debounced write included) nor restores
|
||||||
|
* that entry itself; the incoming engine reads it as it starts.
|
||||||
|
*/
|
||||||
|
holdNavigation() {
|
||||||
|
this._navigationHeld = true;
|
||||||
|
clearTimeout(this._debounceTimer);
|
||||||
|
this._debounceTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take the address back (the engine was kept after all, or it has just
|
||||||
|
* started): end a hold and restore an entry visited while this manager was
|
||||||
|
* held or not yet watching.
|
||||||
|
*/
|
||||||
|
resumeNavigation() {
|
||||||
if (this._destroyed) return;
|
if (this._destroyed) return;
|
||||||
|
this._navigationHeld = false;
|
||||||
|
if (this._onNavigate) this._handleLocationChange(this._onNavigate);
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleLocationChange(onNavigate) {
|
||||||
|
if (this._destroyed || this._navigationHeld) return;
|
||||||
const hash = window.location.hash.slice(1);
|
const hash = window.location.hash.slice(1);
|
||||||
// Back/Forward fires both popstate and hashchange for one entry.
|
// Back/Forward fires both popstate and hashchange for one entry.
|
||||||
if (hash === this._knownHash) return;
|
if (hash === this._knownHash) return;
|
||||||
@@ -483,6 +510,11 @@ export class ShareLinkManager {
|
|||||||
this._layerStateProvider = typeof provider === 'function' ? provider : null;
|
this._layerStateProvider = typeof provider === 'function' ? provider : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The durable layer state this address carries, or null without one. */
|
||||||
|
currentLayerState() {
|
||||||
|
return this._layerStateProvider?.() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install the source of the entity the camera follows, used for share links:
|
* Install the source of the entity the camera follows, used for share links:
|
||||||
* `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share
|
* `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share
|
||||||
@@ -643,13 +675,15 @@ export class ShareLinkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_scheduleUpdate() {
|
_scheduleUpdate() {
|
||||||
if (this._destroyed || this._initialRestorePending) return;
|
if (this._destroyed || this._initialRestorePending || this._navigationHeld)
|
||||||
|
return;
|
||||||
clearTimeout(this._debounceTimer);
|
clearTimeout(this._debounceTimer);
|
||||||
this._debounceTimer = setTimeout(() => this._updateHash(), DEBOUNCE_MS);
|
this._debounceTimer = setTimeout(() => this._updateHash(), DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateHash() {
|
_updateHash() {
|
||||||
if (this._destroyed || this._initialRestorePending) return;
|
if (this._destroyed || this._initialRestorePending || this._navigationHeld)
|
||||||
|
return;
|
||||||
const params = this._buildHashParams();
|
const params = this._buildHashParams();
|
||||||
if (!params) return;
|
if (!params) return;
|
||||||
// The hash belongs to this page's address, not the document base the
|
// The hash belongs to this page's address, not the document base the
|
||||||
|
|||||||
@@ -424,3 +424,55 @@ test('the layer coordinator replaces in-flight share layers with the navigated s
|
|||||||
);
|
);
|
||||||
coordinator.destroy();
|
coordinator.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a manager held for an engine-changing visit neither stamps nor restores that entry', (t) => {
|
||||||
|
const tab = installTab(t, SHARE_A);
|
||||||
|
const timers = installManualTimers(t);
|
||||||
|
const manager = new ShareLinkManager(fakeViewer());
|
||||||
|
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
|
||||||
|
// The page's own popstate listener runs first (src/main.js): the visit
|
||||||
|
// asks for another engine, so it holds this manager.
|
||||||
|
let engineChanges = false;
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
if (engineChanges) manager.holdNavigation();
|
||||||
|
});
|
||||||
|
const navigated = [];
|
||||||
|
manager.watchNavigation((state) => navigated.push(state));
|
||||||
|
tab.navigate(SHARE_B);
|
||||||
|
navigated.length = 0;
|
||||||
|
manager.completeInitialRestore(manager._restoreToken);
|
||||||
|
// A camera move has armed a debounced write of the outgoing view.
|
||||||
|
manager._scheduleUpdate();
|
||||||
|
assert.equal(timers.size, 1);
|
||||||
|
engineChanges = true;
|
||||||
|
tab.back();
|
||||||
|
assert.equal(timers.size, 0);
|
||||||
|
manager._scheduleUpdate();
|
||||||
|
manager._updateHash();
|
||||||
|
manager.flush();
|
||||||
|
window.dispatchEvent(new Event('hashchange'));
|
||||||
|
assert.equal(timers.size, 0);
|
||||||
|
assert.equal(window.location.hash, `#${SHARE_A}`);
|
||||||
|
assert.equal(navigated.length, 0);
|
||||||
|
// Kept after all (the next visit returned to this engine): it restores
|
||||||
|
// the entry it was held on and writes again.
|
||||||
|
manager.resumeNavigation();
|
||||||
|
assert.equal(navigated.length, 1);
|
||||||
|
assert.equal(navigated[0].lat, 10);
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resuming navigation restores an entry visited before the manager watched', (t) => {
|
||||||
|
installTab(t, SHARE_A);
|
||||||
|
const manager = new ShareLinkManager(fakeViewer());
|
||||||
|
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
|
||||||
|
const navigated = [];
|
||||||
|
manager.watchNavigation((state) => navigated.push(state));
|
||||||
|
manager.resumeNavigation();
|
||||||
|
assert.equal(navigated.length, 0, 'the same entry is not restored again');
|
||||||
|
window.location.hash = SHARE_B;
|
||||||
|
manager.resumeNavigation();
|
||||||
|
assert.equal(navigated.length, 1);
|
||||||
|
assert.equal(navigated[0].lat, 48.8584);
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
// The page's engine switch: one layer session, one engine at a time.
|
// The page's engine switch: one layer session, one engine at a time.
|
||||||
//
|
//
|
||||||
// The address (?engine=) is the source of truth. A switch from the picker
|
// The address (?engine=) is the source of truth, except while a picked
|
||||||
// (select) and a history visit (Back/Forward, a pasted address) are the same
|
// engine starts: that choice lives here until the engine runs, and only then
|
||||||
// operation: the running engine hands the session off and is released, the
|
// is written to the address as a new history entry. A switch from the picker
|
||||||
// requested engine starts on the retained session. Requests that arrive while
|
// (select) and a history visit (Back/Forward, a pasted address) both hand the
|
||||||
// a switch or a recovery runs are never dropped: once the current attempt
|
// session off: the running engine is released, the requested engine starts on
|
||||||
// settles, the address is read again and reconciled.
|
// the retained session. They differ in what the incoming engine shows: a pick
|
||||||
|
// keeps the running view and layers, a visit restores the entry's view and
|
||||||
|
// the layers its entry changed (as a same-engine Back does) on the retained
|
||||||
|
// layer models.
|
||||||
|
// Requests that arrive while a switch or a recovery runs are never dropped:
|
||||||
|
// once the current attempt settles, the address is read again and reconciled.
|
||||||
|
|
||||||
const ENGINES = Object.freeze(['cesium', 'navara']);
|
const ENGINES = Object.freeze(['cesium', 'navara']);
|
||||||
|
|
||||||
@@ -25,13 +30,13 @@ export const FIRST_FRAME_WAIT_MS = 10_000;
|
|||||||
* @param {string} options.kind The engine already running.
|
* @param {string} options.kind The engine already running.
|
||||||
* @param {() => string} options.requested The engine the address asks for now.
|
* @param {() => string} options.requested The engine the address asks for now.
|
||||||
* @param {AbortSignal} options.signal The page lifetime.
|
* @param {AbortSignal} options.signal The page lifetime.
|
||||||
* @param {(from: string, to: string) => Promise<void>} options.leave Hand the
|
* @param {(from: string, to: string, change: {visit: boolean}) => Promise<void>} options.leave
|
||||||
* session off from the running engine and release that engine.
|
* Hand the session off from the running engine and release that engine.
|
||||||
* @param {(kind: string, attempt: {generation: number, recovery: boolean}) => Promise<void>} options.enter
|
* @param {(kind: string, attempt: {generation: number, recovery: boolean}) => Promise<void>} options.enter
|
||||||
* Start an engine on the retained session. It resolves once the engine runs
|
* Start an engine on the retained session. It resolves once the engine runs
|
||||||
* with the session attached; when it rejects it has already released what
|
* with the session attached; when it rejects it has already released what
|
||||||
* it started.
|
* it started.
|
||||||
* @param {{select: (from: ?string, to: string) => *, commit: (token: *) => void, restore: (kind: string) => void}} options.address
|
* @param {{select: (from: ?string) => *, commit: (token: *, kind: string) => void, restore: (kind: string) => void}} options.address
|
||||||
* @param {(state: {failed: boolean}) => Promise<void>|void} [options.settle] Runs
|
* @param {(state: {failed: boolean}) => Promise<void>|void} [options.settle] Runs
|
||||||
* once the address is reconciled, before the machine reports idle.
|
* once the address is reconciled, before the machine reports idle.
|
||||||
* @param {(kind: string) => void} [options.remember] Store a picked engine.
|
* @param {(kind: string) => void} [options.remember] Store a picked engine.
|
||||||
@@ -61,10 +66,12 @@ export function createEngineSwitch({
|
|||||||
let busy = false;
|
let busy = false;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
// Every request (select or address visit) bumps this; a visit also bumps
|
// Every request (select or address visit) bumps this; a visit also bumps
|
||||||
// `visits`, which retires a select's pending history entry.
|
// `visits`.
|
||||||
let requests = 0;
|
let requests = 0;
|
||||||
let visits = 0;
|
let visits = 0;
|
||||||
let queued = null;
|
let queued = null;
|
||||||
|
// The pick being started, held here rather than in the address: a visit
|
||||||
|
// made meanwhile retires it and leaves every entry as it was.
|
||||||
let pending = null;
|
let pending = null;
|
||||||
let running = null;
|
let running = null;
|
||||||
|
|
||||||
@@ -79,7 +86,7 @@ export function createEngineSwitch({
|
|||||||
const publish = () => onChange?.(state());
|
const publish = () => onChange?.(state());
|
||||||
|
|
||||||
function prepare(next) {
|
function prepare(next) {
|
||||||
pending = { to: next, visits, token: address.select(current, next) };
|
pending = { to: next, token: address.select(current) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The picker's choice. While a switch runs it is queued, not dropped. */
|
/** The picker's choice. While a switch runs it is queued, not dropped. */
|
||||||
@@ -100,8 +107,9 @@ export function createEngineSwitch({
|
|||||||
if (signal.aborted) return Promise.resolve();
|
if (signal.aborted) return Promise.resolve();
|
||||||
requests++;
|
requests++;
|
||||||
visits++;
|
visits++;
|
||||||
// A later visit supersedes a select queued before it.
|
// A later visit supersedes a pick queued or starting before it.
|
||||||
queued = null;
|
queued = null;
|
||||||
|
pending = null;
|
||||||
return busy ? running : run();
|
return busy ? running : run();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +117,9 @@ export function createEngineSwitch({
|
|||||||
busy = true;
|
busy = true;
|
||||||
publish();
|
publish();
|
||||||
running = drive()
|
running = drive()
|
||||||
.catch((error) => onFailure?.(error, { recovered: false, kind: null }))
|
.catch((error) =>
|
||||||
|
onFailure?.(error, { recovered: current !== null, kind: current }),
|
||||||
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
busy = false;
|
busy = false;
|
||||||
running = null;
|
running = null;
|
||||||
@@ -123,7 +133,12 @@ export function createEngineSwitch({
|
|||||||
await reconcileAddress();
|
await reconcileAddress();
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
const seen = requests;
|
const seen = requests;
|
||||||
await settle?.({ failed });
|
try {
|
||||||
|
await settle?.({ failed });
|
||||||
|
} catch (error) {
|
||||||
|
// Revealing the globe is cosmetic; the engine runs either way.
|
||||||
|
console.error('The globe switch could not settle:', error);
|
||||||
|
}
|
||||||
if (signal.aborted || seen === requests) return;
|
if (signal.aborted || seen === requests) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +152,7 @@ export function createEngineSwitch({
|
|||||||
queued = null;
|
queued = null;
|
||||||
if (next !== current) prepare(next);
|
if (next !== current) prepare(next);
|
||||||
}
|
}
|
||||||
const target = requested();
|
const target = pending?.to ?? requested();
|
||||||
if (target === current) return;
|
if (target === current) return;
|
||||||
// After a failed recovery, wait for the person's next request instead
|
// After a failed recovery, wait for the person's next request instead
|
||||||
// of retrying the same address in a loop.
|
// of retrying the same address in a loop.
|
||||||
@@ -150,12 +165,13 @@ export function createEngineSwitch({
|
|||||||
async function attempt(target) {
|
async function attempt(target) {
|
||||||
const id = ++generation;
|
const id = ++generation;
|
||||||
const visitsAtStart = visits;
|
const visitsAtStart = visits;
|
||||||
|
const visit = pending?.to !== target;
|
||||||
publish();
|
publish();
|
||||||
try {
|
try {
|
||||||
if (current !== null) {
|
if (current !== null) {
|
||||||
const from = current;
|
const from = current;
|
||||||
try {
|
try {
|
||||||
await leave(from, target);
|
await leave(from, target, { visit });
|
||||||
} finally {
|
} finally {
|
||||||
current = null;
|
current = null;
|
||||||
}
|
}
|
||||||
@@ -166,18 +182,17 @@ export function createEngineSwitch({
|
|||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
current = working = target;
|
current = working = target;
|
||||||
failed = false;
|
failed = false;
|
||||||
if (pending?.to === target && pending.visits === visits) {
|
if (!visit && pending?.to === target) {
|
||||||
address.commit(pending.token);
|
address.commit(pending.token, target);
|
||||||
remember?.(target);
|
remember?.(target);
|
||||||
}
|
}
|
||||||
pending = null;
|
pending = null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
pending = null;
|
pending = null;
|
||||||
// Point this history entry back at the working engine unless the
|
// A failed pick never touched the address. A failed visit points its
|
||||||
// person has since moved to another entry. Nothing is pushed, so a
|
// entry at the working engine, unless the person has since moved on.
|
||||||
// failed switch leaves no extra entry behind.
|
if (visit && visits === visitsAtStart && requested() === target)
|
||||||
if (visits === visitsAtStart && requested() === target)
|
|
||||||
address.restore(working);
|
address.restore(working);
|
||||||
if (working === target) {
|
if (working === target) {
|
||||||
live = null;
|
live = null;
|
||||||
@@ -205,10 +220,10 @@ export function createEngineSwitch({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The engine choice in the page address. A picked engine replaces the
|
* The engine choice in the page address. A pick leaves the address alone
|
||||||
* current entry while it starts; only a successful switch adds the history
|
* while its engine starts; a successful one adds the history entry (the
|
||||||
* entry (the earlier entry keeps the outgoing engine and view), so Back
|
* earlier entry keeps the outgoing engine and view), so Back returns to the
|
||||||
* returns to the previous engine and a failed switch adds nothing.
|
* previous engine, and a failed or superseded pick changes no entry.
|
||||||
*/
|
*/
|
||||||
export function createEngineAddress({ location, history, flush = () => {} }) {
|
export function createEngineAddress({ location, history, flush = () => {} }) {
|
||||||
const withEngine = (engine) => {
|
const withEngine = (engine) => {
|
||||||
@@ -217,15 +232,15 @@ export function createEngineAddress({ location, history, flush = () => {} }) {
|
|||||||
return url.href;
|
return url.href;
|
||||||
};
|
};
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
select(from, to) {
|
/** Snapshot the outgoing entry: its engine and its latest view. */
|
||||||
|
select(from) {
|
||||||
flush();
|
flush();
|
||||||
const previous = from ? withEngine(from) : null;
|
return from ? withEngine(from) : null;
|
||||||
history.replaceState(history.state, '', withEngine(to));
|
|
||||||
return previous;
|
|
||||||
},
|
},
|
||||||
commit(previous) {
|
commit(previous, engine) {
|
||||||
if (!previous) return;
|
const next = withEngine(engine);
|
||||||
const next = location.href;
|
// Without a running engine to return to, the pick names this entry.
|
||||||
|
if (!previous) return history.replaceState(history.state, '', next);
|
||||||
history.replaceState(history.state, '', previous);
|
history.replaceState(history.state, '', previous);
|
||||||
history.pushState(null, '', next);
|
history.pushState(null, '', next);
|
||||||
},
|
},
|
||||||
@@ -236,21 +251,26 @@ export function createEngineAddress({ location, history, flush = () => {} }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hand the session off before the running engine is released: record which
|
* Hand the session off before the running engine is released: record the
|
||||||
* layers run so the next engine retains them, and park the cameras and the
|
* layer models so the next engine retains them, and park the cameras and the
|
||||||
* Director scene instead of ending them. Every engine change in this page
|
* Director scene instead of ending them. Every engine change in this page
|
||||||
* hands off, whatever started it; the incoming engine reads the view
|
* hands off, whatever started it. The incoming engine reads the view
|
||||||
* (camera, style, map) from the address it starts with, which for a history
|
* (camera, style, map) from the address it starts with. Each layer records
|
||||||
* visit may differ from the view that was showing.
|
* whether it runs and whether the outgoing address listed it
|
||||||
|
* (`addressLayerIds`): the layer-state coordinator keeps it running as it
|
||||||
|
* was where the incoming address agrees (always after a pick) and follows a
|
||||||
|
* visited entry where it differs (handoffTargetEnabled).
|
||||||
*/
|
*/
|
||||||
export function handOffSession(session) {
|
export function handOffSession(session, { addressLayerIds = null } = {}) {
|
||||||
const manager = session.manager;
|
const manager = session.manager;
|
||||||
|
const listed = addressLayerIds ? new Set(addressLayerIds) : null;
|
||||||
session.handoffLayers = new Map(
|
session.handoffLayers = new Map(
|
||||||
[...manager.layers].map(([id, entry]) => [
|
[...manager.layers].map(([id, entry]) => [
|
||||||
id,
|
id,
|
||||||
{
|
{
|
||||||
entry: entry.module.attachRenderer ? entry : null,
|
entry: entry.module.attachRenderer ? entry : null,
|
||||||
enabled: manager.isEffectivelyEnabled(id),
|
enabled: manager.isEffectivelyEnabled(id),
|
||||||
|
durable: listed ? listed.has(id) : undefined,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ function harness({
|
|||||||
signal: lifetime.signal,
|
signal: lifetime.signal,
|
||||||
async leave(from, to) {
|
async leave(from, to) {
|
||||||
log.push(`leave:${from}->${to}`);
|
log.push(`leave:${from}->${to}`);
|
||||||
handOffSession(session);
|
handOffSession(session, { addressLayerIds: session.addressLayerIds });
|
||||||
parked.add(from);
|
parked.add(from);
|
||||||
running = null;
|
running = null;
|
||||||
},
|
},
|
||||||
@@ -189,13 +189,25 @@ test('a picked engine hands the session off and adds one history entry', async (
|
|||||||
|
|
||||||
test('Back and Forward hand the session off exactly like the picker', async () => {
|
test('Back and Forward hand the session off exactly like the picker', async () => {
|
||||||
const h = harness();
|
const h = harness();
|
||||||
await h.machine.select('navara');
|
// The outgoing address lists flights; directions run off the record.
|
||||||
const picked = h.log.splice(0).filter((line) => line !== 'flush');
|
h.session.addressLayerIds = ['flights'];
|
||||||
let captured;
|
let captured;
|
||||||
h.session.director.suspendScene = () => {
|
h.session.director.suspendScene = () => {
|
||||||
captured = h.session.handoffLayers;
|
captured = h.session.handoffLayers;
|
||||||
h.log.push('suspend:director');
|
h.log.push('suspend:director');
|
||||||
};
|
};
|
||||||
|
await h.machine.select('navara');
|
||||||
|
const picked = h.log.splice(0).filter((line) => line !== 'flush');
|
||||||
|
const record = (handoff) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
[...handoff].map(([id, { enabled, durable }]) => [
|
||||||
|
id,
|
||||||
|
{ enabled, durable },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const afterPick = record(captured);
|
||||||
|
assert.deepEqual(afterPick.flights, { enabled: true, durable: true });
|
||||||
|
assert.deepEqual(afterPick.directions, { enabled: false, durable: false });
|
||||||
h.browser.go(-1);
|
h.browser.go(-1);
|
||||||
await h.machine.reconcile();
|
await h.machine.reconcile();
|
||||||
assert.equal(h.machine.state().kind, 'cesium');
|
assert.equal(h.machine.state().kind, 'cesium');
|
||||||
@@ -210,7 +222,9 @@ test('Back and Forward hand the session off exactly like the picker', async () =
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
assert.ok(!h.log.includes('destroy:director'));
|
assert.ok(!h.log.includes('destroy:director'));
|
||||||
assert.deepEqual(captured.get('flights').enabled, true);
|
// The visit records the same running state and outgoing address; the
|
||||||
|
// layer-state coordinator compares that with the visited entry.
|
||||||
|
assert.deepEqual(record(captured), afterPick);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
captured.get('flights').entry,
|
captured.get('flights').entry,
|
||||||
h.session.manager.layers.get('flights'),
|
h.session.manager.layers.get('flights'),
|
||||||
@@ -249,11 +263,32 @@ test('a visit during a switch is reconciled once the attempt settles', async ()
|
|||||||
'enter:cesium:warm',
|
'enter:cesium:warm',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
// The pick was superseded before it settled: no entry was pushed for it.
|
// The pick was superseded before it settled: no entry was pushed for it,
|
||||||
assert.equal(h.browser.entries.length, 2);
|
// and neither entry was rewritten.
|
||||||
|
assert.deepEqual(h.browser.engines(), ['cesium', 'cesium']);
|
||||||
assert.deepEqual(h.remembered, []);
|
assert.deepEqual(h.remembered, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a pick that fails after Back rewrites no entry', async () => {
|
||||||
|
const gate = deferred();
|
||||||
|
const h = harness({
|
||||||
|
urls: ['/?engine=cesium#a', '/?engine=cesium#b'],
|
||||||
|
outcome: (kind) => (kind === 'navara' ? gate.promise : undefined),
|
||||||
|
});
|
||||||
|
const switching = h.machine.select('navara');
|
||||||
|
h.browser.go(-1);
|
||||||
|
void h.machine.reconcile();
|
||||||
|
gate.reject(new Error('Navara refused'));
|
||||||
|
await switching;
|
||||||
|
assert.equal(h.machine.state().kind, 'cesium');
|
||||||
|
assert.equal(h.browser.index, 0);
|
||||||
|
assert.deepEqual(h.browser.engines(), ['cesium', 'cesium']);
|
||||||
|
assert.deepEqual(
|
||||||
|
h.browser.entries.map(({ url }) => new URL(url).hash),
|
||||||
|
['#a', '#b'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('rollback goes to the last engine that worked, not the one that just failed', async () => {
|
test('rollback goes to the last engine that worked, not the one that just failed', async () => {
|
||||||
const gate = deferred();
|
const gate = deferred();
|
||||||
const h = harness({
|
const h = harness({
|
||||||
@@ -279,7 +314,7 @@ test('rollback goes to the last engine that worked, not the one that just failed
|
|||||||
]);
|
]);
|
||||||
// The visited entry now names the engine that runs; no entry was added.
|
// The visited entry now names the engine that runs; no entry was added.
|
||||||
assert.equal(h.browser.index, 0);
|
assert.equal(h.browser.index, 0);
|
||||||
assert.deepEqual(h.browser.engines(), ['navara', 'navara']);
|
assert.deepEqual(h.browser.engines(), ['navara', 'cesium']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a pick made during a switch is queued, not dropped', async () => {
|
test('a pick made during a switch is queued, not dropped', async () => {
|
||||||
@@ -388,6 +423,8 @@ test('after a failed recovery the page stays usable and the next pick starts an
|
|||||||
assert.equal(h.machine.state().kind, 'navara');
|
assert.equal(h.machine.state().kind, 'navara');
|
||||||
assert.equal(h.machine.state().failed, false);
|
assert.equal(h.machine.state().failed, false);
|
||||||
assert.equal(h.log.filter((line) => line.startsWith('leave')).length, 1);
|
assert.equal(h.log.filter((line) => line.startsWith('leave')).length, 1);
|
||||||
|
// Nothing ran to return to: the pick names the current entry.
|
||||||
|
assert.deepEqual(h.browser.engines(), ['navara']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pagehide during a start neither rolls back nor starts another engine', async () => {
|
test('pagehide during a start neither rolls back nor starts another engine', async () => {
|
||||||
@@ -537,15 +574,16 @@ test('the address keeps the outgoing view on the earlier entry', () => {
|
|||||||
location: browser.location,
|
location: browser.location,
|
||||||
history: browser.history,
|
history: browser.history,
|
||||||
});
|
});
|
||||||
const token = address.select('cesium', 'navara');
|
const token = address.select('cesium');
|
||||||
assert.deepEqual(browser.engines(), ['navara']);
|
// The address stays as it is while the picked engine starts.
|
||||||
|
assert.deepEqual(browser.engines(), [null]);
|
||||||
// The incoming engine writes its own view while it starts.
|
// The incoming engine writes its own view while it starts.
|
||||||
browser.history.replaceState(
|
browser.history.replaceState(
|
||||||
null,
|
null,
|
||||||
'',
|
'',
|
||||||
'/?look=cinematic&engine=navara#lat=2',
|
'/?look=cinematic&engine=navara#lat=2',
|
||||||
);
|
);
|
||||||
address.commit(token);
|
address.commit(token, 'navara');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
browser.entries.map(({ url }) => url.replace(ORIGIN, '/')),
|
browser.entries.map(({ url }) => url.replace(ORIGIN, '/')),
|
||||||
[
|
[
|
||||||
@@ -554,3 +592,40 @@ test('the address keeps the outgoing view on the earlier entry', () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a settle failure is not fatal and later requests are still reconciled', async () => {
|
||||||
|
const h = harness();
|
||||||
|
let settles = 0;
|
||||||
|
const errors = [];
|
||||||
|
const original = console.error;
|
||||||
|
console.error = (...args) => errors.push(args);
|
||||||
|
try {
|
||||||
|
const machine = createEngineSwitch({
|
||||||
|
kind: 'cesium',
|
||||||
|
requested: h.browser.requested,
|
||||||
|
signal: h.lifetime.signal,
|
||||||
|
leave: async () => {},
|
||||||
|
enter: async () => {},
|
||||||
|
settle: async () => {
|
||||||
|
settles += 1;
|
||||||
|
if (settles === 1) {
|
||||||
|
// A pick arrives while the first reveal fails.
|
||||||
|
void machine.select('cesium');
|
||||||
|
throw new Error('cover failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
address: createEngineAddress({
|
||||||
|
location: h.browser.location,
|
||||||
|
history: h.browser.history,
|
||||||
|
}),
|
||||||
|
onFailure: () => assert.fail('a settle error must not report a failure'),
|
||||||
|
});
|
||||||
|
await machine.select('navara');
|
||||||
|
assert.equal(machine.state().kind, 'cesium');
|
||||||
|
assert.equal(machine.state().failed, false);
|
||||||
|
assert.equal(settles, 2);
|
||||||
|
assert.equal(errors.length, 1);
|
||||||
|
} finally {
|
||||||
|
console.error = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -434,6 +434,26 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
|
|||||||
animation: pulse 2s ease-in-out infinite;
|
animation: pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* After a failed globe switch: start an engine again without a reload. */
|
||||||
|
.engine-switch-retry {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.engine-switch-retry button {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--text-dim);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
|
|||||||
Reference in new issue
Block a user