Files
gods-eye-view/src/sharelink.navigation.test.mjs
T
Sameh Khamis b7ece1d4d1 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
2026-10-26 13:56:19 -07:00

426 lines
14 KiB
JavaScript

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();
});