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

+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;