diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs index 87db1b57..dacea21b 100644 --- a/tests/browser/workspace-images.spec.mjs +++ b/tests/browser/workspace-images.spec.mjs @@ -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 = []; diff --git a/web/js/workspace/index.mjs b/web/js/workspace/index.mjs index ad50c0db..389470ab 100644 --- a/web/js/workspace/index.mjs +++ b/web/js/workspace/index.mjs @@ -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);