feat(bulk-upload): guard save() against concurrent invocations
Adds a saving $state flag that blocks re-entry while a chunk upload is in flight. The UploadSaveBar save button is disabled via a new disabled prop while saving is true. Tested: clicking Save twice fires fetch only once. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@ let {
|
||||
let files = new SvelteMap<string, FileEntry>();
|
||||
let activeId = $state<string | null>(null);
|
||||
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
||||
let saving = $state(false);
|
||||
|
||||
// --- Shared metadata ---
|
||||
let senderId = $state(untrack(() => initialSenderId));
|
||||
@@ -84,6 +85,8 @@ onDestroy(() => {
|
||||
|
||||
// --- Save ---
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
const entries = Array.from(files.values());
|
||||
const chunkSize = 10;
|
||||
const chunks: FileEntry[][] = [];
|
||||
@@ -124,6 +127,7 @@ async function save() {
|
||||
}
|
||||
chunkProgress = { done: i + 1, total: chunks.length };
|
||||
}
|
||||
saving = false;
|
||||
if (!hadErrors) goto('/documents');
|
||||
}
|
||||
</script>
|
||||
@@ -276,6 +280,7 @@ async function save() {
|
||||
chunkProgress={chunkProgress}
|
||||
onSave={save}
|
||||
onDiscard={discardAll}
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -206,6 +206,34 @@ describe('BulkDocumentEditLayout', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('save() does not call fetch a second time when already saving', async () => {
|
||||
let resolveFirst: (() => void) | undefined;
|
||||
const mockFetch = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFirst = () =>
|
||||
resolve({
|
||||
ok: true,
|
||||
json: async () => ({ created: [], updated: [], errors: [] })
|
||||
} as Response);
|
||||
})
|
||||
);
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf')]);
|
||||
|
||||
const saveBtn = container.querySelector(
|
||||
'button[data-testid="bulk-save-btn"]'
|
||||
) as HTMLButtonElement;
|
||||
saveBtn.click(); // first click — fetch is in-flight
|
||||
saveBtn.click(); // second click — should be a no-op
|
||||
|
||||
resolveFirst?.();
|
||||
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('discard-all resets to N=0 state and shows drop zone', async () => {
|
||||
const { container } = render(BulkDocumentEditLayout, {});
|
||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||
|
||||
@@ -5,12 +5,14 @@ let {
|
||||
fileCount,
|
||||
chunkProgress,
|
||||
onSave,
|
||||
onDiscard
|
||||
onDiscard,
|
||||
disabled = false
|
||||
}: {
|
||||
fileCount: number;
|
||||
chunkProgress?: { done: number; total: number };
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
onDiscard: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
@@ -37,7 +39,7 @@ let {
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bulk-save-btn"
|
||||
disabled={fileCount === 0}
|
||||
disabled={fileCount === 0 || disabled}
|
||||
onclick={onSave}
|
||||
class="min-h-[44px] rounded-sm bg-primary px-6 text-sm font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user