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

+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)) {