From a066a2d1cae73f2166adf5ecc2a23f7feb5d21d4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 3 Nov 2026 15:24:20 +0400 Subject: [PATCH] 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. --- scripts/qa-engine-switch.mjs | 124 ++++++++++++++++++++------- scripts/qa-layer-handoff.mjs | 70 +++++++++++++++ src/data/layerStateCoordinator.js | 23 ++++- src/data/layerStateHandoff.test.mjs | 38 ++++++++ src/main.js | 92 +++++++++++++------- src/sharelink.js | 40 ++++++++- src/sharelink.navigation.test.mjs | 52 +++++++++++ src/standalone/engineSwitch.js | 98 ++++++++++++--------- src/standalone/engineSwitch.test.mjs | 95 +++++++++++++++++--- src/ui/styles/controls.css | 20 +++++ 10 files changed, 539 insertions(+), 113 deletions(-) create mode 100644 src/data/layerStateHandoff.test.mjs diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs index 3d51bf19e..b1f886651 100644 --- a/scripts/qa-engine-switch.mjs +++ b/scripts/qa-engine-switch.mjs @@ -255,12 +255,91 @@ try { ), ); assert.equal(await stored(), 'navara'); - // Back/Forward must hand the session off like the picker: the Contacts - // subject and its navigation history, the layers it owns and the Director - // survive, although the earlier entry carries an older view. + await page.goBack({ waitUntil: 'domcontentloaded' }); + await ready('cesium'); + 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 () => { const { dataManager, styleManager, sceneDirector } = window.__godsEyeView; - window.qaLayersBefore = [...dataManager.getEnabledLayerIds()].sort(); await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' }); const outcome = await styleManager.setContextMode('contacts'); if (!outcome.ok) throw Error(JSON.stringify(outcome)); @@ -297,20 +376,16 @@ try { assert.equal(live.mode, 'flights'); assert.equal(live.subject, '223456789'); assert.ok(live.history.includes('ais-live-vessels:123456789')); - await page.goBack({ waitUntil: 'domcontentloaded' }); - await ready('cesium'); - await page.waitForFunction( - () => !document.querySelector('.engine-switch-frame'), - ); - assert.equal((await sharpen()).intensityPct, 49); + await settledAddress(); + const historyLength = await page.evaluate(() => history.length); + await pick('cesium'); + assert.deepEqual(await session(), live, 'a pick keeps the session'); + await settledAddress(); + await visit('back', 'navara'); assert.deepEqual(await session(), live, 'Back keeps the session'); - await page.goForward(); - await ready('navara'); - await page.waitForFunction( - () => !document.querySelector('.engine-switch-frame'), - ); - assert.equal((await sharpen()).intensityPct, 80); + await visit('forward', 'cesium'); assert.deepEqual(await session(), live, 'Forward keeps the session'); + assert.equal(await page.evaluate(() => history.length), historyLength + 1); assert.ok( await page.evaluate(async () => { const { dataManager, styleManager } = window.__godsEyeView; @@ -318,26 +393,13 @@ try { await dataManager.setEnabled('ais-live-vessels', false, { origin: 'user', }); - return ( - result.ok && - JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) === - JSON.stringify(window.qaLayersBefore) - ); + return result.ok; }), - 'leaving Contacts after Back/Forward restores the layers it took', ); + await pick('navara'); console.log( '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 ready('navara'); await switchTo('cesium'); diff --git a/scripts/qa-layer-handoff.mjs b/scripts/qa-layer-handoff.mjs index ea059b397..4cd412fe0 100644 --- a/scripts/qa-layer-handoff.mjs +++ b/scripts/qa-layer-handoff.mjs @@ -300,6 +300,9 @@ try { console.log( '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 ready('navara'); assert.ok( @@ -315,6 +318,73 @@ try { 'the retry starts a new Navara renderer on the same session', ); 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(() => window.__godsEyeView.styleManager.controlCockpit('exit'), ); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 351162895..2af5963f9 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -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 * choreography, and coordinates passive post-registration restoration. @@ -382,7 +400,10 @@ export class LayerStateCoordinator { LAYER_STATE_REGISTRY.map(async (entry) => { const controller = controllers.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. // The share still owns durable preferences and later history visits. if (live && this.dataManager.layers.get(entry.id) === live.entry && diff --git a/src/data/layerStateHandoff.test.mjs b/src/data/layerStateHandoff.test.mjs new file mode 100644 index 000000000..6a3867998 --- /dev/null +++ b/src/data/layerStateHandoff.test.mjs @@ -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); +}); diff --git a/src/main.js b/src/main.js index 0af27e092..793e4e9df 100644 --- a/src/main.js +++ b/src/main.js @@ -34,27 +34,20 @@ window.addEventListener('pagehide', (event) => { .finally(() => renderers.clear()); }); -// Registered before any engine starts, so these run ahead of the running -// session's own share-link listeners. A history visit that changes the -// engine belongs to the switch: the outgoing engine must not start restoring -// that entry's share (and release the followed subject) just before it hands -// the session off. The incoming engine reads the entry's view as it starts. -let swallowedHash = null; -window.addEventListener('popstate', (event) => { +// Registered before any engine starts, so it runs ahead of the session's own +// share-link listeners. A history visit that changes the engine belongs to +// the switch: the outgoing engine's share link neither restores that entry +// (releasing the followed subject just before the handoff) nor writes its +// own view onto it. The incoming engine reads the entry as it starts, and +// settle() hands the address back to whichever engine then runs. +window.addEventListener('popstate', () => { if (!engineSwitch) return; - swallowedHash = null; - if (pageEngineKind() !== engineSwitch.state().engine) { - event.stopImmediatePropagation(); - swallowedHash = location.hash; - } + if (pageEngineKind() !== engineSwitch.state().engine) + application + ?.getComponents() + .controls?.styleManager?.shareLinkManager?.holdNavigation?.(); 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) { layerSession.restoringView = engineSwitch; @@ -83,7 +76,11 @@ async function leave(from) { if (components) { cover ??= keepGlobeFrame(components.scene.engine); cockpit = components.controls.styleManager.getCockpitState(); - handOffSession(layerSession); + handOffSession(layerSession, { + addressLayerIds: + components.controls.styleManager.shareLinkManager?.currentLayerState?.() + ?.enabledLayerIds, + }); renderers.retain(from); } await application?.destroy(); @@ -127,20 +124,56 @@ async function enter(kind) { } async function settle() { - const engine = liveComponents()?.scene.engine; - if (cover && engine) await waitForFirstFrame(engine); - // A page left without an engine keeps the last handoff for the next try. - if (engine) { - layerSession.handoffLayers = null; - cockpit = null; + const components = liveComponents(); + try { + if (cover && components) await waitForFirstFrame(components.scene.engine); + } finally { + cover?.remove(); + cover = null; } - cover?.remove(); - cover = null; + // A page left without an engine keeps the last handoff for the next try. + 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() { const { kind, busy } = engineSwitch.state(); 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) { @@ -198,8 +231,9 @@ createApplication(initialKind, false) onChange: present, onFailure(error, { recovered }) { const style = recovered && liveComponents()?.controls.styleManager; - if (style) style._showToast(describeError(error)); - else report(error); + if (style) return style._showToast(describeError(error)); + report(error); + offerEngineRetry(); }, }); present(); diff --git a/src/sharelink.js b/src/sharelink.js index 34cad5787..9006daf5c 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -122,6 +122,9 @@ export class ShareLinkManager { // the app's own state, never an incoming share. this._knownHash = null; this._unwatchNavigation = null; + this._onNavigate = null; + // Held while the page leaves this engine for another (holdNavigation). + this._navigationHeld = false; this._restoreAuthority = { visual: 0, map: 0, @@ -308,6 +311,7 @@ export class ShareLinkManager { if (this._destroyed || typeof onNavigate !== 'function') return; const target = globalThis.window; if (typeof target?.addEventListener !== 'function') return; + this._onNavigate = onNavigate; const handler = () => this._handleLocationChange(onNavigate); target.addEventListener('hashchange', 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; + 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); // Back/Forward fires both popstate and hashchange for one entry. if (hash === this._knownHash) return; @@ -483,6 +510,11 @@ export class ShareLinkManager { 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: * `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share @@ -643,13 +675,15 @@ export class ShareLinkManager { } _scheduleUpdate() { - if (this._destroyed || this._initialRestorePending) return; + if (this._destroyed || this._initialRestorePending || this._navigationHeld) + return; clearTimeout(this._debounceTimer); this._debounceTimer = setTimeout(() => this._updateHash(), DEBOUNCE_MS); } _updateHash() { - if (this._destroyed || this._initialRestorePending) return; + if (this._destroyed || this._initialRestorePending || this._navigationHeld) + return; const params = this._buildHashParams(); if (!params) return; // The hash belongs to this page's address, not the document base the diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs index 9f01d4a6e..15d143589 100644 --- a/src/sharelink.navigation.test.mjs +++ b/src/sharelink.navigation.test.mjs @@ -424,3 +424,55 @@ test('the layer coordinator replaces in-flight share layers with the navigated s ); 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(); +}); diff --git a/src/standalone/engineSwitch.js b/src/standalone/engineSwitch.js index 808c1d0b5..707b9e22e 100644 --- a/src/standalone/engineSwitch.js +++ b/src/standalone/engineSwitch.js @@ -1,11 +1,16 @@ // 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 -// (select) and a history visit (Back/Forward, a pasted address) are the same -// operation: the running engine hands the session off and is released, the -// requested engine starts on the retained session. 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. +// The address (?engine=) is the source of truth, except while a picked +// engine starts: that choice lives here until the engine runs, and only then +// is written to the address as a new history entry. A switch from the picker +// (select) and a history visit (Back/Forward, a pasted address) both hand the +// session off: the running engine is released, the requested engine starts on +// 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']); @@ -25,13 +30,13 @@ export const FIRST_FRAME_WAIT_MS = 10_000; * @param {string} options.kind The engine already running. * @param {() => string} options.requested The engine the address asks for now. * @param {AbortSignal} options.signal The page lifetime. - * @param {(from: string, to: string) => Promise} options.leave Hand the - * session off from the running engine and release that engine. + * @param {(from: string, to: string, change: {visit: boolean}) => Promise} options.leave + * Hand the session off from the running engine and release that engine. * @param {(kind: string, attempt: {generation: number, recovery: boolean}) => Promise} options.enter * 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 * 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} [options.settle] Runs * once the address is reconciled, before the machine reports idle. * @param {(kind: string) => void} [options.remember] Store a picked engine. @@ -61,10 +66,12 @@ export function createEngineSwitch({ let busy = false; let generation = 0; // 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 visits = 0; 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 running = null; @@ -79,7 +86,7 @@ export function createEngineSwitch({ const publish = () => onChange?.(state()); 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. */ @@ -100,8 +107,9 @@ export function createEngineSwitch({ if (signal.aborted) return Promise.resolve(); requests++; visits++; - // A later visit supersedes a select queued before it. + // A later visit supersedes a pick queued or starting before it. queued = null; + pending = null; return busy ? running : run(); } @@ -109,7 +117,9 @@ export function createEngineSwitch({ busy = true; publish(); running = drive() - .catch((error) => onFailure?.(error, { recovered: false, kind: null })) + .catch((error) => + onFailure?.(error, { recovered: current !== null, kind: current }), + ) .finally(() => { busy = false; running = null; @@ -123,7 +133,12 @@ export function createEngineSwitch({ await reconcileAddress(); if (signal.aborted) return; 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; } } @@ -137,7 +152,7 @@ export function createEngineSwitch({ queued = null; if (next !== current) prepare(next); } - const target = requested(); + const target = pending?.to ?? requested(); if (target === current) return; // After a failed recovery, wait for the person's next request instead // of retrying the same address in a loop. @@ -150,12 +165,13 @@ export function createEngineSwitch({ async function attempt(target) { const id = ++generation; const visitsAtStart = visits; + const visit = pending?.to !== target; publish(); try { if (current !== null) { const from = current; try { - await leave(from, target); + await leave(from, target, { visit }); } finally { current = null; } @@ -166,18 +182,17 @@ export function createEngineSwitch({ if (signal.aborted) return; current = working = target; failed = false; - if (pending?.to === target && pending.visits === visits) { - address.commit(pending.token); + if (!visit && pending?.to === target) { + address.commit(pending.token, target); remember?.(target); } pending = null; } catch (error) { if (signal.aborted) return; pending = null; - // Point this history entry back at the working engine unless the - // person has since moved to another entry. Nothing is pushed, so a - // failed switch leaves no extra entry behind. - if (visits === visitsAtStart && requested() === target) + // A failed pick never touched the address. A failed visit points its + // entry at the working engine, unless the person has since moved on. + if (visit && visits === visitsAtStart && requested() === target) address.restore(working); if (working === target) { live = null; @@ -205,10 +220,10 @@ export function createEngineSwitch({ } /** - * The engine choice in the page address. A picked engine replaces the - * current entry while it starts; only a successful switch adds the history - * entry (the earlier entry keeps the outgoing engine and view), so Back - * returns to the previous engine and a failed switch adds nothing. + * The engine choice in the page address. A pick leaves the address alone + * while its engine starts; a successful one adds the history entry (the + * earlier entry keeps the outgoing engine and view), so Back returns to the + * previous engine, and a failed or superseded pick changes no entry. */ export function createEngineAddress({ location, history, flush = () => {} }) { const withEngine = (engine) => { @@ -217,15 +232,15 @@ export function createEngineAddress({ location, history, flush = () => {} }) { return url.href; }; return Object.freeze({ - select(from, to) { + /** Snapshot the outgoing entry: its engine and its latest view. */ + select(from) { flush(); - const previous = from ? withEngine(from) : null; - history.replaceState(history.state, '', withEngine(to)); - return previous; + return from ? withEngine(from) : null; }, - commit(previous) { - if (!previous) return; - const next = location.href; + commit(previous, engine) { + const next = withEngine(engine); + // 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.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 - * layers run so the next engine retains them, and park the cameras and the + * Hand the session off before the running engine is released: record 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 - * hands off, whatever started it; the incoming engine reads the view - * (camera, style, map) from the address it starts with, which for a history - * visit may differ from the view that was showing. + * hands off, whatever started it. The incoming engine reads the view + * (camera, style, map) from the address it starts with. Each layer records + * 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 listed = addressLayerIds ? new Set(addressLayerIds) : null; session.handoffLayers = new Map( [...manager.layers].map(([id, entry]) => [ id, { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id), + durable: listed ? listed.has(id) : undefined, }, ]), ); diff --git a/src/standalone/engineSwitch.test.mjs b/src/standalone/engineSwitch.test.mjs index 67928fff3..b26b983bf 100644 --- a/src/standalone/engineSwitch.test.mjs +++ b/src/standalone/engineSwitch.test.mjs @@ -127,7 +127,7 @@ function harness({ signal: lifetime.signal, async leave(from, to) { log.push(`leave:${from}->${to}`); - handOffSession(session); + handOffSession(session, { addressLayerIds: session.addressLayerIds }); parked.add(from); 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 () => { const h = harness(); - await h.machine.select('navara'); - const picked = h.log.splice(0).filter((line) => line !== 'flush'); + // The outgoing address lists flights; directions run off the record. + h.session.addressLayerIds = ['flights']; let captured; h.session.director.suspendScene = () => { captured = h.session.handoffLayers; 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); await h.machine.reconcile(); 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.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( captured.get('flights').entry, 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', ], ); - // The pick was superseded before it settled: no entry was pushed for it. - assert.equal(h.browser.entries.length, 2); + // The pick was superseded before it settled: no entry was pushed for it, + // and neither entry was rewritten. + assert.deepEqual(h.browser.engines(), ['cesium', 'cesium']); 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 () => { const gate = deferred(); 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. 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 () => { @@ -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().failed, false); 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 () => { @@ -537,15 +574,16 @@ test('the address keeps the outgoing view on the earlier entry', () => { location: browser.location, history: browser.history, }); - const token = address.select('cesium', 'navara'); - assert.deepEqual(browser.engines(), ['navara']); + const token = address.select('cesium'); + // 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. browser.history.replaceState( null, '', '/?look=cinematic&engine=navara#lat=2', ); - address.commit(token); + address.commit(token, 'navara'); assert.deepEqual( 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; + } +}); diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 1f836d397..b3a2a47c6 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -434,6 +434,26 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja)) 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 { 0%, 100% {