fix(web): hide Retry when blob unavailable; failed-state surfaces actionable error
Pending bubbles that survive a reload (or post-redeploy refresh) without a FileSystemFileHandle had no way to recover the original File bytes, so the Retry button silently no-op'd: resumeUpload set state back to 'paused' with no surfaced error, leaving the user stuck. - transferStore: add reactive hasInMemoryFile Set mirroring liveUploadFiles. setInMemoryRef helper keeps both in sync at every set/delete site (startUpload, resumeUpload, onSuccess, remove). Persisted shape is unchanged. - resumeUpload: when no blob is reachable (no in-memory File and no FS handle), call setError with an actionable "File no longer available — discard and re-upload" message instead of silently flipping back to 'paused'. - Message.tsx: compute canRetry reactively from transfersForRow + hasInMemoryFile; hide the Retry button when retry is infeasible. Discard remains. - AttachmentProgress: optional error prop surfaces transfer.error.message via title= on the failed-state ring for hover context. - Tests: existing 3 resumeUpload tests now assert state==='failed' with the actionable message; +2 new tests for hasInMemoryFile lifecycle (start/remove, abort retains).
This commit is contained in:
@@ -6,6 +6,8 @@ interface Props {
|
|||||||
total: number;
|
total: number;
|
||||||
state: TransferState;
|
state: TransferState;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
/** Optional human-readable error surfaced as a hover tooltip when state==='failed'. */
|
||||||
|
error?: string;
|
||||||
onPause?: () => void;
|
onPause?: () => void;
|
||||||
onResume?: () => void;
|
onResume?: () => void;
|
||||||
onAbort?: () => void;
|
onAbort?: () => void;
|
||||||
@@ -19,7 +21,7 @@ function fmt(bytes: number): string {
|
|||||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AttachmentProgress({ loaded, total, state, filename, onPause, onResume, onAbort, size = 'tile' }: Props) {
|
export function AttachmentProgress({ loaded, total, state, filename, error, onPause, onResume, onAbort, size = 'tile' }: Props) {
|
||||||
const pct = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
|
const pct = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
|
||||||
const bg = state === 'failed' ? 'bg-accent-rose/30' : 'bg-accent-mint/30';
|
const bg = state === 'failed' ? 'bg-accent-rose/30' : 'bg-accent-mint/30';
|
||||||
const isFinal = state === 'completed' || state === 'aborted';
|
const isFinal = state === 'completed' || state === 'aborted';
|
||||||
@@ -28,6 +30,7 @@ export function AttachmentProgress({ loaded, total, state, filename, onPause, on
|
|||||||
<div
|
<div
|
||||||
className={`w-9 h-9 rounded-full ${bg} flex items-center justify-center`}
|
className={`w-9 h-9 rounded-full ${bg} flex items-center justify-center`}
|
||||||
style={state !== 'failed' ? { background: `conic-gradient(rgba(180,220,200,.85) ${pct}%, rgba(255,255,255,.15) ${pct}%)` } : undefined}
|
style={state !== 'failed' ? { background: `conic-gradient(rgba(180,220,200,.85) ${pct}%, rgba(255,255,255,.15) ${pct}%)` } : undefined}
|
||||||
|
title={state === 'failed' ? error : undefined}
|
||||||
>
|
>
|
||||||
<div className="w-7 h-7 rounded-full bg-surface-overlay text-[10px] text-txt-primary flex items-center justify-center font-medium">
|
<div className="w-7 h-7 rounded-full bg-surface-overlay text-[10px] text-txt-primary flex items-center justify-center font-medium">
|
||||||
{state === 'failed' ? '!' : `${pct}%`}
|
{state === 'failed' ? '!' : `${pct}%`}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ function PendingAttachmentTile({ transferId }: PendingAttachmentTileProps) {
|
|||||||
total={transfer.progress.total}
|
total={transfer.progress.total}
|
||||||
state={transfer.state}
|
state={transfer.state}
|
||||||
filename={transfer.file.name}
|
filename={transfer.file.name}
|
||||||
|
error={transfer.error?.message}
|
||||||
onPause={transfer.state === 'active' ? () => pauseUpload(transfer.id) : undefined}
|
onPause={transfer.state === 'active' ? () => pauseUpload(transfer.id) : undefined}
|
||||||
onResume={transfer.state === 'paused' ? () => resumeUpload(transfer.id) : undefined}
|
onResume={transfer.state === 'paused' ? () => resumeUpload(transfer.id) : undefined}
|
||||||
onAbort={() => abortUpload(transfer.id)}
|
onAbort={() => abortUpload(transfer.id)}
|
||||||
@@ -133,11 +134,22 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
const showInteractions = !pending;
|
const showInteractions = !pending;
|
||||||
|
|
||||||
const transfersForRow = useTransferStore((s) => s.transfers);
|
const transfersForRow = useTransferStore((s) => s.transfers);
|
||||||
|
const inMemoryFiles = useTransferStore((s) => s.hasInMemoryFile);
|
||||||
const anyTransferTerminallyBad = !!pending && pending.transferIds.some((tid) => {
|
const anyTransferTerminallyBad = !!pending && pending.transferIds.some((tid) => {
|
||||||
const t = transfersForRow.get(tid);
|
const t = transfersForRow.get(tid);
|
||||||
return t && (t.state === 'failed' || t.state === 'aborted');
|
return t && (t.state === 'failed' || t.state === 'aborted');
|
||||||
});
|
});
|
||||||
const showRetryDiscardRow = pending?.state === 'failed' || anyTransferTerminallyBad;
|
const showRetryDiscardRow = pending?.state === 'failed' || anyTransferTerminallyBad;
|
||||||
|
// Retry is only feasible when we can re-source the bytes for every transfer in the
|
||||||
|
// pending row — either the in-memory File ref still exists (same-session retry)
|
||||||
|
// or a persisted FileSystemFileHandle can reacquire the bytes (Chrome/Edge drag-drop).
|
||||||
|
// After a reload (or post-redeploy refresh) without a handle, both are gone, and
|
||||||
|
// showing a Retry button that silently no-ops would strand the user. Hide it instead.
|
||||||
|
const canRetry = !!pending && pending.transferIds.every((tid) => {
|
||||||
|
const t = transfersForRow.get(tid);
|
||||||
|
if (!t || t.type !== 'upload') return false;
|
||||||
|
return inMemoryFiles.has(tid) || !!t.fileHandleId;
|
||||||
|
});
|
||||||
|
|
||||||
const channelKey: string = isPendingMessage(message)
|
const channelKey: string = isPendingMessage(message)
|
||||||
? message.channelId || message.dmChannelId || ''
|
? message.channelId || message.dmChannelId || ''
|
||||||
@@ -519,24 +531,26 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
Upload failed
|
Upload failed
|
||||||
</span>
|
</span>
|
||||||
<span className="w-px h-3.5 bg-accent-rose/25" aria-hidden="true" />
|
<span className="w-px h-3.5 bg-accent-rose/25" aria-hidden="true" />
|
||||||
<button
|
{canRetry && (
|
||||||
onClick={async () => {
|
<button
|
||||||
const transfers = useTransferStore.getState().transfers;
|
onClick={async () => {
|
||||||
const retryIds = pending.transferIds.filter((tid) => {
|
const transfers = useTransferStore.getState().transfers;
|
||||||
const s = transfers.get(tid)?.state;
|
const retryIds = pending.transferIds.filter((tid) => {
|
||||||
return s === 'failed' || s === 'aborted';
|
const s = transfers.get(tid)?.state;
|
||||||
});
|
return s === 'failed' || s === 'aborted';
|
||||||
// Flip the bubble back to 'sending' first so the orchestrator
|
});
|
||||||
// re-evaluates after the resumed transfers complete.
|
// Flip the bubble back to 'sending' first so the orchestrator
|
||||||
usePendingMessageStore.getState().markSending(pending.clientId);
|
// re-evaluates after the resumed transfers complete.
|
||||||
for (const tid of retryIds) {
|
usePendingMessageStore.getState().markSending(pending.clientId);
|
||||||
await useTransferStore.getState().resumeUpload(tid);
|
for (const tid of retryIds) {
|
||||||
}
|
await useTransferStore.getState().resumeUpload(tid);
|
||||||
}}
|
}
|
||||||
className="px-2 py-0.5 rounded-md text-[11.5px] font-medium text-accent-mint bg-accent-mint/10 hover:bg-accent-mint/20 transition-colors"
|
}}
|
||||||
>
|
className="px-2 py-0.5 rounded-md text-[11.5px] font-medium text-accent-mint bg-accent-mint/10 hover:bg-accent-mint/20 transition-colors"
|
||||||
Retry
|
>
|
||||||
</button>
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const transfers = useTransferStore.getState().transfers;
|
const transfers = useTransferStore.getState().transfers;
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export interface CreateTransferInput {
|
|||||||
|
|
||||||
interface TransferStoreState {
|
interface TransferStoreState {
|
||||||
transfers: Map<string, Transfer>;
|
transfers: Map<string, Transfer>;
|
||||||
|
/**
|
||||||
|
* Mirrors the keys of the module-scoped `liveUploadFiles` map so React components
|
||||||
|
* can subscribe to "do we still hold the original File for this transfer?" reactively.
|
||||||
|
* Session-scoped — never persisted (the underlying File refs vanish on reload).
|
||||||
|
*/
|
||||||
|
hasInMemoryFile: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TransferStoreActions {
|
interface TransferStoreActions {
|
||||||
@@ -129,8 +135,23 @@ const mapAwareStorage: PersistStorage<Pick<TransferStoreState, 'transfers'>> = {
|
|||||||
|
|
||||||
export const useTransferStore = create<TransferStore>()(
|
export const useTransferStore = create<TransferStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => {
|
||||||
|
// DRY helper: keep the reactive `hasInMemoryFile` set in sync with the
|
||||||
|
// module-scoped `liveUploadFiles` map. Both call-sites that mutate the map
|
||||||
|
// immediately follow up with this so subscribers re-render.
|
||||||
|
const setInMemoryRef = (id: string, present: boolean) => {
|
||||||
|
set((s) => {
|
||||||
|
const has = s.hasInMemoryFile.has(id);
|
||||||
|
if (present === has) return s;
|
||||||
|
const next = new Set(s.hasInMemoryFile);
|
||||||
|
if (present) next.add(id); else next.delete(id);
|
||||||
|
return { hasInMemoryFile: next };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return ({
|
||||||
transfers: new Map<string, Transfer>(),
|
transfers: new Map<string, Transfer>(),
|
||||||
|
hasInMemoryFile: new Set<string>(),
|
||||||
|
|
||||||
createTransfer: (input) => {
|
createTransfer: (input) => {
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
@@ -196,13 +217,16 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
return { transfers: next };
|
return { transfers: next };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
remove: (id) => set((s) => {
|
remove: (id) => {
|
||||||
if (!s.transfers.has(id)) return s;
|
|
||||||
const next = new Map(s.transfers);
|
|
||||||
next.delete(id);
|
|
||||||
liveUploadFiles.delete(id);
|
liveUploadFiles.delete(id);
|
||||||
return { transfers: next };
|
setInMemoryRef(id, false);
|
||||||
}),
|
set((s) => {
|
||||||
|
if (!s.transfers.has(id)) return s;
|
||||||
|
const next = new Map(s.transfers);
|
||||||
|
next.delete(id);
|
||||||
|
return { transfers: next };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
startUpload: async (file, opts) => {
|
startUpload: async (file, opts) => {
|
||||||
const token = useAuthStore.getState().token;
|
const token = useAuthStore.getState().token;
|
||||||
@@ -262,6 +286,7 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
} finally {
|
} finally {
|
||||||
liveUploads.delete(id);
|
liveUploads.delete(id);
|
||||||
liveUploadFiles.delete(id);
|
liveUploadFiles.delete(id);
|
||||||
|
setInMemoryRef(id, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
@@ -276,6 +301,7 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
const upload = new Upload(file as File, tusOpts);
|
const upload = new Upload(file as File, tusOpts);
|
||||||
liveUploads.set(id, upload);
|
liveUploads.set(id, upload);
|
||||||
liveUploadFiles.set(id, file);
|
liveUploadFiles.set(id, file);
|
||||||
|
setInMemoryRef(id, true);
|
||||||
upload.start();
|
upload.start();
|
||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
@@ -349,10 +375,14 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!blob) {
|
if (!blob) {
|
||||||
// No handle, no in-memory file — surface "re-pick to resume" to the user.
|
// No in-memory File (post-reload) AND no FS handle to reacquire bytes from.
|
||||||
// (Cross-reload on Firefox/Safari, or after MessageInput unmount cleared
|
// The user must discard and re-upload. Setting 'failed' lets the orchestrator's
|
||||||
// the in-memory File and the bubble survives without an FS handle.)
|
// mark-failed sweep keep the bubble in failed state and Message.tsx surfaces
|
||||||
get().setState_(id, 'paused');
|
// the discard control. The Retry button is hidden in this case (canRetry gate).
|
||||||
|
get().setError(id, {
|
||||||
|
message: 'File no longer available — discard and re-upload',
|
||||||
|
permanent: true,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +455,7 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
} finally {
|
} finally {
|
||||||
liveUploads.delete(id);
|
liveUploads.delete(id);
|
||||||
liveUploadFiles.delete(id);
|
liveUploadFiles.delete(id);
|
||||||
|
setInMemoryRef(id, false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
@@ -435,6 +466,11 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Retain the resolved blob in the in-memory map so a subsequent retry-after-failure
|
||||||
|
// (e.g., transient network error) can resume without going through the handle path.
|
||||||
|
liveUploadFiles.set(id, blob);
|
||||||
|
setInMemoryRef(id, true);
|
||||||
|
|
||||||
const upload = new Upload(blob as File, tusOpts);
|
const upload = new Upload(blob as File, tusOpts);
|
||||||
liveUploads.set(id, upload);
|
liveUploads.set(id, upload);
|
||||||
upload.start();
|
upload.start();
|
||||||
@@ -631,7 +667,8 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
listVisible: () => Array.from(get().transfers.values()).filter((t) => t.tray),
|
listVisible: () => Array.from(get().transfers.values()).filter((t) => t.tray),
|
||||||
listForChannel: (channelId) =>
|
listForChannel: (channelId) =>
|
||||||
Array.from(get().transfers.values()).filter((t) => t.channelId === channelId),
|
Array.from(get().transfers.values()).filter((t) => t.channelId === channelId),
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'transferStore@v1',
|
name: 'transferStore@v1',
|
||||||
storage: mapAwareStorage,
|
storage: mapAwareStorage,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ import { useTransferStore } from './transferStore';
|
|||||||
|
|
||||||
describe('transferStore.startUpload', () => {
|
describe('transferStore.startUpload', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useTransferStore.setState({ transfers: new Map() });
|
useTransferStore.setState({ transfers: new Map(), hasInMemoryFile: new Set() });
|
||||||
startMock.mockClear();
|
startMock.mockClear();
|
||||||
abortMock.mockClear();
|
abortMock.mockClear();
|
||||||
if (typeof localStorage !== 'undefined') localStorage.clear();
|
if (typeof localStorage !== 'undefined') localStorage.clear();
|
||||||
@@ -118,12 +118,12 @@ describe('transferStore.startUpload', () => {
|
|||||||
|
|
||||||
describe('transferStore.resumeUpload', () => {
|
describe('transferStore.resumeUpload', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useTransferStore.setState({ transfers: new Map() });
|
useTransferStore.setState({ transfers: new Map(), hasInMemoryFile: new Set() });
|
||||||
startMock.mockClear();
|
startMock.mockClear();
|
||||||
abortMock.mockClear();
|
abortMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stays paused when transfer has no tusUploadUrl and no available blob', async () => {
|
it('marks failed when transfer has no available blob to resume from', async () => {
|
||||||
const id = useTransferStore.getState().createTransfer({
|
const id = useTransferStore.getState().createTransfer({
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
||||||
@@ -132,12 +132,15 @@ describe('transferStore.resumeUpload', () => {
|
|||||||
useTransferStore.getState().setState_(id, 'paused');
|
useTransferStore.getState().setState_(id, 'paused');
|
||||||
await useTransferStore.getState().resumeUpload(id);
|
await useTransferStore.getState().resumeUpload(id);
|
||||||
const t = useTransferStore.getState().get(id)!;
|
const t = useTransferStore.getState().get(id)!;
|
||||||
// No URL + no in-memory file + no handle → can neither resume nor restart;
|
// No URL + no in-memory file + no handle → cannot resume or restart.
|
||||||
// surface 'paused' to prompt the user to re-pick.
|
// Surface 'failed' with an actionable message so the UI shows Discard
|
||||||
expect(t.state).toBe('paused');
|
// and hides the Retry button (which would silently no-op).
|
||||||
|
expect(t.state).toBe('failed');
|
||||||
|
expect(t.error?.message ?? '').toMatch(/file no longer available/i);
|
||||||
|
expect(t.error?.permanent).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stays paused when tus URL has expired and no available blob', async () => {
|
it('marks failed when tus URL has expired and no available blob', async () => {
|
||||||
const id = useTransferStore.getState().createTransfer({
|
const id = useTransferStore.getState().createTransfer({
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
||||||
@@ -146,11 +149,13 @@ describe('transferStore.resumeUpload', () => {
|
|||||||
useTransferStore.getState().setTusUrl(id, '/api/files/expired', Date.now() - 1000);
|
useTransferStore.getState().setTusUrl(id, '/api/files/expired', Date.now() - 1000);
|
||||||
useTransferStore.getState().setState_(id, 'paused');
|
useTransferStore.getState().setState_(id, 'paused');
|
||||||
await useTransferStore.getState().resumeUpload(id);
|
await useTransferStore.getState().resumeUpload(id);
|
||||||
// Expired URL with no blob to restart → stay paused, user re-picks.
|
const t = useTransferStore.getState().get(id)!;
|
||||||
expect(useTransferStore.getState().get(id)!.state).toBe('paused');
|
// Expired URL with no blob to restart → failed, user must discard and re-upload.
|
||||||
|
expect(t.state).toBe('failed');
|
||||||
|
expect(t.error?.message ?? '').toMatch(/file no longer available/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stays paused when no FS handle is available (re-pick required)', async () => {
|
it('marks failed when no FS handle is available (re-pick required)', async () => {
|
||||||
const id = useTransferStore.getState().createTransfer({
|
const id = useTransferStore.getState().createTransfer({
|
||||||
type: 'upload',
|
type: 'upload',
|
||||||
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
||||||
@@ -160,6 +165,54 @@ describe('transferStore.resumeUpload', () => {
|
|||||||
useTransferStore.getState().setTusUrl(id, '/api/files/abc', Date.now() + 60_000);
|
useTransferStore.getState().setTusUrl(id, '/api/files/abc', Date.now() + 60_000);
|
||||||
useTransferStore.getState().setState_(id, 'paused');
|
useTransferStore.getState().setState_(id, 'paused');
|
||||||
await useTransferStore.getState().resumeUpload(id);
|
await useTransferStore.getState().resumeUpload(id);
|
||||||
expect(useTransferStore.getState().get(id)!.state).toBe('paused');
|
const t = useTransferStore.getState().get(id)!;
|
||||||
|
expect(t.state).toBe('failed');
|
||||||
|
expect(t.error?.message ?? '').toMatch(/file no longer available/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('transferStore.hasInMemoryFile', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useTransferStore.setState({ transfers: new Map(), hasInMemoryFile: new Set() });
|
||||||
|
startMock.mockClear();
|
||||||
|
abortMock.mockClear();
|
||||||
|
if (typeof localStorage !== 'undefined') localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasInMemoryFile tracks startUpload + remove lifecycle', async () => {
|
||||||
|
const file = new File([new Uint8Array(100)], 'a.png', { type: 'image/png' });
|
||||||
|
const id = await useTransferStore.getState().startUpload(file, { tray: true });
|
||||||
|
// The mock's onSuccess fires synchronously inside start(), which clears the
|
||||||
|
// in-memory ref. So here we should see it cleared (transfer is 'completed').
|
||||||
|
expect(useTransferStore.getState().get(id)!.state).toBe('completed');
|
||||||
|
expect(useTransferStore.getState().hasInMemoryFile.has(id)).toBe(false);
|
||||||
|
useTransferStore.getState().remove(id);
|
||||||
|
expect(useTransferStore.getState().hasInMemoryFile.has(id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasInMemoryFile survives abortUpload (file retained for retry)', async () => {
|
||||||
|
// Simulate an in-flight transfer (pre-success) by manually populating the
|
||||||
|
// store and the in-memory file ref via a fresh transfer that hasn't completed.
|
||||||
|
// We do this by intercepting startUpload before the mock resolves: createTransfer
|
||||||
|
// and prime hasInMemoryFile + state directly to mirror an in-flight upload.
|
||||||
|
const id = useTransferStore.getState().createTransfer({
|
||||||
|
type: 'upload',
|
||||||
|
file: { name: 'a.png', size: 100, mimetype: 'image/png' },
|
||||||
|
tray: true,
|
||||||
|
});
|
||||||
|
// Prime the reactive set to mirror a live upload.
|
||||||
|
useTransferStore.setState((s) => {
|
||||||
|
const next = new Set(s.hasInMemoryFile);
|
||||||
|
next.add(id);
|
||||||
|
return { hasInMemoryFile: next };
|
||||||
|
});
|
||||||
|
useTransferStore.getState().setState_(id, 'active');
|
||||||
|
|
||||||
|
expect(useTransferStore.getState().hasInMemoryFile.has(id)).toBe(true);
|
||||||
|
useTransferStore.getState().abortUpload(id);
|
||||||
|
// abortUpload must NOT clear the in-memory ref — the user can still retry.
|
||||||
|
expect(useTransferStore.getState().hasInMemoryFile.has(id)).toBe(true);
|
||||||
|
useTransferStore.getState().remove(id);
|
||||||
|
expect(useTransferStore.getState().hasInMemoryFile.has(id)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user