fix(images): release a collection viewer image Viewer.js has not built yet

Leaving a viewer image while it was still loading (Previous, Next, the
arrow keys, Retry) deferred the inline Viewer.js destroy() until the
image's view settled, failed or a 10 s fallback ran. Before its image
loads Viewer.js has not built, so nothing could settle: the stage
dropped the image, the late load ran Viewer.js's build() on an image
without a parent, and getComputedStyle(null) threw a page error
("Argument 1 is not an object" in Firefox, "must be an instance of
Element" in WebKit). Under CI load this failed "collection viewer
follows filtered loaded order beyond mounted cards".

A viewer that is not ready yet is now destroyed at once, which cancels
its pending build, and its image src is released. A built viewer with
a still-loading view keeps the existing deferral.
This commit is contained in:
Vito Sansevero
2026-10-22 21:36:51 -07:00
parent 5123654b0d
commit 429b3a849e
3 changed files with 52 additions and 6 deletions
+20
View File
@@ -463,6 +463,26 @@ test('collection viewer ignores delayed media callbacks and keeps inspection ret
await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0); await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
}); });
test('collection viewer leaving an image Viewer.js has not built yet releases it at once',async({page})=>{
await page.goto(workspace);await expect(cards(page)).toHaveCount(2);
let release,held;const waiting=new Promise(resolve=>{held=resolve;});const pending=new Promise(resolve=>{release=resolve;});
await page.route('**/images/serve/shared/same.png?*',async route=>{
const url=new URL(route.request().url());
if(url.searchParams.get('root')==='0'&&url.searchParams.has('_pm_viewer')) {held();await pending;}
await route.continue().catch(()=>{});
});
await cards(page).first().getByRole('button',{name:'Open same.png',exact:true}).click();await waiting;
const preview=page.locator('dialog#viewer-dialog');
await page.evaluate(()=>{window.unbuiltImage=document.querySelector('#viewer-stage > img');});
await preview.getByRole('button',{name:'Next media',exact:true}).click();
await expect(preview.getByRole('button',{name:'Zoom in',exact:true})).toHaveAttribute('aria-disabled','false');
// The left image is off the stage. Viewer.js has not built it, so the viewer is destroyed and the src released at
// once: its late load must never build Viewer.js into an image without a parent (getComputedStyle(null)).
release();
await expect.poll(()=>page.evaluate(()=>[window.unbuiltImage.isConnected,window.unbuiltImage.hasAttribute('src'),!!window.unbuiltImage.viewer])).toEqual([false,false,false]);
await page.evaluate(()=>{location.hash='/overview';});await expect(page.locator('.viewer-container,#viewer-dialog')).toHaveCount(0);
});
test('collection viewer describes video transforms and releases native playback on navigation',async({page,request})=>{ test('collection viewer describes video transforms and releases native playback on navigation',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{include_video:true}})).toBeOK();await page.goto(workspace); await expect(await request.post('/__test__/reset',{data:{include_video:true}})).toBeOK();await page.goto(workspace);
await page.locator('article[data-image-key="0:clip.webm"] .image-open').click(); await page.locator('article[data-image-key="0:clip.webm"] .image-open').click();
+26 -2
View File
@@ -124,12 +124,36 @@ test('real inspection navigation preserves slideshow across two advances and def
} finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();} } finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();}
}); });
test('an inline Viewer not yet built is destroyed and its image released at once', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];
const instances = [];
// Viewer.js builds an inline viewer only once its image loads; until then `ready` is false and destroy() cancels it.
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.ready = false; this.viewed = false; instances.push(this); }
destroy() { this.destroyed++; } resize() {} };
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [loading] = instances, image = loading.node, removed = [];
image.removeAttribute = key => removed.push(key);
// Next drops the still-loading image from the stage; a deferred destroy would let its late load build there.
f.walk(f.container,n => n.attributes['aria-label'] === 'Next media')[0].fire('click');
assert.equal(instances.length, 2, 'Next builds the next image');
assert.equal(loading.destroyed, 1, 'an unbuilt viewer is destroyed at once');
assert.deepEqual(removed, ['src'], 'and its image is released at once');
loading.options.viewed.call(loading);
assert.equal(loading.destroyed, 1, 'never destroyed twice');
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
});
test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => { test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs'); const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')]; const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
const instances = []; const instances = [];
// Viewer.js's inline destroy() leaves an in-flight view running; its callback then needs a live instance. // A built (`ready`) inline Viewer.js's destroy() leaves an in-flight view running; its callback then needs a live instance.
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.viewed = false; instances.push(this); } f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.ready = true; this.viewed = false; instances.push(this); }
destroy() { this.destroyed++; } resize() {} }; destroy() { this.destroyed++; } resize() {} };
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame}; const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}}); Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
+6 -4
View File
@@ -176,10 +176,12 @@ export function createMediaViewer({api,container,parentScope,inspectionControlle
const observer = new ResizeObserver(scheduleFit);observer.observe(stage); const observer = new ResizeObserver(scheduleFit);observer.observe(stage);
viewer = instance;mediaLocal.cleanup(() => { viewer = instance;mediaLocal.cleanup(() => {
observer.disconnect();cancelAnimationFrame(fitFrame); observer.disconnect();cancelAnimationFrame(fitFrame);
// Inline Viewer.js keeps viewing a still-loading image after destroy(), and that view // Before Viewer.js has built (its image still loading), destroy() cancels the pending build;
// rebuilds its list from images that still have a src; keep both until the view // deferring it would let the late load build into the image the stage has just dropped.
// settles, fails or times out. // Once built, inline Viewer.js keeps viewing a still-loading image after destroy(), and that
if (viewSettled) { release(); return; } // view rebuilds its list from images that still have a src; keep both until the view settles,
// fails or times out.
if (viewSettled || !instance.ready) { release(); return; }
node.addEventListener('error',release,{once:true}); node.addEventListener('error',release,{once:true});
fallback = setTimeout(release,PENDING_VIEW_DESTROY_MS); fallback = setTimeout(release,PENDING_VIEW_DESTROY_MS);
}); });