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 files = new SvelteMap<string, FileEntry>();
|
||||||
let activeId = $state<string | null>(null);
|
let activeId = $state<string | null>(null);
|
||||||
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
let chunkProgress = $state<{ done: number; total: number } | undefined>(undefined);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
// --- Shared metadata ---
|
// --- Shared metadata ---
|
||||||
let senderId = $state(untrack(() => initialSenderId));
|
let senderId = $state(untrack(() => initialSenderId));
|
||||||
@@ -84,6 +85,8 @@ onDestroy(() => {
|
|||||||
|
|
||||||
// --- Save ---
|
// --- Save ---
|
||||||
async function save() {
|
async function save() {
|
||||||
|
if (saving) return;
|
||||||
|
saving = true;
|
||||||
const entries = Array.from(files.values());
|
const entries = Array.from(files.values());
|
||||||
const chunkSize = 10;
|
const chunkSize = 10;
|
||||||
const chunks: FileEntry[][] = [];
|
const chunks: FileEntry[][] = [];
|
||||||
@@ -124,6 +127,7 @@ async function save() {
|
|||||||
}
|
}
|
||||||
chunkProgress = { done: i + 1, total: chunks.length };
|
chunkProgress = { done: i + 1, total: chunks.length };
|
||||||
}
|
}
|
||||||
|
saving = false;
|
||||||
if (!hadErrors) goto('/documents');
|
if (!hadErrors) goto('/documents');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -276,6 +280,7 @@ async function save() {
|
|||||||
chunkProgress={chunkProgress}
|
chunkProgress={chunkProgress}
|
||||||
onSave={save}
|
onSave={save}
|
||||||
onDiscard={discardAll}
|
onDiscard={discardAll}
|
||||||
|
disabled={saving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 () => {
|
it('discard-all resets to N=0 state and shows drop zone', async () => {
|
||||||
const { container } = render(BulkDocumentEditLayout, {});
|
const { container } = render(BulkDocumentEditLayout, {});
|
||||||
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
await addFilesViaInput(container, [makeFile('a.pdf'), makeFile('b.pdf')]);
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ let {
|
|||||||
fileCount,
|
fileCount,
|
||||||
chunkProgress,
|
chunkProgress,
|
||||||
onSave,
|
onSave,
|
||||||
onDiscard
|
onDiscard,
|
||||||
|
disabled = false
|
||||||
}: {
|
}: {
|
||||||
fileCount: number;
|
fileCount: number;
|
||||||
chunkProgress?: { done: number; total: number };
|
chunkProgress?: { done: number; total: number };
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onDiscard: () => void;
|
onDiscard: () => void | Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ let {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="bulk-save-btn"
|
data-testid="bulk-save-btn"
|
||||||
disabled={fileCount === 0}
|
disabled={fileCount === 0 || disabled}
|
||||||
onclick={onSave}
|
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"
|
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