mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(share): restore a share link opened in an already-open tab (#1416)
* fix(share): restore a share link opened by hash navigation Opening a different share link in an already-open tab (pasting it into the address bar, following a link, or Back/Forward) changed the URL but left the camera, visual style, map, panels and layers on the old view until a reload. The share manager now listens for hashchange and popstate. A new hash that parses as a share and differs from the last hash the app read or wrote runs the start-up restore pipeline: lane authority is reserved, URL writes are held (including a pending debounced write of the old view), the camera restore takes a new navigation generation, and the layer coordinator replaces any in-flight layer restore with the new share's layers through the passive share origin. A newer navigation supersedes an older restore end to end; only the newest one releases URL writes. The app's own replaceState writes never restore. Listeners are removed on destroy. Returning to the Normal style and resetting an already-active preset now also apply when a share is restored after start-up, so the result matches a fresh load of the same link. * docs: changelog for #1416
This commit is contained in:
1 parent
301bc2e0b3
commit
b7ece1d4d1
8 files changed
+688
-58
No files matched your search
@@ -0,0 +1,4 @@
|
||||
- Restore a share link opened in an already-open tab. Pasting a link into the
|
||||
address bar, following one, or using Back/Forward now moves the camera and
|
||||
restores the style, map, panels and layers instead of changing only the URL
|
||||
(samehkhamis, #1416).
|
||||
@@ -148,6 +148,36 @@ export class LayerStateCoordinator {
|
||||
return this.restorePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the running layer state with a share opened later in this tab
|
||||
* (hash navigation). Every in-flight restore and pending shared Follow is
|
||||
* superseded; the new state restores through the same passive share origin
|
||||
* as start-up, so recipient-local preferences are never written. A share
|
||||
* without a layer payload restores the defaults, as a reload of it would.
|
||||
*/
|
||||
applyShareLayerState({
|
||||
shareLayerState = null,
|
||||
shareCreatedAtMs = null,
|
||||
} = {}) {
|
||||
if (this._destroyed) return Promise.resolve([]);
|
||||
for (const controller of this._restoreControllers.values())
|
||||
controller.abort('superseded-by-share-navigation');
|
||||
this._revokePendingTrackingWatch('superseded-by-share-navigation');
|
||||
const selected = shareLayerState
|
||||
? normalizeLayerState(shareLayerState)
|
||||
: null;
|
||||
this._source = selected ? 'share' : 'legacy-share';
|
||||
this._shareCreatedAtMs =
|
||||
selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null;
|
||||
this._durableState = selected || createDefaultLayerState();
|
||||
this.shareLinkManager?.onLayerStateChange?.();
|
||||
this._notifyDurableState();
|
||||
this.restorePromise = this._restoreSelectedState(
|
||||
LAYER_RESTORE_ORIGINS.share,
|
||||
);
|
||||
return this.restorePromise;
|
||||
}
|
||||
|
||||
get source() {
|
||||
return this._source;
|
||||
}
|
||||
@@ -312,15 +342,19 @@ export class LayerStateCoordinator {
|
||||
}
|
||||
|
||||
async _restoreSelectedState(origin) {
|
||||
// One controller set per run: a superseded run must not clear the newer
|
||||
// run's controllers when it settles.
|
||||
const controllers = new Map();
|
||||
for (const entry of LAYER_STATE_REGISTRY) {
|
||||
this._restoreControllers.set(entry.id, new AbortController());
|
||||
controllers.set(entry.id, new AbortController());
|
||||
}
|
||||
this._restoreControllers = controllers;
|
||||
try {
|
||||
await this._waitForRestoreGate();
|
||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||
const settled = await Promise.allSettled(
|
||||
LAYER_STATE_REGISTRY.map(async (entry) => {
|
||||
const controller = this._restoreControllers.get(entry.id);
|
||||
const controller = controllers.get(entry.id);
|
||||
const targetEnabled = enabled.has(entry.id);
|
||||
const options = layerOptionsForRestore(this._durableState, entry.id);
|
||||
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
||||
@@ -370,7 +404,7 @@ export class LayerStateCoordinator {
|
||||
}));
|
||||
}),
|
||||
);
|
||||
this.lastRestoreResults = settled.map((result, index) => {
|
||||
const results = settled.map((result, index) => {
|
||||
if (result.status === 'fulfilled') return result.value;
|
||||
const entry = LAYER_STATE_REGISTRY[index];
|
||||
return {
|
||||
@@ -387,9 +421,13 @@ export class LayerStateCoordinator {
|
||||
succeeded: false,
|
||||
};
|
||||
});
|
||||
return this.lastRestoreResults.map((result) => ({ ...result }));
|
||||
// A run superseded by a newer share keeps the newer run's results.
|
||||
if (this._restoreControllers === controllers)
|
||||
this.lastRestoreResults = results;
|
||||
return results.map((result) => ({ ...result }));
|
||||
} finally {
|
||||
this._restoreControllers.clear();
|
||||
if (this._restoreControllers === controllers)
|
||||
this._restoreControllers.clear();
|
||||
this._notifyDurableState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,11 +94,13 @@ test('a deferred lookup that never flies leaves the readout standing', () => {
|
||||
/stamp: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection,\s*clearSearchedLocation: false,?\s*\}\)/,
|
||||
);
|
||||
|
||||
const reassert = ui.indexOf(' _reassertNavigationHandoff(generation) {');
|
||||
const reassert = ui.indexOf(
|
||||
" _reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {",
|
||||
);
|
||||
assert.ok(reassert > 0, '_reassertNavigationHandoff is missing');
|
||||
assert.match(
|
||||
ui.slice(reassert, reassert + 900),
|
||||
/release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\)/,
|
||||
/release: \(\) => \{[\s\S]{0,320}?this\.clearLocation\(\);[\s\S]{0,200}?this\._releaseFollowCamera\(\{ trackingOrigin \}\)/,
|
||||
);
|
||||
|
||||
// …and the policy only reaches `release` after its authority checks pass.
|
||||
|
||||
+69
-10
@@ -122,6 +122,13 @@ export class ShareLinkManager {
|
||||
this._panelStateProvider = null;
|
||||
this._styleParamStateProvider = null;
|
||||
this._initialRestorePending = false;
|
||||
// Each incoming share (start-up or a later hash navigation) gets a token;
|
||||
// only the newest one may release URL-write suppression.
|
||||
this._restoreToken = 0;
|
||||
// The hash this page last read or wrote. A location change back to it is
|
||||
// the app's own state, never an incoming share.
|
||||
this._knownHash = null;
|
||||
this._unwatchNavigation = null;
|
||||
this._restoreAuthority = {
|
||||
visual: 0,
|
||||
map: 0,
|
||||
@@ -152,6 +159,15 @@ export class ShareLinkManager {
|
||||
*/
|
||||
parseInitialHash() {
|
||||
const hash = window.location.hash.slice(1);
|
||||
this._knownHash = hash;
|
||||
const state = this._parseShareHash(hash);
|
||||
if (!state) return null;
|
||||
this._beginIncomingRestore(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Parse one hash without side effects. Returns null unless it is a share. */
|
||||
_parseShareHash(hash) {
|
||||
if (!hash) return null;
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
@@ -242,15 +258,55 @@ export class ShareLinkManager {
|
||||
panelState: decodePanelStateParams(params),
|
||||
sharedAtMs: decodeShareCreatedAtMs(params),
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Reserve restore lanes and hold URL writes for one incoming share. */
|
||||
_beginIncomingRestore(state) {
|
||||
state.restoreAuthority = {
|
||||
visual: this._restoreAuthority.visual,
|
||||
map: this._restoreAuthority.map,
|
||||
panels: new Map(this._restoreAuthority.panels),
|
||||
};
|
||||
|
||||
// Hold URL writes until the complete incoming state has been restored.
|
||||
state.restoreToken = ++this._restoreToken;
|
||||
// Hold URL writes until the complete incoming state has been restored. A
|
||||
// debounced write of the previous view must not land over the new link.
|
||||
this._initialRestorePending = true;
|
||||
return state;
|
||||
clearTimeout(this._debounceTimer);
|
||||
this._debounceTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a share link opened in this tab after start-up: a pasted address,
|
||||
* a followed link, or Back/Forward. The app's own replaceState writes never
|
||||
* fire these events, and a hash equal to the last one read or written is
|
||||
* ignored anyway, so only a genuinely different incoming share restores.
|
||||
*/
|
||||
watchNavigation(onNavigate) {
|
||||
this._unwatchNavigation?.();
|
||||
if (this._destroyed || typeof onNavigate !== 'function') return;
|
||||
const target = globalThis.window;
|
||||
if (typeof target?.addEventListener !== 'function') return;
|
||||
const handler = () => this._handleLocationChange(onNavigate);
|
||||
target.addEventListener('hashchange', handler);
|
||||
target.addEventListener('popstate', handler);
|
||||
this._unwatchNavigation = () => {
|
||||
target.removeEventListener('hashchange', handler);
|
||||
target.removeEventListener('popstate', handler);
|
||||
this._unwatchNavigation = null;
|
||||
};
|
||||
}
|
||||
|
||||
_handleLocationChange(onNavigate) {
|
||||
if (this._destroyed) return;
|
||||
const hash = window.location.hash.slice(1);
|
||||
// Back/Forward fires both popstate and hashchange for one entry.
|
||||
if (hash === this._knownHash) return;
|
||||
this._knownHash = hash;
|
||||
const state = this._parseShareHash(hash);
|
||||
if (!state) return;
|
||||
this._beginIncomingRestore(state);
|
||||
onNavigate(state);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,9 +430,13 @@ export class ShareLinkManager {
|
||||
};
|
||||
}
|
||||
|
||||
/** Release initial hash suppression only after every restore owner settles. */
|
||||
completeInitialRestore() {
|
||||
/**
|
||||
* Release hash suppression only after every restore owner settles. A token
|
||||
* from a superseded share restore leaves the newer one holding the URL.
|
||||
*/
|
||||
completeInitialRestore(restoreToken = this._restoreToken) {
|
||||
if (!this._initialRestorePending) return;
|
||||
if (restoreToken !== this._restoreToken) return;
|
||||
this._initialRestorePending = false;
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
@@ -528,11 +588,9 @@ export class ShareLinkManager {
|
||||
if (!params) return;
|
||||
// The hash belongs to this page's address, not the document base the
|
||||
// app may load under.
|
||||
history.replaceState(
|
||||
null,
|
||||
'',
|
||||
new URL(`#${params.toString()}`, window.location.href).href,
|
||||
);
|
||||
const url = new URL(`#${params.toString()}`, window.location.href);
|
||||
this._knownHash = url.hash.slice(1);
|
||||
history.replaceState(null, '', url.href);
|
||||
}
|
||||
|
||||
/** Build a deterministic snapshot without mutating history. */
|
||||
@@ -615,6 +673,7 @@ export class ShareLinkManager {
|
||||
this._debounceTimer = null;
|
||||
this._removeCameraChanged?.();
|
||||
this._removeCameraChanged = null;
|
||||
this._unwatchNavigation?.();
|
||||
this._layerStateProvider = null;
|
||||
this._panelStateProvider = null;
|
||||
this._styleParamStateProvider = null;
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ShareLinkManager } from './sharelink.js';
|
||||
import { ShareRestoration } from './ui/shareRestoration.js';
|
||||
import { DataLayerManager } from './data/manager.js';
|
||||
import {
|
||||
LAYER_STATE_REGISTRY,
|
||||
REGISTERED_LAYER_IDS,
|
||||
createDefaultLayerState,
|
||||
decodeLayerStateParams,
|
||||
} from './data/layerState.js';
|
||||
import { LayerStateCoordinator } from './data/layerStateCoordinator.js';
|
||||
|
||||
const SHARE_A = 'v=2&lat=10.0000&lon=20.0000&alt=800&style=nvg&l=e';
|
||||
const SHARE_B = 'v=2&lat=48.8584&lon=2.2945&alt=500&style=normal&l=f';
|
||||
|
||||
/**
|
||||
* A window whose address behaves like a tab's: `navigate` is a hash-only
|
||||
* navigation (new history entry plus hashchange), `back`/`forward` traverse
|
||||
* entries (popstate plus hashchange), and `history.replaceState` rewrites the
|
||||
* current entry without firing anything.
|
||||
*/
|
||||
function installTab(t, initialHash = '') {
|
||||
const prior = {
|
||||
window: globalThis.window,
|
||||
history: globalThis.history,
|
||||
};
|
||||
const target = new EventTarget();
|
||||
const location = new URL(
|
||||
`http://localhost/${initialHash ? `#${initialHash}` : ''}`,
|
||||
);
|
||||
const entries = [location.hash];
|
||||
let index = 0;
|
||||
const listeners = { hashchange: 0, popstate: 0 };
|
||||
const add = target.addEventListener.bind(target);
|
||||
const remove = target.removeEventListener.bind(target);
|
||||
target.addEventListener = (type, handler, options) => {
|
||||
if (type in listeners) listeners[type] += 1;
|
||||
add(type, handler, options);
|
||||
};
|
||||
target.removeEventListener = (type, handler, options) => {
|
||||
if (type in listeners) listeners[type] -= 1;
|
||||
remove(type, handler, options);
|
||||
};
|
||||
target.location = location;
|
||||
globalThis.window = target;
|
||||
globalThis.history = {
|
||||
replaceState(_state, _title, next) {
|
||||
location.hash = new URL(next, location.href).hash;
|
||||
entries[index] = location.hash;
|
||||
},
|
||||
};
|
||||
t.after(() => Object.assign(globalThis, prior));
|
||||
const go = (nextIndex) => {
|
||||
index = nextIndex;
|
||||
location.hash = entries[index];
|
||||
target.dispatchEvent(new Event('popstate'));
|
||||
target.dispatchEvent(new Event('hashchange'));
|
||||
};
|
||||
return {
|
||||
listeners,
|
||||
navigate(hash) {
|
||||
entries.splice(index + 1);
|
||||
entries.push(`#${hash}`);
|
||||
index = entries.length - 1;
|
||||
location.hash = hash;
|
||||
target.dispatchEvent(new Event('hashchange'));
|
||||
},
|
||||
back: () => go(index - 1),
|
||||
forward: () => go(index + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/** Queue timers instead of running them, so delayed work runs only on demand. */
|
||||
function installManualTimers(t) {
|
||||
const prior = {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
const timers = new Map();
|
||||
let timerId = 0;
|
||||
globalThis.setTimeout = (fn) => {
|
||||
timers.set(++timerId, fn);
|
||||
return timerId;
|
||||
};
|
||||
globalThis.clearTimeout = (id) => timers.delete(id);
|
||||
t.after(() => Object.assign(globalThis, prior));
|
||||
return timers;
|
||||
}
|
||||
|
||||
function fakeViewer() {
|
||||
return {
|
||||
camera: {
|
||||
changed: { addEventListener: () => () => {} },
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: 1000 },
|
||||
heading: 0,
|
||||
pitch: -Math.PI / 2,
|
||||
roll: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a hash navigation to a different share restores it and holds URL writes', (t) => {
|
||||
const tab = installTab(t, SHARE_A);
|
||||
const manager = new ShareLinkManager(fakeViewer());
|
||||
const initial = manager.parseInitialHash();
|
||||
manager.completeInitialRestore(initial.restoreToken);
|
||||
const navigated = [];
|
||||
manager.watchNavigation((state) => navigated.push(state));
|
||||
|
||||
tab.navigate(SHARE_B);
|
||||
assert.equal(navigated.length, 1);
|
||||
assert.equal(navigated[0].lat, 48.8584);
|
||||
assert.equal(navigated[0].style, 'normal');
|
||||
assert.deepEqual(navigated[0].layerState.enabledLayerIds, ['flights']);
|
||||
assert.equal(manager._initialRestorePending, true);
|
||||
// Nothing from the previous view may overwrite the incoming link.
|
||||
manager._updateHash();
|
||||
assert.equal(window.location.hash, `#${SHARE_B}`);
|
||||
manager.completeInitialRestore(navigated[0].restoreToken);
|
||||
assert.equal(manager._initialRestorePending, false);
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("the app's own hash writes never trigger a restore", (t) => {
|
||||
installTab(t, SHARE_A);
|
||||
const manager = new ShareLinkManager(fakeViewer());
|
||||
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
|
||||
let restores = 0;
|
||||
manager.watchNavigation(() => {
|
||||
restores += 1;
|
||||
});
|
||||
manager._updateHash();
|
||||
assert.notEqual(window.location.hash, `#${SHARE_A}`);
|
||||
// replaceState fires nothing, but a stray event for our own hash is ignored.
|
||||
window.dispatchEvent(new Event('hashchange'));
|
||||
window.dispatchEvent(new Event('popstate'));
|
||||
assert.equal(restores, 0);
|
||||
assert.equal(manager._initialRestorePending, false);
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test('a non-share hash is ignored and Back/Forward restores each entry once', (t) => {
|
||||
const tab = installTab(t, SHARE_A);
|
||||
const manager = new ShareLinkManager(fakeViewer());
|
||||
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
|
||||
// The app rewrites entry 0 with its own state; Back must return to that.
|
||||
manager._updateHash();
|
||||
const ownHash = window.location.hash.slice(1);
|
||||
const navigated = [];
|
||||
manager.watchNavigation((state) => navigated.push(state));
|
||||
|
||||
tab.navigate('not-a-share');
|
||||
assert.equal(navigated.length, 0);
|
||||
assert.equal(manager._initialRestorePending, false);
|
||||
|
||||
tab.navigate(SHARE_B);
|
||||
assert.equal(navigated.length, 1);
|
||||
manager.completeInitialRestore(navigated[0].restoreToken);
|
||||
|
||||
tab.back(); // not-a-share
|
||||
tab.back(); // the app's own rewritten first entry
|
||||
assert.equal(navigated.length, 2, 'popstate and hashchange restore once');
|
||||
assert.equal(window.location.hash.slice(1), ownHash);
|
||||
assert.equal(navigated[1].lat, 0);
|
||||
manager.completeInitialRestore(navigated[1].restoreToken);
|
||||
|
||||
tab.forward();
|
||||
tab.forward();
|
||||
assert.equal(navigated.length, 3);
|
||||
assert.equal(navigated[2].lat, 48.8584);
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test('a newer navigation supersedes an older restore and its pending write', (t) => {
|
||||
const tab = installTab(t, SHARE_A);
|
||||
const timers = installManualTimers(t);
|
||||
|
||||
const manager = new ShareLinkManager(fakeViewer());
|
||||
manager.completeInitialRestore(manager.parseInitialHash().restoreToken);
|
||||
assert.equal(timers.size, 1, 'releasing suppression schedules a write');
|
||||
const navigated = [];
|
||||
manager.watchNavigation((state) => navigated.push(state));
|
||||
|
||||
tab.navigate(SHARE_B);
|
||||
assert.equal(
|
||||
timers.size,
|
||||
0,
|
||||
'the pending write of the old view is cancelled',
|
||||
);
|
||||
tab.navigate(SHARE_A);
|
||||
const [older, newer] = navigated;
|
||||
assert.ok(newer.restoreToken > older.restoreToken);
|
||||
manager.completeInitialRestore(older.restoreToken);
|
||||
assert.equal(
|
||||
manager._initialRestorePending,
|
||||
true,
|
||||
'a superseded restore cannot release writes',
|
||||
);
|
||||
manager.completeInitialRestore(newer.restoreToken);
|
||||
assert.equal(manager._initialRestorePending, false);
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test('destroy removes the navigation listeners', (t) => {
|
||||
const tab = installTab(t, SHARE_A);
|
||||
const manager = new ShareLinkManager(fakeViewer());
|
||||
manager.parseInitialHash();
|
||||
let restores = 0;
|
||||
manager.watchNavigation(() => {
|
||||
restores += 1;
|
||||
});
|
||||
assert.deepEqual(tab.listeners, { hashchange: 1, popstate: 1 });
|
||||
manager.watchNavigation(() => {
|
||||
restores += 1;
|
||||
});
|
||||
assert.deepEqual(
|
||||
tab.listeners,
|
||||
{ hashchange: 1, popstate: 1 },
|
||||
'rewatching replaces',
|
||||
);
|
||||
manager.destroy();
|
||||
assert.deepEqual(tab.listeners, { hashchange: 0, popstate: 0 });
|
||||
tab.navigate(SHARE_B);
|
||||
assert.equal(restores, 0);
|
||||
});
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/** A ShareRestoration over a real ShareLinkManager with a stub restore sink. */
|
||||
function restorationHarness(t, initialHash, navigationOverrides = {}) {
|
||||
const tab = installTab(t, initialHash);
|
||||
const timers = installManualTimers(t);
|
||||
|
||||
const restores = [];
|
||||
let navigationGeneration = 0;
|
||||
const manager = new ShareLinkManager(fakeViewer(), {
|
||||
onRestore: (state) => {
|
||||
const gate = deferred();
|
||||
restores.push({ state, gate });
|
||||
return gate.promise;
|
||||
},
|
||||
isNavigationCurrent: () => false,
|
||||
});
|
||||
const layerCalls = [];
|
||||
const handoffs = [];
|
||||
const owner = new ShareRestoration({
|
||||
viewer: { canvas: new EventTarget() },
|
||||
navigation: {
|
||||
_beginDeferredNavigation: () => ++navigationGeneration,
|
||||
_reassertNavigationHandoff: (generation, options) => {
|
||||
handoffs.push({ generation, options });
|
||||
return true;
|
||||
},
|
||||
_stampNavigation() {},
|
||||
...navigationOverrides,
|
||||
},
|
||||
syncShareState() {},
|
||||
syncModels3d() {},
|
||||
showStatus() {},
|
||||
feedback: {},
|
||||
updateFeedback() {},
|
||||
});
|
||||
owner.attachLinks(manager);
|
||||
owner._layerStateCoordinator = {
|
||||
applyShareLayerState(options) {
|
||||
layerCalls.push(options);
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
getDurableState: () => null,
|
||||
cancelPendingShareTracking: () => false,
|
||||
destroy() {},
|
||||
};
|
||||
owner.start();
|
||||
t.after(() => owner.destroy());
|
||||
return { tab, timers, manager, owner, restores, layerCalls, handoffs };
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test('a navigated share runs the start-up restore pipeline and supersedes the delayed one', async (t) => {
|
||||
const { tab, timers, manager, owner, restores, layerCalls } =
|
||||
restorationHarness(t, SHARE_A);
|
||||
assert.equal(timers.size, 1, 'start-up restore waits for its delayed flight');
|
||||
|
||||
tab.navigate(SHARE_B);
|
||||
assert.equal(timers.size, 0, 'the delayed start-up restore never runs');
|
||||
assert.equal((await owner.initialRestorePromise).status, 'superseded');
|
||||
assert.equal(restores.length, 1);
|
||||
assert.equal(restores[0].state.style, 'normal');
|
||||
assert.equal(restores[0].state.mapStack, 'photoreal');
|
||||
assert.deepEqual(layerCalls[0].shareLayerState.enabledLayerIds, ['flights']);
|
||||
|
||||
restores[0].gate.resolve();
|
||||
await flush();
|
||||
assert.equal(manager._initialRestorePending, false);
|
||||
});
|
||||
|
||||
test('a navigated share releases the old Follow passively, never as a saved preference', (t) => {
|
||||
const { tab, handoffs } = restorationHarness(t, SHARE_A);
|
||||
tab.navigate(SHARE_B);
|
||||
assert.equal(handoffs.length, 1);
|
||||
assert.deepEqual(handoffs[0].options, { trackingOrigin: 'programmatic' });
|
||||
});
|
||||
|
||||
test('a navigated share that fails to start never freezes the address bar', async (t) => {
|
||||
const settled = [];
|
||||
let calls = 0;
|
||||
const { tab, manager } = restorationHarness(t, SHARE_A, {
|
||||
// Start-up reserves the camera; the navigated share then fails to.
|
||||
_beginDeferredNavigation: () => {
|
||||
calls += 1;
|
||||
if (calls > 1) throw new Error('navigation unavailable');
|
||||
return calls;
|
||||
},
|
||||
});
|
||||
window.addEventListener('gev:share-navigation-restore-settled', (event) =>
|
||||
settled.push(event.detail),
|
||||
);
|
||||
tab.navigate(SHARE_B);
|
||||
assert.equal(manager._initialRestorePending, false);
|
||||
assert.equal(settled[0]?.status, 'failed');
|
||||
});
|
||||
|
||||
test('a second navigation during a restore supersedes it end to end', async (t) => {
|
||||
const { tab, manager, restores, layerCalls } = restorationHarness(t, SHARE_A);
|
||||
tab.navigate(SHARE_B);
|
||||
tab.navigate(SHARE_A);
|
||||
assert.equal(restores.length, 2);
|
||||
assert.equal(layerCalls.length, 2);
|
||||
assert.deepEqual(layerCalls[1].shareLayerState.enabledLayerIds, [
|
||||
'earthquakes',
|
||||
]);
|
||||
|
||||
// The newer restore finishes first; the older one settling late must not
|
||||
// release or reopen anything.
|
||||
restores[1].gate.resolve();
|
||||
await flush();
|
||||
assert.equal(manager._initialRestorePending, false);
|
||||
manager._initialRestorePending = true; // observe any late release
|
||||
restores[0].gate.resolve();
|
||||
await flush();
|
||||
assert.equal(manager._initialRestorePending, true);
|
||||
});
|
||||
|
||||
test('the layer coordinator replaces in-flight share layers with the navigated share', async () => {
|
||||
const enableGates = new Map();
|
||||
const manager = new DataLayerManager({});
|
||||
for (const id of REGISTERED_LAYER_IDS) {
|
||||
manager.register({
|
||||
id,
|
||||
name: id,
|
||||
icon: '',
|
||||
source: 'test',
|
||||
async init() {
|
||||
return true;
|
||||
},
|
||||
async enable() {
|
||||
const gate = enableGates.get(id);
|
||||
if (gate) await gate.promise;
|
||||
return true;
|
||||
},
|
||||
async update() {
|
||||
return true;
|
||||
},
|
||||
async disable() {
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
manager.finalizeRegistrations(LAYER_STATE_REGISTRY);
|
||||
const quakeGate = deferred();
|
||||
enableGates.set('earthquakes', quakeGate);
|
||||
const writes = [];
|
||||
const storage = {
|
||||
getItem: () => null,
|
||||
setItem: (...args) => writes.push(args),
|
||||
};
|
||||
const sink = {
|
||||
provider: null,
|
||||
setLayerStateProvider(provider) {
|
||||
this.provider = provider;
|
||||
},
|
||||
onLayerStateChange() {},
|
||||
};
|
||||
const coordinator = new LayerStateCoordinator(manager, sink, { storage });
|
||||
const first = coordinator.start({
|
||||
shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_A)),
|
||||
allowLocalState: false,
|
||||
});
|
||||
const second = coordinator.applyShareLayerState({
|
||||
shareLayerState: decodeLayerStateParams(new URLSearchParams(SHARE_B)),
|
||||
});
|
||||
quakeGate.resolve();
|
||||
await Promise.all([first, second]);
|
||||
const enabled = (id) => manager.getLayerLifecycleState(id)?.enabled === true;
|
||||
assert.equal(enabled('earthquakes'), false);
|
||||
assert.equal(enabled('flights'), true);
|
||||
assert.deepEqual(sink.provider().enabledLayerIds, ['flights']);
|
||||
assert.equal(coordinator.source, 'share');
|
||||
assert.deepEqual(
|
||||
writes,
|
||||
[],
|
||||
'a passive share restore never writes local preferences',
|
||||
);
|
||||
|
||||
// A share without a layer payload restores the defaults, as a reload does.
|
||||
await coordinator.applyShareLayerState({ shareLayerState: null });
|
||||
assert.equal(coordinator.source, 'legacy-share');
|
||||
assert.deepEqual(
|
||||
sink.provider().enabledLayerIds,
|
||||
createDefaultLayerState().enabledLayerIds,
|
||||
);
|
||||
assert.equal(
|
||||
enabled('flights'),
|
||||
createDefaultLayerState().enabledLayerIds.includes('flights'),
|
||||
);
|
||||
coordinator.destroy();
|
||||
});
|
||||
@@ -241,7 +241,7 @@ export class NavigationController {
|
||||
});
|
||||
}
|
||||
|
||||
_reassertNavigationHandoff(generation) {
|
||||
_reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {
|
||||
return reassertNavigationHandoff({
|
||||
generation,
|
||||
currentGeneration: this._navigationGeneration,
|
||||
@@ -253,7 +253,7 @@ export class NavigationController {
|
||||
// was refused by the cockpit leaves the old readout standing.
|
||||
release: () => {
|
||||
this.clearLocation();
|
||||
return this._releaseFollowCamera();
|
||||
return this._releaseFollowCamera({ trackingOrigin });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+128
-37
@@ -80,6 +80,10 @@ export class ShareRestoration {
|
||||
this._initialShareRestoreTimeout = null;
|
||||
this._layerStateCoordinator = null;
|
||||
this._layerStateRestorePromise = null;
|
||||
// Bumped by every share opened after start-up; an older restore that
|
||||
// settles later must neither apply follow-up work nor release URL writes.
|
||||
this._shareRestoreGeneration = 0;
|
||||
this._navigatedShareRestoreActive = false;
|
||||
}
|
||||
attachLinks(shareLinkManager) {
|
||||
this.shareLinkManager = shareLinkManager;
|
||||
@@ -108,42 +112,12 @@ export class ShareRestoration {
|
||||
const applyCamera =
|
||||
Number.isInteger(generation) &&
|
||||
this.navigation._reassertNavigationHandoff(generation);
|
||||
void (async () => {
|
||||
try {
|
||||
const share = await this.shareLinkManager.applyState(savedState, {
|
||||
applyCamera,
|
||||
navigationToken: generation,
|
||||
});
|
||||
const layers = await (this._layerStateRestorePromise ||
|
||||
Promise.resolve([]));
|
||||
const tracking =
|
||||
share.camera === 'applied'
|
||||
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
|
||||
: {
|
||||
status: 'superseded',
|
||||
cleared:
|
||||
this._layerStateCoordinator?.cancelPendingShareTracking?.(
|
||||
'shared-camera-superseded',
|
||||
{ clearSelection: true },
|
||||
) === true,
|
||||
};
|
||||
this.shareLinkManager.completeInitialRestore();
|
||||
this._settleInitialShareRestore({
|
||||
status: 'settled',
|
||||
share,
|
||||
layers,
|
||||
tracking,
|
||||
});
|
||||
} catch (error) {
|
||||
this.shareLinkManager.completeInitialRestore();
|
||||
this._settleInitialShareRestore({
|
||||
status: 'failed',
|
||||
error: String(error?.message || error),
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
}
|
||||
})();
|
||||
void this._runShareRestore(savedState, {
|
||||
applyCamera,
|
||||
navigationToken: generation,
|
||||
layersPromise: this._layerStateRestorePromise,
|
||||
settle: (result) => this._settleInitialShareRestore(result),
|
||||
});
|
||||
}, 1500);
|
||||
} else {
|
||||
this.syncShareState();
|
||||
@@ -155,7 +129,8 @@ export class ShareRestoration {
|
||||
if (
|
||||
this._disposed ||
|
||||
!this._hasShareState ||
|
||||
!this._resolveInitialShareRestore
|
||||
(!this._resolveInitialShareRestore &&
|
||||
!this._navigatedShareRestoreActive)
|
||||
)
|
||||
return;
|
||||
stampInitialShareGesture((options) =>
|
||||
@@ -176,6 +151,122 @@ export class ShareRestoration {
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
// A different share opened in this tab later (pasted address, link,
|
||||
// Back/Forward) runs the same restore as start-up.
|
||||
this.shareLinkManager?.watchNavigation?.((state) =>
|
||||
this._restoreNavigatedShare(state),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one incoming share: camera, visual, map and panels, then layers and
|
||||
* any shared Follow. Only the newest share may finish: a superseded run
|
||||
* stops before follow-up work and leaves URL writes suppressed for the
|
||||
* newer one.
|
||||
*/
|
||||
async _runShareRestore(
|
||||
state,
|
||||
{ applyCamera, navigationToken, layersPromise, settle },
|
||||
) {
|
||||
const generation = this._shareRestoreGeneration;
|
||||
const current = () =>
|
||||
!this._disposed && generation === this._shareRestoreGeneration;
|
||||
try {
|
||||
const share = await this.shareLinkManager.applyState(state, {
|
||||
applyCamera,
|
||||
navigationToken,
|
||||
});
|
||||
const layers = await (layersPromise || Promise.resolve([]));
|
||||
if (!current()) return;
|
||||
const tracking =
|
||||
share.camera === 'applied'
|
||||
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
|
||||
: {
|
||||
status: 'superseded',
|
||||
cleared:
|
||||
this._layerStateCoordinator?.cancelPendingShareTracking?.(
|
||||
'shared-camera-superseded',
|
||||
{ clearSelection: true },
|
||||
) === true,
|
||||
};
|
||||
if (!current()) return;
|
||||
this.shareLinkManager.completeInitialRestore(state.restoreToken);
|
||||
settle({ status: 'settled', share, layers, tracking });
|
||||
} catch (error) {
|
||||
if (!current()) return;
|
||||
this.shareLinkManager.completeInitialRestore(state.restoreToken);
|
||||
settle({
|
||||
status: 'failed',
|
||||
error: String(error?.message || error),
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a share opened in this tab after start-up, superseding any other. */
|
||||
_restoreNavigatedShare(state) {
|
||||
if (this._disposed || !state) return;
|
||||
this._shareRestoreGeneration += 1;
|
||||
const settle = (result) => {
|
||||
this._navigatedShareRestoreActive = false;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:share-navigation-restore-settled', {
|
||||
detail: result,
|
||||
}),
|
||||
);
|
||||
};
|
||||
try {
|
||||
return this._beginNavigatedShareRestore(state, settle);
|
||||
} catch (error) {
|
||||
// Never leave the address bar frozen on a restore that did not start.
|
||||
this.shareLinkManager.completeInitialRestore(state.restoreToken);
|
||||
settle({
|
||||
status: 'failed',
|
||||
error: String(error?.message || error),
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_beginNavigatedShareRestore(state, settle) {
|
||||
// A start-up restore still waiting for its delayed flight never runs.
|
||||
clearTimeout(this._initialShareRestoreTimeout);
|
||||
this._initialShareRestoreTimeout = null;
|
||||
this._settleInitialShareRestore({
|
||||
status: 'superseded',
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
this._initialShareState = state;
|
||||
this._initialShareSelectionSuperseded = false;
|
||||
this._hasShareState = true;
|
||||
this._navigatedShareRestoreActive = true;
|
||||
// A new deferred navigation generation retires any older share flight.
|
||||
const navigationToken = this.navigation._beginDeferredNavigation(
|
||||
'shared view',
|
||||
{ cancelPendingSelection: false },
|
||||
);
|
||||
// Releasing the previous Follow is part of the passive share restore and
|
||||
// must not be saved as the recipient's own layer preference.
|
||||
const applyCamera =
|
||||
Number.isInteger(navigationToken) &&
|
||||
this.navigation._reassertNavigationHandoff(navigationToken, {
|
||||
trackingOrigin: 'programmatic',
|
||||
});
|
||||
const layersPromise = this._layerStateCoordinator
|
||||
? this._layerStateCoordinator.applyShareLayerState({
|
||||
shareLayerState: state.layerState || null,
|
||||
shareCreatedAtMs: state.sharedAtMs ?? null,
|
||||
})
|
||||
: null;
|
||||
return this._runShareRestore(state, {
|
||||
applyCamera,
|
||||
navigationToken,
|
||||
layersPromise,
|
||||
settle,
|
||||
});
|
||||
}
|
||||
connect(dataManager) {
|
||||
this._dataManager = dataManager;
|
||||
|
||||
@@ -75,13 +75,24 @@ export class VisualSettings {
|
||||
panelState,
|
||||
styleParams,
|
||||
} = state || {};
|
||||
// Ignore the retired 'ai-edit' style from older share links.
|
||||
if (style && style !== 'normal' && style !== 'ai-edit') {
|
||||
// Ignore the retired 'ai-edit' style from older share links. Normal is
|
||||
// the start-up style, but a share opened later in the tab must still leave
|
||||
// a filtered style for it.
|
||||
if (
|
||||
style &&
|
||||
style !== 'ai-edit' &&
|
||||
(style !== 'normal' || this.activeStyle !== 'normal')
|
||||
) {
|
||||
const alreadyActive = style === this.activeStyle;
|
||||
this.setStyle(style, {
|
||||
applyPreset: true,
|
||||
revealParameters: false,
|
||||
restore: true,
|
||||
});
|
||||
// setStyle leaves an already-active style untouched; the incoming share
|
||||
// still starts from that preset before its own parameters apply.
|
||||
if (alreadyActive && style !== 'normal')
|
||||
this._applyStylePresetDefaults(style);
|
||||
}
|
||||
if (styleParams && style && this.stages[style] && STYLES[style]?.uniforms) {
|
||||
for (const [uniformName, uniformValue] of Object.entries(styleParams)) {
|
||||
|
||||
Reference in new issue
Block a user