diff --git a/tests/browser/workspace-library.spec.mjs b/tests/browser/workspace-library.spec.mjs index a980ee2..a6a530d 100644 --- a/tests/browser/workspace-library.spec.mjs +++ b/tests/browser/workspace-library.spec.mjs @@ -216,9 +216,9 @@ async function observeLibrary(page, hold=false) { const NativeResizeObserver=window.ResizeObserver; const records=[];let holding=hold; const raf=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window); - const pending=new Set();let requested=0,mutations=0,unmounted=0; - window.requestAnimationFrame=callback=>{requested++;const id=raf(time=>{pending.delete(id);callback(time);});pending.add(id);return id;}; - window.cancelAnimationFrame=id=>{pending.delete(id);cancel(id);}; + const pending=new Set(),heldFrames=new Map();let requested=0,mutations=0,unmounted=0,holdingFrames=false; + window.requestAnimationFrame=callback=>{requested++;const id=raf(time=>{const deliver=()=>{pending.delete(id);callback(time);};if(holdingFrames)heldFrames.set(id,deliver);else deliver();});pending.add(id);return id;}; + window.cancelAnimationFrame=id=>{pending.delete(id);heldFrames.delete(id);cancel(id);}; window.ResizeObserver=class { constructor(callback) { const record={callback,targets:new Set(),entries:new Map(),latest:new Map(),disconnected:false}; @@ -243,8 +243,10 @@ async function observeLibrary(page, hold=false) { window.libraryObservation={ hold(){holding=true;}, release(){holding=false;for(const record of records){const entries=[...record.entries.values()].filter(entry=>record.targets.has(entry.target));record.entries.clear();if(entries.length)record.callback(entries,record.native);}}, + holdFrames(){holdingFrames=true;}, + releaseFrames(){holdingFrames=false;const callbacks=[...heldFrames.values()];heldFrames.clear();for(const callback of callbacks)callback();}, equal(){for(const record of records){const entries=[...record.latest.values()].filter(entry=>record.targets.has(entry.target));if(entries.length)record.callback(entries,record.native);}}, - stats(){return {requested,pending:pending.size,mutations,unmounted,targets:records.reduce((sum,record)=>sum+record.targets.size,0)};}, + stats(){return {requested,pending:pending.size,heldFrames:heldFrames.size,measurements:records.reduce((sum,record)=>sum+record.entries.size,0),mutations,unmounted,targets:records.reduce((sum,record)=>sum+record.targets.size,0)};}, }; },{hold}); } @@ -334,6 +336,57 @@ test('row measurement preserves anchor after width invalidation and append',asyn } finally {release();} }); +test('queued row growth preserves newer native wheel movement and correction events',async({page,request},testInfo)=>{ + await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}}); + await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); + await page.setViewportSize({width:1600,height:1100});await page.goto(route); + const more=page.getByRole('button',{name:'Load more prompts',exact:true}); + for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());} + await expect(page.locator('.library-caption')).toContainText('160 of 203'); + await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+20*486+25)); + await settleLibrary(page);await page.mouse.move(1100,550); + const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true})); + try { + // The same queued-frame wheel sequence is stable without changed measurements. + const control=await firstVisibleParent(page),controlY=await page.evaluate(()=>scrollY); + await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160); + await expect.poll(()=>page.evaluate(()=>scrollY)).toBe(controlY+160); + await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0); + await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page); + expect(await page.evaluate(()=>scrollY)).toBe(controlY+160);await retainedParent(page,{...control,offset:control.offset-160}); + + const before=await firstVisibleParent(page),beforeY=await page.evaluate(()=>scrollY); + await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='45px';}); + await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0); + const parent=page.locator(`article[data-prompt-id="${before.id}"]`); + const growth=await parent.evaluate(node=>node.getBoundingClientRect().top)-before.offset; + expect(growth).toBeGreaterThan(0);expect(await page.evaluate(()=>scrollY)).toBe(beforeY); + await page.evaluate(()=>{window.libraryObservation.holdFrames();window.libraryObservation.release();}); + await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0); + await page.mouse.wheel(0,160);await expect.poll(()=>page.evaluate(()=>scrollY)).toBe(beforeY+160); + expect(await page.evaluate(()=>document.documentElement.scrollHeight-innerHeight-scrollY)).toBeGreaterThan(1000); + await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page); + await testInfo.attach('queued-growth-wheel-geometry',{body:JSON.stringify({beforeY,offset:before.offset,growth,wheel:160,finalY:await page.evaluate(()=>scrollY),finalOffset:await parent.evaluate(node=>node.getBoundingClientRect().top)}),contentType:'application/json'}); + await retainedParent(page,{...before,offset:before.offset-160}); + expect(Math.abs(await page.evaluate(()=>scrollY)-(beforeY+160+growth))).toBeLessThanOrEqual(2); + + // The correction's own native scroll event must not become new user motion. + const corrected=await firstVisibleParent(page); + await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='65px';}); + await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0); + await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,corrected); + await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused(); + const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(ids.length).toBeLessThan(80);expect(new Set(ids).size).toBe(ids.length); + await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160); + await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0); + await page.evaluate(()=>{location.hash='#/overview';});await expect(cards(page)).toHaveCount(0); + expect((await page.evaluate(()=>window.libraryObservation.stats())).targets).toBe(0); + expect((await page.evaluate(()=>window.libraryObservation.stats())).pending).toBe(0); + expect((await page.evaluate(()=>window.libraryObservation.stats())).heldFrames).toBe(0); + expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0); + } finally {await page.evaluate(()=>{window.libraryObservation.release();window.libraryObservation.releaseFrames();});} +}); + test('unchanged row observations settle without resize feedback',async({page,request,browserName})=>{ await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}}); await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;}); diff --git a/web/js/workspace/library.mjs b/web/js/workspace/library.mjs index 5140b8c..fb3cd8a 100644 --- a/web/js/workspace/library.mjs +++ b/web/js/workspace/library.mjs @@ -48,7 +48,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2; const readInlineSize=()=>Math.round(grid.getBoundingClientRect().width*64)/64; let inlineSize=readInlineSize(), columnCount=columns(), layout={rows:[],height:0}, dirty=true, items, indexesById=new Map(); - let anchor=visit?.layout?.anchor || null, pendingAnchor=anchor, correctedScrollY=null; + let anchor=visit?.layout?.anchor || null, pendingAnchor=anchor, observedScrollY=window.scrollY; let restoreScrollY=Number.isFinite(visit?.scrollY) && visit.scrollY>=0?visit.scrollY:null; if(visit?.layout?.inlineSize===inlineSize && visit.layout.columns===columnCount) for(const [id,height] of visit.layout.heights) heights.set(id,height); function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();} @@ -86,6 +86,13 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } return null; } + function reconcileScroll() { + const delta=window.scrollY-observedScrollY;observedScrollY=window.scrollY; + if(!delta)return; + // Keep pending height compensation while honoring movement since it was queued. + if(pendingAnchor)pendingAnchor={...pendingAnchor,offset:pendingAnchor.offset-delta}; + else anchor=captureAnchor(); + } function spacer(key,height) { let node=spacers.get(key); if(!node) {node=el('div',undefined,'library-row-spacer');node.setAttribute('aria-hidden','true');spacers.set(key,node);} @@ -127,6 +134,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { } function flush() { frame=0;if(scope.signal.aborted)return; + reconcileScroll(); const width=readInlineSize(), size=columns(); if(width!==inlineSize || size!==columnCount) {pendingAnchor ||= anchor;inlineSize=width;columnCount=size;heights.clear();dirty=true;} for(const [id,measurement] of pendingMeasurements) { @@ -140,12 +148,14 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { renderWindow(target); const card=index===undefined?null:cards.get(restore.id); if(card) { - const delta=card.node.getBoundingClientRect().top-restore.offset;if(Math.abs(delta)>.5){window.scrollBy(0,delta);correctedScrollY=window.scrollY;} + const delta=card.node.getBoundingClientRect().top-restore.offset;if(Math.abs(delta)>.5)window.scrollBy(0,delta); anchor=restore; } else { - if(restoreScrollY!==null) {window.scrollTo(0,restoreScrollY);correctedScrollY=window.scrollY;} + if(restoreScrollY!==null)window.scrollTo(0,restoreScrollY); anchor=captureAnchor(); } + // Read the actual clamped result now; its later scroll event is not new motion. + observedScrollY=window.scrollY; restoreScrollY=null;pendingAnchor=null;scheduleLoad(); } const schedule=()=>{if(!frame)frame=requestAnimationFrame(flush);}; @@ -208,7 +218,9 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) { scope.listen(tags,'input',scope.debounce(()=>{filters.tags=[...new Set(tags.value.split(',').map(tag=>tag.trim()).filter(Boolean))];reset();},200)); scope.listen(category,'change',()=>{filters.category=category.value;reset();});scope.listen(sort,'change',()=>{filters.sort=sort.value;reset();}); scope.listen(refresh,'click',()=>{loadCategories();reset();});scope.listen(more,'click',()=>stream.loadMore());scope.listen(retry,'click',()=>stream.loadMore()); - scope.listen(window,'scroll',()=>{if(window.scrollY===correctedScrollY)correctedScrollY=null;else if(!pendingAnchor)anchor=captureAnchor();schedule();});scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;schedule();}); + scope.listen(window,'scroll',()=>{reconcileScroll();schedule();}); + // Mobile viewport resizing can adjust scroll before delivering its resize event. + scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;observedScrollY=window.scrollY;schedule();}); scope.cleanup(()=>{shelfVisit=state?{filters:{...filters,tags:[...filters.tags]},state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:pendingAnchor || anchor || captureAnchor()}}:null;stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();}); loadCategories();if(visit) {render(stream.state);if(visit.state.loading || visit.state.error)stream.loadMore();} else reset(); }