Compare commits

...
Author SHA1 Message Date
Vito Sansevero 7b09aa8a26 fix(workspace): start no sidebar count read while the page is leaving (#494)
The Favorites count is re-read 300 ms after a star changes. A reload
inside that window started the read on the leaving page, which WebKit
fails ("access control checks") and reports as a page error. After
beforeunload, the workspace clears that timer, aborts count reads in
flight and defers new ones, as well as a finished restore's reload. A
page that stays reads them on its next input or when it becomes visible,
as the legacy admin page does.
2026-10-29 12:44:27 -07:00
2 changed files with 85 additions and 1 deletions
+55
View File
@@ -1376,6 +1376,61 @@ async function expectStar(button, favorite) {
await expect(button).toHaveAttribute('aria-pressed', String(favorite));
}
// #494: the Favorites count is re-read 300 ms after a star changes. A page that has begun to leave starts no count read
// (WebKit fails it with "Fetch API cannot load … due to access control checks.", reported as a page error; queue run
// 40934368716). A leave that is called off counts again on the next input.
for (const leave of ['beforeunload', 'pagehide']) {
test(`a star changed just before ${leave} starts no Favorites count read while the page leaves`, async ({ page }) => {
await page.clock.install();
const countReads = [];
page.on('request', item => {
const url = new URL(item.url());
if (url.pathname === '/prompt_manager/images/output' && url.searchParams.get('favorites') === '1' && url.searchParams.get('limit') === '1') countReads.push(url.search);
});
await page.goto(workspace);
const card = page.locator('article[data-image-key="0:shared/same.png"]');
await expectStar(star(card, 'same.png'), false);
await expect.poll(() => countReads.length).toBe(1);
await star(card, 'same.png').click();
await expectStar(star(card, 'same.png'), true);
await expect(toast(page)).toHaveText('Added to favorites.');
await page.evaluate(type => window.dispatchEvent(new Event(type)), leave);
await page.clock.runFor(1000);
// A read made after the clock ran is listed, so the listener saw every count read.
await page.evaluate(() => fetch('/prompt_manager/images/output?mode=workspace&favorites=1&limit=1&probe=1'));
await expect.poll(() => countReads.length).toBe(2);
expect(countReads.at(-1)).toContain('probe=1');
if (leave === 'beforeunload') {
// The leave was called off (the page is still here): the next input counts again, once, and the badge shows it.
await page.keyboard.press('Shift');
await expect.poll(() => countReads.length).toBe(3);
expect(countReads.at(-1)).not.toContain('probe=1');
await expect(page.locator('nav [data-route="favorites"] .nav-count')).toHaveText('1');
await page.clock.runFor(1000);
expect(countReads).toHaveLength(3);
}
});
}
test('a count read in flight when the page starts to leave is dropped, then read again if the page stays (#494)', async ({ page }) => {
const held = [];
// The page's first Library count read is held at the server boundary while the page starts to leave.
await page.route('**/prompt_manager/library?limit=1', route => { held.push(route); });
await page.goto(workspace);
await expect.poll(() => held.length).toBe(1);
const failed = page.waitForEvent('requestfailed', item => item.url().includes('/prompt_manager/library?limit=1'));
await page.evaluate(() => window.dispatchEvent(new Event('beforeunload')));
await failed;
await page.unroute('**/prompt_manager/library?limit=1');
await held[0].continue().catch(() => {});
await expect(page.locator('nav [data-route="library"] .nav-count')).toHaveCount(0);
// The page stayed: the next input reads the dropped count again.
const reread = page.waitForRequest(item => item.url().includes('/prompt_manager/library?limit=1'));
await page.keyboard.press('Shift');
await reread;
await expect(page.locator('nav [data-route="library"] .nav-count')).toHaveText(/^\d+$/);
});
test('favorite persists across reload', async ({ page, request }) => {
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
const sent = [];
+30 -1
View File
@@ -137,7 +137,13 @@ function mountWorkspace() {
onSave: name => savedViews.create(page, name, state) });
// One read per count, so a write that refreshes Library and Tags never supersedes an Images read in flight.
const countRequests = new Map();
// #494: a page that has begun to leave (beforeunload) starts no count read: WebKit fails one with "Fetch API cannot
// load … due to access control checks." (a page error). Counts asked for meanwhile wait; a leave that is called off
// (the page is still here) reads them on its next input, as the legacy admin page does (#290).
let leaving = false;
const deferredCounts = new Set();
function refreshCounts(ids) {
if (leaving) { for (const id of ids) deferredCounts.add(id); return; }
for (const id of ids) {
countRequests.get(id)?.abort();
const request = new AbortController();
@@ -167,9 +173,29 @@ function mountWorkspace() {
let favoriteCountTimer = 0;
scope.cleanup(onFavoriteChange(() => {
clearTimeout(favoriteCountTimer);
favoriteCountTimer = setTimeout(() => refreshCounts(FAVORITE_WRITE_COUNTS), FAVORITE_COUNT_DELAY_MS);
favoriteCountTimer = setTimeout(() => { favoriteCountTimer = 0; refreshCounts(FAVORITE_WRITE_COUNTS); }, FAVORITE_COUNT_DELAY_MS);
}));
scope.cleanup(() => clearTimeout(favoriteCountTimer));
// Any beforeunload: a page that stays (Stop, a download, a "stay" prompt) revives on its next input or when shown.
scope.listen(window, 'beforeunload', () => {
leaving = true;
if (favoriteCountTimer) { clearTimeout(favoriteCountTimer); favoriteCountTimer = 0; for (const id of FAVORITE_WRITE_COUNTS) deferredCounts.add(id); }
for (const [id, request] of countRequests) { request.abort(); deferredCounts.add(id); }
countRequests.clear();
});
const revive = () => {
if (!leaving) return;
leaving = false;
const ids = [...deferredCounts]; deferredCounts.clear();
if (ids.length) refreshCounts(ids);
if (restorePending) { restorePending = false; restoreReload.request(); }
};
// Capture, as the admin page does, so no handler that stops an input keeps the page from reviving.
for (const type of ['pointerdown', 'keydown']) {
window.addEventListener(type, revive, true);
scope.cleanup(() => window.removeEventListener(type, revive, true));
}
scope.listen(document, 'visibilitychange', () => { if (document.visibilityState === 'visible') revive(); });
// A metadata scan that ends may have added items to Needs review: re-read its count once per ended scan.
const runningScans = new Set();
scope.cleanup(activity.subscribe(() => {
@@ -186,7 +212,10 @@ function mountWorkspace() {
// again, and the open page is shown again from the restored data; the Activity job view the restore opened stays.
// #415: an open review closes (its plan no longer describes storage); while any other dialog is open, all of it
// waits until the last one has closed, so the dialog keeps what was typed.
// #494: a restore that ends while the page leaves reloads nothing then; a page that stays reloads on revive.
let restorePending = false;
const restoreReload = createRestoreReload(() => {
if (leaving) { restorePending = true; return; }
forgetImagesVisit();
forgetLibraryVisit();
refreshCounts(NAV_COUNT_ROUTES);