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

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