diff --git a/client/src/pages/ImageGen.jsx b/client/src/pages/ImageGen.jsx index cb369c9fd3..2d31896522 100644 --- a/client/src/pages/ImageGen.jsx +++ b/client/src/pages/ImageGen.jsx @@ -186,6 +186,13 @@ export default function ImageGen() { // updater React skips once unmounted — an implementation detail to guard // against, not to rely on.) const mountedRef = useMounted(); + // Per-target pick sequence: two overlapping upload picks both survive the + // EXIF-normalization await, read the same stale preview ref, and each mint a + // url — only the last setState survives, orphaning the loser's url. Each + // handler bumps its slot's counter and bails when superseded, BEFORE minting, + // so a losing pick never creates a url (same token idiom as + // statusRequestToken below). + const pickSeqRef = useRef({ init: 0, refs: [] }); const [initImageStrength, setInitImageStrength] = useState(0.4); // Visual gallery picker target: null (closed), { kind: 'init' }, or // { kind: 'reference', slot: i }. The search/browse alternative to the plain @@ -550,13 +557,18 @@ export default function ImageGen() { const handlePickInitImage = async (e) => { const raw = e.target.files?.[0]; if (!raw) return; + const myPick = ++pickSeqRef.current.init; const file = await normalizeImageOrientation(raw); if (!mountedRef.current) return; + // A newer pick started while this one normalized — it owns the slot now. + // Bail before minting so this pick never creates an unreachable url. + if (myPick !== pickSeqRef.current.init) return; revokeIfBlob(initImagePreviewRef.current); setInitImage({ source: 'upload', file, name: file.name, previewUrl: URL.createObjectURL(file) }); // Default the output resolution to the uploaded image's dimensions, clamped // to the server's edge/pixel caps so a large phone photo doesn't 400 on Generate. const dims = await readImageDimensions(file); + if (myPick !== pickSeqRef.current.init) return; const clamped = dims && clampImageDimensions(dims.width, dims.height); if (clamped) { setWidth(clamped.width); setHeight(clamped.height); } }; @@ -582,8 +594,13 @@ export default function ImageGen() { const handlePickReferenceImage = async (slotIndex, e) => { const raw = e.target.files?.[0]; if (!raw) return; + const seqs = pickSeqRef.current.refs; + const myPick = seqs[slotIndex] = (seqs[slotIndex] ?? 0) + 1; const file = await normalizeImageOrientation(raw); if (!mountedRef.current) return; + // Superseded by a newer pick on this slot — bail before minting so the + // losing pick never creates an unreachable url. + if (myPick !== pickSeqRef.current.refs[slotIndex]) return; // Mint the url OUTSIDE the updater. StrictMode invokes a functional updater // twice in dev, and a url created inside it on the discarded pass is never // stored — so it can never be revoked. (The revoke stays inside, where it diff --git a/client/src/pages/ImageGen.objectUrls.test.jsx b/client/src/pages/ImageGen.objectUrls.test.jsx index b663316992..7fadc79468 100644 --- a/client/src/pages/ImageGen.objectUrls.test.jsx +++ b/client/src/pages/ImageGen.objectUrls.test.jsx @@ -7,7 +7,7 @@ import { MemoryRouter } from 'react-router'; // init image plus all four reference slots without needing a FLUX.2 install. const MODEL = { id: 'dev', name: 'FLUX.1 Dev', runner: 'mflux', steps: 20, guidance: 3.5 }; -const state = vi.hoisted(() => ({ created: [], revoked: [], fileSeq: 0 })); +const state = vi.hoisted(() => ({ created: [], createdFiles: [], revoked: [], fileSeq: 0 })); const nextFile = () => new File(['x'], `photo-${++state.fileSeq}.jpg`, { type: 'image/jpeg' }); @@ -139,12 +139,14 @@ describe('ImageGen object-URL lifecycle', () => { beforeEach(() => { state.created = []; + state.createdFiles = []; state.revoked = []; state.fileSeq = 0; restore = [ - stub(URL, 'createObjectURL', vi.fn(() => { + stub(URL, 'createObjectURL', vi.fn((file) => { const url = `blob:portos/${state.created.length + 1}`; state.created.push(url); + state.createdFiles.push(file?.name ?? ''); return url; })), stub(URL, 'revokeObjectURL', vi.fn((url) => { state.revoked.push(url); })), @@ -283,4 +285,55 @@ describe('ImageGen object-URL lifecycle', () => { expect(state.revoked).toEqual([blobUrl]); }); + + // Two picks that overlap in the EXIF-normalization await must not each mint + // a url: only the last setState survives, so the loser's url would be + // unreachable from state, the clear path, and the unmount sweep. + it('creates exactly one url when two init picks overlap, keeping the last pick', async () => { + // Defer only the normalization calls (two-arg form); the dims probe + // (one-arg form) keeps its immediate fallback so it can't hold a handler. + const pending = []; + window.createImageBitmap = vi.fn((...args) => (args.length === 2 + ? new Promise((_, reject) => { pending.push(() => reject(new Error('no decoder'))); }) + : Promise.reject(new Error('no decoder')))); + + await mount(); + await click('pick-init'); + await click('pick-init'); + expect(pending).toHaveLength(2); + expect(state.created).toEqual([]); + + await act(async () => { + pending.forEach((fail) => fail()); + await new Promise((r) => setTimeout(r, 0)); + }); + + await waitFor(() => expect(state.created).toHaveLength(1)); + expect(state.createdFiles).toEqual(['photo-2.jpg']); + expect(liveUrls()).toHaveLength(1); + expect(screen.getByTestId('init-url')).toHaveTextContent(state.created[0]); + }); + + it('creates exactly one url when two picks overlap on one reference slot, keeping the last pick', async () => { + const pending = []; + window.createImageBitmap = vi.fn(() => new Promise((_, reject) => { + pending.push(() => reject(new Error('no decoder'))); + })); + + await mount(); + await click('pick-ref-0'); + await click('pick-ref-0'); + expect(pending).toHaveLength(2); + expect(state.created).toEqual([]); + + await act(async () => { + pending.forEach((fail) => fail()); + await new Promise((r) => setTimeout(r, 0)); + }); + + await waitFor(() => expect(state.created).toHaveLength(1)); + expect(state.createdFiles).toEqual(['photo-2.jpg']); + expect(liveUrls()).toHaveLength(1); + expect(screen.getByTestId('ref-url-0')).toHaveTextContent(state.created[0]); + }); });