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.
This commit is contained in:
Vito
2026-10-22 12:37:19 -07:00
committed by GitHub
parent 1b8fbdbb75
commit 2561d3b789
3 changed files with 32 additions and 1 deletions
+13
View File
@@ -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');
+16
View File
@@ -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]);
+3 -1
View File
@@ -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));
}