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:
Sameh Khamis authored and GitHub committed 2026-10-26 13:56:19 -07:00
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).
+43 -5
View File
@@ -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();
}
}
+4 -2
View File
@@ -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
View File
@@ -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;
+425
View File
@@ -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();
});
+2 -2
View File
@@ -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
View File
@@ -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;
+13 -2
View File
@@ -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)) {