Suspend only the layers Context isolation is switching off

The session snapshot recorded a user's switch only after the CCTV panel had
already rendered the layer off, so a first switch-off by the user inside
Contacts, or after a partly failed restore, still kept the panel open.
Context controls now hold the layers _clearLayersOutsideContextMode is
switching off while its transitions run; suspendsLayer reads that set, and
the snapshot is no longer consulted.
This commit is contained in:
Sameh Khamis committed 2026-11-13 18:32:57 -08:00
1 parent f0ee1b95ca
commit 9b46692c64
6 files changed
+50 -63

No files matched your search

+3 -1
View File
@@ -2235,7 +2235,9 @@ the collapse, so a panel the desktop accordion only folded has its saved and
shared state closed too. Clear selected data layers and Context isolation shared state closed too. Clear selected data layers and Context isolation
(Contacts, Space Missions) switch the layer off without the user asking: the (Contacts, Space Missions) switch the layer off without the user asking: the
panel and its share preference stay as they are, and the layer comes back to an panel and its share preference stay as they are, and the layer comes back to an
open panel. A panel opened by hand while the layer is off stays open, and its open panel. Only the switch-off isolation makes counts (`suspendsLayer`, true
while `_clearLayersOutsideContextMode` switches the layer off): the user
switching CCTV off inside Contacts closes the panel as anywhere else. A panel opened by hand while the layer is off stays open, and its
meta line reads the catalog count, not the last active camera. meta line reads the catalog count, not the last active camera.
## UI disposal ## UI disposal
-1
View File
@@ -277,7 +277,6 @@ export function recordContextSessionUserChange({
) { ) {
return false; return false;
} }
snapshot.userSwitched?.add(change.layerId);
const isCompanion = CONTEXT_COMPANIONS.has(change.layerId); const isCompanion = CONTEXT_COMPANIONS.has(change.layerId);
if (change.enabled) { if (change.enabled) {
if (CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId)) return false; if (CONTEXT_ENTRY_LAYER_IDS.includes(change.layerId)) return false;
+6 -8
View File
@@ -51,6 +51,8 @@ export class ContextControls {
this._contextRestoreState = null; this._contextRestoreState = null;
this._contextLayerReactionPromises = new Set(); this._contextLayerReactionPromises = new Set();
this._preservePanelStateDuringLayerClear = false; this._preservePanelStateDuringLayerClear = false;
/** Layers Context isolation is switching off right now. */
this._isolatingLayerIds = new Set();
this._userFacingContextNotificationTokens = new Set(); this._userFacingContextNotificationTokens = new Set();
this._clearSelectedLayersPromise = null; this._clearSelectedLayersPromise = null;
this._clearSelectedLayersManagerPromise = null; this._clearSelectedLayersManagerPromise = null;
@@ -60,18 +62,14 @@ export class ContextControls {
if (!this.destroyed) this.actions.showToast(...args); if (!this.destroyed) this.actions.showToast(...args);
} }
/** /**
* Whether Context isolation switched this layer off and leaving the mode * Whether Context isolation is switching this layer off right now: it is
* restores it. Such a layer is suspended, not switched off by the user: a * suspended, and leaving the mode restores it. A switch-off by the user,
* layer the user switched during the session is theirs again. * inside the mode or out of it, is not.
* @param {string} layerId Layer id. * @param {string} layerId Layer id.
* @returns {boolean} * @returns {boolean}
*/ */
suspendsLayer(layerId) { suspendsLayer(layerId) {
const snapshot = this._contextSessionSnapshot; return this._isolatingLayerIds.has(layerId);
return Boolean(
snapshot?.enabledLayerIds?.has(layerId) &&
!snapshot.userSwitched?.has(layerId),
);
} }
setClearBusy(...args) { setClearBusy(...args) {
if (!this.destroyed) this.actions.setClearBusy(...args); if (!this.destroyed) this.actions.setClearBusy(...args);
+14 -33
View File
@@ -1,7 +1,6 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { test } from 'node:test'; import { test } from 'node:test';
import { ContextControls } from './contextControls.js'; import { ContextControls } from './contextControls.js';
import { recordContextSessionUserChange } from '../contextModePolicy.js';
class Button extends EventTarget { class Button extends EventTarget {
constructor() { constructor() {
@@ -155,7 +154,7 @@ test('a rejected tracked reaction settles without an unhandled rejection', async
assert.equal(controls._contextLayerReactionPromises.size, 0); assert.equal(controls._contextLayerReactionPromises.size, 0);
}); });
test('a layer Context isolation switches off is suspended until the mode is left', async (t) => { test('only a layer Context isolation is switching off is suspended', async (t) => {
const { controls } = fixture(t); const { controls } = fixture(t);
const enabled = new Set(['cctv']); const enabled = new Set(['cctv']);
// What the CCTV panel is told at each visibility change of its layer. // What the CCTV panel is told at each visibility change of its layer.
@@ -168,6 +167,7 @@ test('a layer Context isolation switches off is suspended until the mode is left
isEnabled: (layerId) => enabled.has(layerId), isEnabled: (layerId) => enabled.has(layerId),
isEffectivelyEnabled: (layerId) => enabled.has(layerId), isEffectivelyEnabled: (layerId) => enabled.has(layerId),
async setEnabled(layerId, next) { async setEnabled(layerId, next) {
// The layer tells its panel it is off while it is being disabled.
if (next) enabled.add(layerId); if (next) enabled.add(layerId);
else enabled.delete(layerId); else enabled.delete(layerId);
seen.push([layerId, next, controls.suspendsLayer(layerId)]); seen.push([layerId, next, controls.suspendsLayer(layerId)]);
@@ -181,47 +181,28 @@ test('a layer Context isolation switches off is suspended until the mode is left
controls._captureContextSessionSnapshot(); controls._captureContextSessionSnapshot();
await controls._clearLayersOutsideContextMode('flights'); await controls._clearLayersOutsideContextMode('flights');
assert.deepEqual(seen, [['cctv', false, true]]); assert.deepEqual(seen, [['cctv', false, true]]);
assert.equal(controls.suspendsLayer('cctv'), true); assert.equal(controls.suspendsLayer('cctv'), false, 'only while isolating');
assert.equal(controls.suspendsLayer('street-level'), false); // Inside the mode the user turns CCTV back on, then off: their own.
await controls._dataManager.setEnabled('cctv', true);
await controls._dataManager.setEnabled('cctv', false);
assert.deepEqual(seen.at(-1), ['cctv', false, false]);
await controls._restoreContextSession(); await controls._restoreContextSession();
assert.equal(enabled.has('cctv'), true); assert.equal(enabled.has('cctv'), true);
assert.equal(controls.suspendsLayer('cctv'), false); // A switch-off after the mode is left is the user's own too.
// A switch-off after the mode is left is the user's own.
await controls._dataManager.setEnabled('cctv', false); await controls._dataManager.setEnabled('cctv', false);
assert.deepEqual(seen.at(-1), ['cctv', false, false]); assert.deepEqual(seen.at(-1), ['cctv', false, false]);
}); });
test('a layer the user switches inside the mode is no longer suspended', async (t) => { test('a failed isolation leaves no layer suspended', async (t) => {
const { controls } = fixture(t); const { controls } = fixture(t);
const enabled = new Set(['cctv']);
controls._dataManager = { controls._dataManager = {
layers: new Map([['cctv', {}]]), layers: new Map([['cctv', {}]]),
getEnabledLayerIds: () => [...enabled], isEffectivelyEnabled: () => true,
getLayerParams: () => null, isEnabled: () => true,
isEffectivelyEnabled: (layerId) => enabled.has(layerId), setEnabled: async () => {
isEnabled: (layerId) => enabled.has(layerId), throw new Error('disable failed');
async setEnabled(layerId, next) {
if (next) enabled.add(layerId);
else enabled.delete(layerId);
return true;
}, },
}; };
controls._captureContextSessionSnapshot(); await assert.rejects(controls._clearLayersOutsideContextMode('flights'));
await controls._clearLayersOutsideContextMode('flights');
assert.equal(controls.suspendsLayer('cctv'), true);
// Inside Contacts the user turns CCTV back on, then off again: the panel
// must close like any other switch-off.
for (const next of [true, false]) {
recordContextSessionUserChange({
snapshot: controls._contextSessionSnapshot,
change: {
type: 'visibility',
origin: 'user',
layerId: 'cctv',
enabled: next,
},
effectiveContextMode: 'flights',
});
}
assert.equal(controls.suspendsLayer('cctv'), false); assert.equal(controls.suspendsLayer('cctv'), false);
}); });
-3
View File
@@ -36,8 +36,6 @@ export function _captureContextSessionSnapshot({ excludeLayerIds = [] } = {}) {
), ),
userAdded: new Set(), userAdded: new Set(),
userRemoved: new Set(), userRemoved: new Set(),
// Every layer the user switched during the session, either way.
userSwitched: new Set(),
params, params,
}; };
} }
@@ -151,7 +149,6 @@ export async function _restoreContextSession({
enabledLayerIds: new Set(restoreState.enabledLayerIds), enabledLayerIds: new Set(restoreState.enabledLayerIds),
userAdded: new Set(), userAdded: new Set(),
userRemoved: new Set(), userRemoved: new Set(),
userSwitched: new Set(),
params: snapshot.params, params: snapshot.params,
}; };
} }
+27 -17
View File
@@ -240,25 +240,35 @@ export async function _clearLayersOutsideContextMode(
) { ) {
const allowed = contextAllowedLayerIds(mode); const allowed = contextAllowedLayerIds(mode);
const pending = []; const pending = [];
for (const [layerId] of this._dataManager.layers || []) { // Panels read this while their layer goes off (suspendsLayer): a layer
// Effective visibility: a disallowed layer still mid-ENABLING must be // isolation switches off is suspended, not switched off by the user.
// isolated too, or it settles ON inside the exclusive mode. const isolating = [];
if ( let results;
!allowed.has(layerId) && try {
this._dataManager.isEffectivelyEnabled(layerId) for (const [layerId] of this._dataManager.layers || []) {
) { // Effective visibility: a disallowed layer still mid-ENABLING must be
pending.push({ // isolated too, or it settles ON inside the exclusive mode.
layerId, if (
transition: this._dataManager.setEnabled(layerId, false, { !allowed.has(layerId) &&
notificationToken, this._dataManager.isEffectivelyEnabled(layerId)
...(signal ? { signal } : {}), ) {
}), if (!this._isolatingLayerIds.has(layerId)) {
}); this._isolatingLayerIds.add(layerId);
isolating.push(layerId);
}
pending.push({
layerId,
transition: this._dataManager.setEnabled(layerId, false, {
notificationToken,
...(signal ? { signal } : {}),
}),
});
}
} }
results = await Promise.all(pending.map(({ transition }) => transition));
} finally {
for (const layerId of isolating) this._isolatingLayerIds.delete(layerId);
} }
const results = await Promise.all(
pending.map(({ transition }) => transition),
);
const failed = pending const failed = pending
.filter( .filter(
({ layerId }, index) => ({ layerId }, index) =>