Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions client/src/pages/ImageGen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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); }
};
Expand All @@ -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
Expand Down
57 changes: 55 additions & 2 deletions client/src/pages/ImageGen.objectUrls.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down Expand Up @@ -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); })),
Expand Down Expand Up @@ -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]);
});
});