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:
Marcel
2026-04-25 11:03:58 +02:00
parent 9aed929b67
commit 1299f191e2
3 changed files with 38 additions and 3 deletions

View File

@@ -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>

View File

@@ -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')]);

View File

@@ -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"
>