From d9988f246aad484f48ca043553f8b0039dc71600 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 28 Oct 2026 20:45:24 -0700 Subject: [PATCH] fix(library): lay out a loaded page of prompts without waiting for an animation frame (#443) A starved renderer (headless WebKit under CPU load, a background tab) can hold requestAnimationFrame for seconds. The shelf laid out each loaded page only in its next frame, so the grid stayed empty after the first load or a delete's reload while the caption already showed the new count. A loaded batch is now laid out at once; measurements still flush per frame. The regression test holds every animation frame and fails before the fix. --- .../browser/workspace-library-delete.spec.mjs | 23 +++++++++++++++++++ web/js/workspace/library.mjs | 5 +++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/browser/workspace-library-delete.spec.mjs b/tests/browser/workspace-library-delete.spec.mjs index de3f8e19..8df1f325 100644 --- a/tests/browser/workspace-library-delete.spec.mjs +++ b/tests/browser/workspace-library-delete.spec.mjs @@ -116,6 +116,29 @@ test('an archived prompt\'s page says so and restores it from its notice',async( expect(byText(await items(request),'Cat portrait')?.id).toBe(cat.id); }); +// #443: a starved renderer (headless WebKit under CPU load, a background tab) can hold animation frames for +// seconds; a loaded page of prompts is shown without waiting for one, on first load and after a delete. +async function holdAnimationFrames(page) { + await page.addInitScript(()=>{ + const request=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window); + const held=new Map(),real=new Map();let holding=true,next=0; + window.requestAnimationFrame=callback=>{const id=--next;if(holding)held.set(id,callback);else real.set(id,request(callback));return id;}; + window.cancelAnimationFrame=id=>{held.delete(id);if(real.has(id))cancel(real.get(id));real.delete(id);}; + window.__releaseAnimationFrames=()=>{holding=false;for(const [id,callback] of held)real.set(id,request(callback));held.clear();}; + }); +} +test('a held animation frame does not hide the loaded prompts, on first load or after a delete',async({page,request})=>{ + const before=await items(request);const dog=byText(before,'Dog landscape'); + await holdAnimationFrames(page); + await page.goto(route);await expect(cards(page)).toHaveCount(before.length); + await box(page,dog.id).check();await openReview(page); + await review(page).getByRole('button',{name:'Delete 1 prompt',exact:true}).click(); + await expect(toast(page)).toHaveText(/^Deleted 1 prompt\./); + await expect(cards(page)).toHaveCount(before.length-1);await expect(card(page,dog.id)).toHaveCount(0); + await page.evaluate(()=>window.__releaseAnimationFrames()); + await expect(cards(page)).toHaveCount(before.length-1); +}); + test('a double press sends one preview, and the bar holds while it is reviewed',async({page,request})=>{ const dog=byText(await items(request),'Dog landscape'); const previews=recordPosts(page,PREVIEW); diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 53c63a64..adb61d66 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -412,7 +412,10 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on const hidden=hiddenSave!==null && !next.loading && !next.error && !next.hasMore && !next.items.some(item=>item.id===hiddenSave)?' The saved prompt is hidden by the current filters.':''; message.textContent=(next.error?'Prompt library could not be loaded. Retry.':next.loading?'Loading prompt library…':!next.items.length?empty:!next.hasMore?'You’re all caught up.':'')+hidden; message.setAttribute('role',next.error?'alert':'status');retry.hidden=!next.error;more.hidden=!next.hasMore || !!next.error;more.setAttribute('aria-disabled',String(next.loading)); - schedule();scheduleLoad(); + // #443: a loaded page is laid out now, not on the next frame: a starved renderer (a background tab, headless + // WebKit under load) can hold frames for seconds, which left a loaded shelf empty. Measurements still flush per frame. + if(batch && grid.isConnected) {cancelAnimationFrame(frame);flush();} else schedule(); + scheduleLoad(); } const stream=createLibraryStream({api,onChange:render,restore:restorable?{filters,state:restorable.state}:undefined}); function maybeLoad() {