fix(street-level): retire lookups and preserve embedded photos

Clear cancelled lookup loading over an existing photo, release caller abort listeners on internal retirement, surface embedded errors, and permit embedded photos over the clean globe. Add seven regression cases and update runtime documentation. Preserve the published localization and other review fixes.
This commit is contained in:
Said Turksever committed 2026-11-03 15:40:46 +00:00
1 parent 0ec57057fa
commit 7703090d90
10 files changed
+175 -12

No files matched your search

+3 -3
View File
@@ -724,7 +724,7 @@ export function createStreetLevelLayer({
if (nearestLookup !== lookup) return false;
nearestLookup = null;
// The caller moved on: the panel shows nothing of this lookup.
if (state.street.loading && !state.street.open) {
if (state.street.loading) {
state.street.loading = false;
notify();
}
@@ -751,13 +751,13 @@ export function createStreetLevelLayer({
} catch (error) {
if (callerSignal?.aborted) return retired();
// Overtaken: whatever replaced it owns the panel, errors included.
if (signal.aborted) return false;
if (signal.aborted) return retired();
// One provider failing (no key, offline) must not hide the others.
lastError = error;
continue;
}
if (callerSignal?.aborted) return retired();
if (signal.aborted) return false;
if (signal.aborted) return retired();
if (!state.enabled) break;
// Switched off, or its map left, while it looked: try the next.
if (!imageId || !entry.on || !providerAvailable(entry)) continue;
+80
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getEventListeners } from 'node:events';
import * as Cesium from 'cesium';
import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
@@ -1666,6 +1667,85 @@ test('openNearest keeps honouring the caller after the lookup: an abort while th
assert.equal(layer.getUIState().street.loading, false);
});
for (const rejectsOnAbort of [true, false]) {
test(`cancelling a lookup over an open photo clears its spinner (provider rejects: ${rejectsOnAbort})`, async (t) => {
const answer = deferred();
const provider = fakeProvider({
nearestImage: (_point, { signal }) => {
if (rejectsOnAbort)
signal.addEventListener('abort', () => answer.reject(signal.reason), {
once: true,
});
return answer.promise;
},
});
const { layer } = await enabledLayer(t, [provider]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
layer.attachViewerHost(fakeHost());
assert.equal(
await layer.openImage('mapillary', 'existing', { frame: false }),
true,
);
const controller = new AbortController();
const pending = layer.openNearest(
{ lat: 1, lon: 2 },
{ frame: false, signal: controller.signal },
);
assert.equal(layer.getUIState().street.loading, true);
controller.abort();
if (!rejectsOnAbort) answer.resolve('late-image');
assert.equal(await pending, false);
const street = layer.getUIState().street;
assert.equal(
street.loading,
false,
'the cancelled lookup owns and clears the spinner',
);
assert.equal(street.open, true, 'the existing photo remains open');
assert.equal(street.error, null);
assert.deepEqual(
provider.calls.open,
['existing'],
'no replacement photo opens',
);
});
}
for (const rejectsOnAbort of [true, false]) {
test(`retired lookups release the caller listener (provider rejects: ${rejectsOnAbort})`, async (t) => {
let answer;
const provider = fakeProvider({
nearestImage: (_point, { signal }) => {
answer = deferred();
if (rejectsOnAbort)
signal.addEventListener('abort', () => answer.reject(signal.reason), {
once: true,
});
return answer.promise;
},
});
const { layer } = await enabledLayer(t, [provider]);
const lifetime = new AbortController();
for (let i = 0; i < 3; i++) {
const pending = layer.openNearest(
{ lat: 1, lon: 2 },
{ frame: false, signal: lifetime.signal },
);
assert.equal(getEventListeners(lifetime.signal, 'abort').length, 1);
layer.closeViewer();
if (!rejectsOnAbort) answer.resolve('late-image');
assert.equal(await pending, false);
assert.equal(
getEventListeners(lifetime.signal, 'abort').length,
0,
'closing the lookup releases its listener on the application lifetime',
);
}
assert.equal(lifetime.signal.aborted, false);
});
}
test('an aborted older lookup leaves its replacement’s spinner alone', async (t) => {
const answers = [];
const provider = fakeProvider({
+9
View File
@@ -19,6 +19,8 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
const expanded = shallowRef(null);
/** False on a page with neither element fullscreen nor popovers. */
const expandAvailable = shallowRef(true);
/** Whether the photo is shown over the globe on an embedded page. */
const embedOverlay = shallowRef(false);
/** @type {{handlers: StreetLevelHandlers}|null} */
let owner = null;
@@ -47,6 +49,11 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
expandAvailable.value = Boolean(available);
}
/** @param {boolean} on */
function setEmbedOverlay(on) {
embedOverlay.value = Boolean(on);
}
/**
* Stop forwarding actions to the owner holding `token`. The panel keeps
* its last render, as the page did when its controls were wired by hand.
@@ -69,10 +76,12 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
view,
expanded,
expandAvailable,
embedOverlay,
attach,
publish,
setExpanded,
setExpandAvailable,
setEmbedOverlay,
detach,
act,
describeSinceStop,
+1
View File
@@ -318,6 +318,7 @@ export class StreetLevelControls {
const wrap = this._elements.viewerWrap;
if (!wrap || !document.body) return;
const lift = open === true && isEmbedPage();
this.store.setEmbedOverlay(lift);
if (lift && !this._embedHome) {
this._embedHome = { parent: wrap.parentNode, next: wrap.nextSibling };
document.body.appendChild(wrap);
+33
View File
@@ -1038,3 +1038,36 @@ test('destroying the panel in embed mode puts an open photo back in the panel',
assert.equal(overGlobe(dom), false);
assertSameNode(dom.wrap.parentNode, home);
}));
test('an embedded photo failure is shown inside the overlay, and only there', () =>
withDom(async (dom) => {
dom.document.body.classList.add('ui-embed');
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
const failed = uiState({ open: true });
Object.assign(failed.street, {
imageId: null,
error: 'Image could not be opened',
});
await layer.publish(failed);
assert.equal(overGlobe(dom), true);
const error = dom.wrap.querySelector('#sl-viewer-error');
assert.ok(error, 'the overlay contains an error region');
assert.equal(error.hidden, false);
assert.match(error.textContent, /Image could not be opened/);
assert.equal(dom.root.querySelector('#sl-error').hidden, true);
await layer.publish(uiState({ open: true }));
assert.equal(error.hidden, true, 'a successful retry clears the error');
controls.destroy();
}));
test('outside embed mode a photo failure stays in the panel error region', () =>
withDom(async (dom) => {
const failed = uiState({ open: true });
failed.street.error = 'Image could not be opened';
const { controls } = await stubPanel(dom, failed);
const error = dom.wrap.querySelector('#sl-viewer-error');
assert.ok(error);
assert.equal(error.hidden, true, 'no duplicate error inside the photo');
assert.equal(dom.root.querySelector('#sl-error').hidden, false);
controls.destroy();
}));
+3 -2
View File
@@ -13,7 +13,7 @@ import UiSlider from '../../kit/UiSlider.js';
// Until it renders, the panel shows its initial markup. The viewer element
// belongs to the layer and EXPAND to the controls.
const store = useStreetLevelStore();
const { view, expanded, expandAvailable } = storeToRefs(store);
const { view, expanded, expandAvailable, embedOverlay } = storeToRefs(store);
const status = computed(
() =>
@@ -142,6 +142,7 @@ const vThumb = {
<button id="sl-viewer-close" class="sl-viewer-x" type="button" :title="t('Close the image and deselect it on the map')" :aria-label="t('Close the street-level image')">×</button>
</div>
</div>
<div id="sl-viewer-error" class="sl-error" role="alert" :hidden="!embedOverlay || !view?.error"><span aria-hidden="true">⚠</span> {{ view?.error || '' }}</div>
<div class="sl-viewer-stage">
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" :hidden="!viewer.loading"></div>
<div id="sl-viewer" class="sl-viewer" :aria-label="t('Street-level viewer')"></div>
@@ -158,7 +159,7 @@ const vThumb = {
<span class="cctv-summary-label">{{ t('PROVIDERS') }}</span>
<div id="sl-provider-chips" class="sl-chips"><RailChips :chips="view ? view.providers : []" /></div>
</div>
<div id="sl-error" class="sl-error" role="alert" :hidden="!view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
<div id="sl-error" class="sl-error" role="alert" :hidden="embedOverlay || !view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
<!-- A missing key gates the filters only: the chips above stay
focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls" :disabled="Boolean(view?.controlsDisabled)" :hidden="!shows.pano && !shows.since">
+10 -2
View File
@@ -3440,9 +3440,17 @@ export async function showStreetLevel(
}
// Cockpit, clean view, recording and scene playback hide the panel the
// photo opens in: say so before anything loads unseen.
// photo opens in: say so before anything loads unseen. Embed mode uses
// clean view for its globe but lifts the photo out of the hidden rail.
const body = globalThis.document?.body;
if (EXCLUSIVE_SURFACE_CLASSES.some((mode) => body?.classList?.contains(mode)))
const embedded = body?.classList?.contains('ui-embed') === true;
if (
EXCLUSIVE_SURFACE_CLASSES.some(
(mode) =>
body?.classList?.contains(mode) &&
!(embedded && mode === 'ui-clean-view'),
)
)
return fail(
'view',
msg(
+22
View File
@@ -4668,3 +4668,25 @@ test('show_street_level in a view that hides its panel stops before anything loa
}
}
});
test('show_street_level opens over the clean embedded globe but respects its other exclusive modes', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const saved = globalThis.document;
try {
for (const mode of [null, 'cockpit-mode', 'recording-mode', 'scene-playback-mode']) {
const classes = new Set(['ui-embed', 'ui-clean-view', mode]);
globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } };
const { runner, order } = createStreetLevelHarness({ enabled: true });
const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
if (mode) {
assert.deepEqual([result.ok, result.stage], [false, 'view'], mode);
assert.deepEqual(order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], `${mode}: nothing switched`);
} else {
assert.equal(result.ok, true, 'embed clean view lifts the photo over the globe');
assert.ok(order.some((step) => step.startsWith('open:mapillary')));
}
}
} finally {
globalThis.document = saved;
}
});