From 2561d3b789cf06b2f2bfd8806c6902a6f47741d8 Mon Sep 17 00:00:00 2001 From: Vito Date: Thu, 22 Oct 2026 12:37:19 -0700 Subject: [PATCH] fix(library): restore a shelf left before its first page answered (#282) Leaving the Library while its first page was still loading (or after it failed) saved a stream with no variant total. Returning to the shelf in the same page restored that state, the restore validation rejected the null variant total, and mounting threw "Invalid library page" as an uncaught page error, leaving the shelf empty. A saved state with no page yet (no items, no cursor, total 0, more to load) now restores as a fresh stream, and the shelf reads page one as it already does for a restored loading or failed state. Any other saved state still goes through the full page validation. --- tests/browser/workspace-library.spec.mjs | 13 +++++++++++++ tests/js/workspace-library-state.test.js | 16 ++++++++++++++++ web/js/workspace/library-state.mjs | 4 +++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index 85a9ca3..7055766 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -256,6 +256,19 @@ test('returning from history restores library context and mobile content stays i await expect(history(page)).toHaveCount(0);await expect(page.locator('dialog[open]')).toHaveCount(0); }); +test('leaving the shelf before its first page answers and returning reads page one without a page error',async({page})=>{ + // Hold the first shelf page so the route changes while the shelf is still loading (a slow CI runner's natural order). + let reached;const firstRead=new Promise(resolve=>{reached=resolve;}); + await page.route(url=>url.pathname==='/prompt_manager/library'&&!url.searchParams.has('cursor'),route=>{reached();}); + await page.goto(route);await firstRead;await expect(page.locator('.library-status')).toHaveText('Loading prompt library…'); + await page.evaluate(()=>{window.location.hash='#/tags';}); + await expect(page.locator('#workspace-main > .heading h1')).toHaveText('Tags & categories'); + await page.unrouteAll({behavior:'ignoreErrors'}); + await page.evaluate(()=>{window.location.hash='#/library';}); + await expect(page.locator('#workspace-main > .heading h1')).toHaveText('Prompt library'); + await expect(cards(page)).toHaveCount(3);await expect(page.locator('.section-caption')).toContainText('3 positive prompts'); + expect(page.libraryErrors).toEqual([]); +}); test('empty shelf visits restore saved scroll without inventing a parent anchor',async({page})=>{ await page.setViewportSize({width:1600,height:300});await page.goto(route); await page.getByRole('searchbox',{name:'Search workspace',exact:true}).fill('No matching saved positive'); diff --git a/tests/js/workspace-library-state.test.js b/tests/js/workspace-library-state.test.js index 98cdc5a..2442364 100644 --- a/tests/js/workspace-library-state.test.js +++ b/tests/js/workspace-library-state.test.js @@ -47,6 +47,22 @@ test('restored shelves keep filters, loaded parents and continuation without a f const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(2)]);}},restore:{filters:{text:'retained',sort:'name'},state:old.state}});assert.equal(next.state.items.length,1);await next.loadMore();assert.match(urls[0],/text=retained/);assert.match(urls[0],/cursor=next/);assert.equal(next.state.items.length,2);next.dispose(); assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state:{items:[item(1),item(1)],total:2,variantTotal:0,cursor:null,hasMore:false}}})); }); +test('a shelf left before its first page answered or after it failed restores and reads page one',async()=>{ + const {createLibraryStream}=await source; + // The shelf saves whatever state it holds when the route changes; a first page still pending or failed has no variant total yet. + let pending;const left=createLibraryStream({api:{get:()=>new Promise(resolve=>{pending=resolve;})}});const first=left.reset({text:'kept'}); + assert.equal(left.state.loading,true);assert.equal(left.state.variantTotal,null);const saved=left.state;left.dispose();pending(page([item(9)]));await first; + const failed=createLibraryStream({api:{get:async()=>{throw Error('offline');}}});await failed.reset();assert.ok(failed.state.error);failed.dispose(); + for(const state of [saved,failed.state]) { + const urls=[];const next=createLibraryStream({api:{get:async url=>{urls.push(url);return page([item(1)]);}},restore:{filters:{text:'kept'},state}}); + assert.deepEqual([next.state.items.length,next.state.loading,next.state.error,next.state.variantTotal,next.state.hasMore],[0,false,null,null,true]); + assert.equal(await next.loadMore(),1);assert.equal(new URL(urls[0],'https://x').searchParams.get('text'),'kept');assert.equal(new URL(urls[0],'https://x').searchParams.has('cursor'),false); + assert.equal(next.state.variantTotal,6);next.dispose(); + } + // A pending first page is only the empty, cursorless state: anything else without a variant total stays invalid. + for(const state of [{items:[item(1)],total:1,variantTotal:null,cursor:null,hasMore:false},{items:[],total:0,variantTotal:null,cursor:'next',hasMore:true},{items:[],total:3,variantTotal:null,cursor:null,hasMore:true},{items:[],total:0,variantTotal:null,cursor:null,hasMore:false}]) + assert.throws(()=>createLibraryStream({api:{},restore:{filters:{},state}}),/Invalid library page/); +}); test('parent shelf window bounds DOM and pins focused rows without changing logical order',async()=>{ const {libraryWindow}=await source;const first=libraryWindow(500,2,0,900);assert.equal(first[0],0);assert.ok(first.length<24); const middle=libraryWindow(500,2,50000,900,0);assert.equal(middle[0],0);assert.ok(middle.some(index=>index>150));assert.ok(middle.length<24);assert.deepEqual(middle,[...middle].sort((a,b)=>a-b));assert.deepEqual(libraryWindow(3,2,0,900),[0,1,2]); diff --git a/web/js/workspace/library-state.mjs b/web/js/workspace/library-state.mjs index 52a771c..7ba2b79 100644 --- a/web/js/workspace/library-state.mjs +++ b/web/js/workspace/library-state.mjs @@ -23,7 +23,9 @@ export function createLibraryStream({api,onChange=()=>{},restore}) { let query=parameters({},40),request,disposed=false,seen=new Set(); if(restore) { query=parameters(restore.filters,40);const saved=restore.state; - validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null); + // A shelf left before its first page answered (or after it failed) has no page yet: it restores as a fresh stream. + const firstPagePending=saved?.variantTotal===null && Array.isArray(saved.items) && !saved.items.length && saved.total===0 && saved.cursor===null && saved.hasMore===true; + if(!firstPagePending) validatePage({items:saved.items,total:saved.total,variant_total:saved.variantTotal,next_cursor:saved.cursor,has_more:saved.cursor!==null},null); if(new Set(saved.items.map(item=>item.id)).size!==saved.items.length) throw new TypeError('Duplicate restored parent'); state=Object.freeze({...saved,items:Object.freeze(saved.items.map(item=>Object.freeze({...item}))),loading:false,error:null});seen=new Set(state.items.map(item=>item.id)); }