perf(workspace): re-read only Library and Tags counts after a Library write

A confirmed Library or vocabulary write cannot change the Images count,
but it re-read all three, including the Images output listing. It now
re-reads only the Library and Tags counts. Each count has its own read,
so a write never cancels an Images read that is still in flight.
This commit is contained in:
Vito Sansevero
2026-10-19 22:48:39 -07:00
parent 12aa1e51d6
commit 075930f439
4 changed files with 49 additions and 18 deletions
+2
View File
@@ -539,6 +539,8 @@ test('counts refresh after a confirmed editor save', async ({ page, request }) =
await expect(navCount(page, 'library')).toHaveText(String(after.library));
await expect(navCount(page, 'tags')).toHaveText(String(after.tags));
expect(reads.filter(path => path === '/prompt_manager/library').length).toBeGreaterThanOrEqual(1);
// A Library write cannot change the Images count, so the Images listing is not read again.
expect(reads.filter(path => path === '/prompt_manager/images/output')).toEqual([]);
});
test('a superseded count read never lands and a failed refresh removes the stale count', async ({ page, request }) => {
+16
View File
@@ -131,3 +131,19 @@ test('nav counts issue three bounded requests and drop only the failed count', a
assert.equal(urls[0].searchParams.get('mode'), 'workspace');
assert.ok(calls.every(([, used]) => used === signal));
});
test('a Library or vocabulary write re-reads only the Library and Tags counts', async () => {
const { loadNavCounts, LIBRARY_WRITE_COUNTS } = await source;
assert.deepEqual([...LIBRARY_WRITE_COUNTS], ['library', 'tags']);
assert.ok(Object.isFrozen(LIBRARY_WRITE_COUNTS));
const calls = [];
const api = {get: async url => {
calls.push(new URL(url, 'http://host').pathname);
return {success: true, total: url.startsWith('/prompt_manager/library') ? 4 : 9};
}};
const settled = [];
const counts = await loadNavCounts(api, new AbortController().signal, (id, count) => settled.push([id, count]), LIBRARY_WRITE_COUNTS);
assert.deepEqual(calls, ['/prompt_manager/library', '/prompt_manager/vocabulary/tags']);
assert.deepEqual(settled.sort(), [['library', 4], ['tags', 9]]);
assert.deepEqual([...counts], [['library', 4], ['tags', 9]]);
});
+21 -15
View File
@@ -1,6 +1,7 @@
import '../api-client.js';
import { createButton, createScope } from '../shared/toolkit/core.js';
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts } from './state.mjs';
import { routes, mountRouter, summarizeStats, routeTitle, isHandoff, loadNavCounts,
LIBRARY_WRITE_COUNTS, NAV_COUNT_ROUTES } from './state.mjs';
import { mountWorkspaceTheme } from './theme.mjs';
import { mountImages } from './images.mjs';
import { mountLibrary, mountNewPromptAction, onLibraryWrite } from './library.mjs';
@@ -59,21 +60,26 @@ function mountWorkspace() {
// D28: System, then the production Overview hub, in the concept's tools-and-system group.
systemNav.replaceChildren(...navLinks([['system', 'settings'], ['overview', 'overview']]));
const sidebar = document.querySelector('aside.sidebar');
let countsRequest;
function refreshCounts() {
countsRequest?.abort();
const request = countsRequest = new AbortController();
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
loadNavCounts(api, request.signal, (id, count) => {
if (request.signal.aborted) return;
const link = primaryNav.querySelector(`[data-route="${id}"]`);
link?.querySelector('.nav-count')?.remove();
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
});
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
const countRequests = new Map();
function refreshCounts(ids) {
for (const id of ids) {
countRequests.get(id)?.abort();
const request = new AbortController();
countRequests.set(id, request);
// Each badge lands as its own read settles; a count that failed to refresh is removed, never left stale.
loadNavCounts(api, request.signal, (_, count) => {
if (request.signal.aborted) return;
const link = primaryNav.querySelector(`[data-route="${id}"]`);
link?.querySelector('.nav-count')?.remove();
if (link && Number.isSafeInteger(count)) link.append(element('span', count.toLocaleString(), 'nav-count'));
}, [id]);
}
}
scope.cleanup(() => countsRequest?.abort());
scope.cleanup(onLibraryWrite(refreshCounts));
refreshCounts();
scope.cleanup(() => { for (const request of countRequests.values()) request.abort(); });
// A confirmed Library or vocabulary write cannot change the Images count, so only Library and Tags are re-read.
scope.cleanup(onLibraryWrite(() => refreshCounts(LIBRARY_WRITE_COUNTS)));
refreshCounts(NAV_COUNT_ROUTES);
let viewScope;
let firstRender = true;
scope.cleanup(() => viewScope?.dispose());
+10 -3
View File
@@ -61,13 +61,20 @@ export function summarizeNavCounts(pages = {}) {
* Read the three counts; a failed read yields null for its route only. `onCount(id, count)` runs as each read
* settles, so a slow listing (the Images output scan) never holds back the cheap Library and Tags counts.
*/
export async function loadNavCounts(api, signal, onCount = () => {}) {
/** The WORKSPACE routes that carry a count. */
export const NAV_COUNT_ROUTES = Object.freeze(NAV_COUNT_SOURCES.map(([id]) => id));
/** The counts a confirmed Library or vocabulary write can change; the Images listing cannot. */
export const LIBRARY_WRITE_COUNTS = Object.freeze(['library', 'tags']);
/** Read the given WORKSPACE counts (all by default), reporting each as its own read settles. */
export async function loadNavCounts(api, signal, onCount = () => {}, ids = NAV_COUNT_ROUTES) {
const pages = {};
await Promise.all(NAV_COUNT_SOURCES.map(async ([id, url]) => {
await Promise.all(NAV_COUNT_SOURCES.filter(([id]) => ids.includes(id)).map(async ([id, url]) => {
try { pages[id] = await api.get(url, { signal }); } catch { pages[id] = undefined; }
onCount(id, summarizeNavCounts(pages).get(id));
}));
return summarizeNavCounts(pages);
return new Map([...summarizeNavCounts(pages)].filter(([id]) => ids.includes(id)));
}
export function mountRouter(win, onRoute) {