fix(admin): the gallery starts no thumbnail status read while it leaves

The gallery's startup thumbnail check runs one second after load. When that
second ended while the page was leaving (CI, WebKit: the workflow test
navigates from the gallery to the admin page), WebKit failed the new read
with "Fetch API cannot load ... due to access control checks." and
Playwright reported a page error.

Like the admin page (#290), the gallery now has a page lifetime that ends
on beforeunload and pagehide (and starts again on a back/forward restore).
The check starts no read once it has ended and passes it as the read's
signal. A page-clock workflow test that leaves before the second ends
failed on both events, in Chromium and WebKit, before this change.
This commit is contained in:
Vito Sansevero
2026-10-22 21:22:23 -07:00
parent d7ccd9b4df
commit bac05711b9
3 changed files with 52 additions and 1 deletions
+18
View File
@@ -74,6 +74,24 @@ test('a page whose leave was called off reads its list again on the next input',
await expect(page.locator('#resultsTitle')).toHaveText('Recent Prompts');
});
// The gallery's startup thumbnail check runs a second after load. When that second ends while the page is leaving,
// the check must start no read: WebKit fails it with "Fetch API cannot load … due to access control checks." and
// Playwright reports a page error (seen in CI on PR #289). The page clock makes the second end after the leave began.
for (const leave of ['beforeunload', 'pagehide']) {
test(`the gallery startup check starts no read after ${leave}`, async ({ page }) => {
await page.clock.install();
const statusReads = [];
page.on('request', req => { if (req.url().includes('/images/thumbnails/status')) statusReads.push(req.url()); });
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
await page.evaluate(type => window.dispatchEvent(new Event(type)), leave);
await page.clock.runFor(1500);
// A read made after the clock ran is listed, so the listener saw every status read.
await page.evaluate(() => fetch('/prompt_manager/images/thumbnails/status?probe=1'));
expect(statusReads.map(url => new URL(url).search)).toEqual(['?probe=1']);
});
}
test('actual gallery review generates and persists tags for the selected root', async ({ page, request }) => {
await page.goto('/prompt_manager/gallery');
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
+22
View File
@@ -73,6 +73,28 @@ test('Not Now persists the collection state and suppresses the unchanged prompt'
assert.equal(f.prompts.length, 1);
});
// WebKit fails a fetch a leaving page starts ("Fetch API cannot load … due to access control checks.") and logs it
// as a page error: the delayed startup check starts no read once the page has begun to leave (as admin.js, #290).
test('the startup check starts no read once the page is leaving', async () => {
const f = fixture({ success: true, total_images: 100, pending: 30, collection_id: 'a' });
f.manager.pageLifetime = new AbortController();
f.manager.pageLifetime.abort();
await f.check();
assert.equal(f.requests.length, 0);
assert.equal(f.prompts.length, 0);
});
test('the startup check passes the page lifetime to its read', async () => {
const f = fixture({ success: true, total_images: 100, pending: 30, collection_id: 'a' });
const signals = [];
f.manager.pageLifetime = new AbortController();
const get = f.manager.api.get;
f.manager.api.get = async (url, options) => { signals.push(options?.signal); return get(url); };
await f.check();
assert.deepEqual(signals, [f.manager.pageLifetime.signal]);
assert.equal(f.prompts.length, 1);
});
test('disabled checks do not request collection status', async () => {
const f = fixture({}, { checkThumbnailsAtStartup: false });
await f.check();
+12 -1
View File
@@ -17,6 +17,14 @@
this.dupResults = null;
this.api = ApiClient.createApiClient();
// The page's lifetime ends when it starts to leave: WebKit fails a fetch a leaving page starts
// ("Fetch API cannot load … due to access control checks.") and logs it as a page error.
this.pageLifetime = new AbortController();
window.addEventListener("beforeunload", () => this.pageLifetime.abort());
window.addEventListener("pagehide", () => this.pageLifetime.abort());
window.addEventListener("pageshow", (e) => {
if (e.persisted && this.pageLifetime.signal.aborted) this.pageLifetime = new AbortController();
});
// Only the newest page request may update the grid
this.imagesRequest = ApiClient.latestOnly();
@@ -2534,8 +2542,11 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
return;
}
// A leaving page starts no read, and one it aborts is not reported.
const signal = this.pageLifetime?.signal;
if (signal?.aborted) return;
try {
const data = await this.api.get('/prompt_manager/images/thumbnails/status');
const data = await this.api.get('/prompt_manager/images/thumbnails/status', { signal });
const total = data.total_images;
const pending = data.pending;
if (!data.success || !Number.isSafeInteger(total) ||