diff --git a/src/library/controller.js b/src/library/controller.js index 6be6d29ca..9fdecd041 100644 --- a/src/library/controller.js +++ b/src/library/controller.js @@ -184,6 +184,28 @@ export function createDataLibrary({ persist(); }); + /** + * The library after a share's entries join it. A share always fits: in a + * full library the oldest entries it leaves off make room for it, so a + * link opened into a full library still shows what it shares. + */ + function withShared(added, linked) { + const kept = [...new Set(added)].filter((id) => switchable.has(id)); + const incoming = [...new Set(linked)].filter( + (id) => switchable.has(id) && !kept.includes(id), + ); + let room = MAX_SELECTED - kept.length - incoming.length; + const result = []; + for (const id of kept) { + if (room < 0 && !linked.includes(id)) { + room += 1; + continue; + } + result.push(id); + } + return [...result, ...incoming]; + } + /** Entry ids for share tokens; unknown tokens are skipped. */ const idsForTokens = (list) => (list || []).map((token) => tokens.idByToken.get(token)).filter(Boolean); @@ -212,7 +234,17 @@ export function createDataLibrary({ * is never remembered as the viewer's own choice. */ function applySelection(next, origin) { + const before = selection.added; selection = normalizeLibrarySelection(next, switchable); + // Entries a share made room for leave with their layers. + for (const id of before) { + if (selection.added.includes(id) || !isRegistered(id)) continue; + dataManager + .unregisterLibraryLayer(libraryLayerId(id)) + .catch((error) => + console.warn(`[Library] ${id} could not be removed:`, error), + ); + } for (const id of selection.added) { const entry = byId.get(id); if (!entry || !addable(id)) continue; @@ -251,7 +283,7 @@ export function createDataLibrary({ } const linked = idsForTokens(shareTokens); applySelection( - { added: [...selection.added, ...linked], on: linked }, + { added: withShared(selection.added, linked), on: linked }, 'share-restore', ); } @@ -297,7 +329,10 @@ export function createDataLibrary({ // Only what the selection keeps registers: past its cap an entry would // be a row the library no longer knows. const next = normalizeLibrarySelection( - { added: [...stored.added, ...(linked || [])], on: linked ?? stored.on }, + { + added: linked ? withShared(stored.added, linked) : stored.added, + on: linked ?? stored.on, + }, switchable, ); for (const id of next.added) { @@ -423,7 +458,9 @@ export function createDataLibrary({ entry, available: state.available, reason: state.reason, - added: selection.added.includes(entry.id) && isRegistered(entry.id), + // An entry this instance cannot serve now stays added (and can be + // removed) without a layer: it still holds a place in the library. + added: selection.added.includes(entry.id), on: dataManager.isEnabled?.(libraryLayerId(entry.id)) === true, busy: pending.has(entry.id), canZoom: typeof zoomTo === 'function' && isRegistered(entry.id), diff --git a/src/library/controller.test.mjs b/src/library/controller.test.mjs index 51daf7f2a..46bed73ca 100644 --- a/src/library/controller.test.mjs +++ b/src/library/controller.test.mjs @@ -418,25 +418,92 @@ test('a full library refuses another entry before its layer exists', async (t) = assert.equal(manager.layers.size, 64); assert.equal(await library.add('entry-64'), false); assert.equal(manager.layers.size, 64, 'no layer outside the selection'); - // A share into a full library adds no row the library cannot keep. - const shared = sealedManager(t); - const opened = createDataLibrary({ + library.destroy(); +}); + +test('a share opened into a full library still shows what it shares', async (t) => { + const many = validateLibraryCatalog( + Array.from({ length: 67 }, (_, index) => tiles(`entry-${index}`)), + ).entries; + const manyLedger = many.map(({ id }, index) => [ + id, + index.toString(36).padStart(2, '0'), + ]); + const availability = { + async getAvailability() { + return { + server: true, + availability: new Map( + many.map(({ id }) => [id, { available: true, reason: null }]), + ), + }; + }, + tileTemplate: (entry) => entry.source.url, + }; + const full = JSON.stringify({ + added: many.slice(0, 64).map(({ id }) => id), + on: ['entry-0'], + }); + const manager = sealedManager(t); + const shareLink = { + ...shareLinkDouble(), + initialLibraryTokens: [manyLedger[64][1]], + listener: null, + setLibraryShareListener(listener) { + this.listener = listener; + }, + }; + const library = createDataLibrary({ entries: many, ledger: manyLedger, - dataManager: shared, + dataManager: manager, services: {}, storage: memoryStorage(full), - shareLink: fakeShareLink([manyLedger[65][1]]), + shareLink, source: availability, }); - await opened.start(); - assert.equal(shared.layers.has('lib-entry-65'), false); - assert.equal( - opened.list().filter((row) => row.added).length, - shared.layers.size, + await library.start(); + await manager.waitForLayerSettled?.('lib-entry-64'); + assert.equal(manager.isEnabled('lib-entry-64'), true); + // The oldest entry the share leaves off made room. + assert.equal(manager.layers.has('lib-entry-0'), false); + assert.equal(manager.layers.size, 64); + assert.equal(shareLink.encode(), manyLedger[64][1]); + // A share navigated to in this tab makes room the same way, and the + // entry it evicts leaves with its layer. + shareLink.listener([manyLedger[65][1]]); + await manager.waitForLayerSettled?.('lib-entry-65'); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(manager.isEnabled('lib-entry-65'), true); + assert.equal(manager.layers.has('lib-entry-1'), false); + assert.equal(manager.layers.size, 64); + library.destroy(); +}); + +test('entries this instance cannot serve still show as added and can be removed', async (t) => { + const manager = sealedManager(t); + const storage = memoryStorage( + JSON.stringify({ added: ['night-lights'], on: ['night-lights'] }), ); + const library = createDataLibrary({ + entries, + ledger, + dataManager: manager, + services: {}, + storage, + shareLink: fakeShareLink(null), + source: source({ + 'night-lights': { available: false, reason: 'disabled' }, + }), + }); + await library.start(); + assert.equal(manager.layers.size, 0); + const row = library.list().find(({ entry }) => entry.id === 'night-lights'); + assert.equal(row.added, true); + assert.equal(library.summary().added, 1); + assert.equal(await library.remove('night-lights'), true); + assert.deepEqual(JSON.parse(storage.store.get(LIBRARY_STORAGE_KEY)).added, []); library.destroy(); - opened.destroy(); }); test('a layer that fails to tear down stays in the library', async (t) => {