# Dropzone Polling UX Fix Plan

Created: 2026-04-22
Author: smarcet@gmail.com
Status: VERIFIED
Approved: Yes
Iterations: 2
Worktree: No
Type: Bugfix

## Summary

**Symptom:** After the last chunk is uploaded and the server returns HTTP 202 (async processing), both UploadInputV2 and UploadInputV3 immediately show the file as "Complete" / "success" — even though server-side processing hasn't finished and polling has just started.
**Trigger:** Upload a file where the server returns HTTP 202 on the last chunk (async processing path). The file appears fully uploaded while `pollUploadStatus` is still polling in the background.
**Root Cause:** `src/components/inputs/dropzone/index.js:313` — In the `sending` handler's custom `xhr.onload`, `dropzoneOnLoad(e)` is called unconditionally before checking the HTTP status code. For the 202 path, Dropzone.js processes the response through `_finishedUploading` → `finishedChunkUpload` → `chunksUploaded(file, done)` → `done()` → `_finished` → emits `success` event. This makes both V2 (Dropzone's default UI) and V3 (DropzoneV3 → UploadInputV3) mark the file as complete prematurely.

## Investigation

- Dropzone.js v5.7.2 has a `chunksUploaded(file, done)` option (line 563 in dist/dropzone.js). It's called when all chunks have been uploaded successfully. The default implementation calls `done()` immediately, which triggers `_finished` → `success` event.
- The `success` event sets `file.status = Dropzone.SUCCESS` and updates the file preview in V2's default template. In V3, DropzoneV3 maps `success` → `onFileCompleted` → UploadInputV3 sets `{complete: true}` → shows "Complete" with green check.
- The polling code (`pollUploadStatus`) was added in commit `6bad1f7` but didn't account for the fact that Dropzone.js fires `success` on any 2xx response for the final chunk.
- The `sending` event fires for EVERY chunk. The `xhr.onload` override is per-chunk. For intermediate chunks (200 without `name` field), Dropzone tracks chunk completion. Only when the last chunk response arrives does `finishedChunkUpload` trigger `chunksUploaded`.

## Behavior Contract

**Given:** A file is being uploaded in chunks and the server returns HTTP 202 on the last chunk (indicating async server-side processing is needed)
**When:** The last chunk's XHR response is received and processed
**Currently (bug):** Dropzone.js immediately fires the `success` event, causing both V2 and V3 UIs to show the file as "Complete" / "success" while polling is still running
**Expected (fix):** The `success` event should NOT fire until polling confirms server-side processing is complete (`data.status === 'complete'`). During the polling phase, the file should remain in its uploading/in-progress visual state.
**Anti-regression:** HTTP 200 uploads (synchronous path where the response contains `name` field) must continue to fire `success` immediately as before. Component unmount cleanup must still clear polling intervals. Error handling during polling must still work.

## Fix Approach

**Chosen:** Defer `chunksUploaded` done callback for 202 responses
**Why:** Uses Dropzone.js's public `chunksUploaded` option to control when `_finished` (and thus `success`) fires. Single-file change in `dropzone/index.js` that fixes the UX for both V2 and V3 consumers. No internal API hacking.

**Alternatives considered:**
- *Suppress success via internal `_callbacks` manipulation:* Would require accessing Dropzone's private `_callbacks` object — fragile and version-dependent. Rejected.
- *Add "Processing" state to V3 with new callback:* Better UX (shows "Processing..." text with indeterminate progress) but requires changes across 3 files. Can be done as a follow-up enhancement.
- *Skip `dropzoneOnLoad` for 202:* Would break Dropzone's internal state (chunk tracking, file counters, queue advancement). Rejected.

**Files:** `src/components/inputs/dropzone/index.js`
**Strategy:**
1. In `getDjsConfig()`: Override `chunksUploaded` option with a conditional callback that checks `file._asyncProcessing`. If the flag is set, store the `done` callback on the file object instead of calling it — this prevents `_finished` and the `success` event.
2. In `sending` handler's `xhr.onload`: Set `file._asyncProcessing = true` BEFORE calling `dropzoneOnLoad(e)` when `xhr.status == 202`. This ensures the `chunksUploaded` callback sees the flag.
3. In `pollUploadStatus`: Accept the `file` parameter. When polling returns `status: 'complete'`, call the stored `done` callback (`file._chunksUploadedDone()`) to complete the Dropzone lifecycle — this fires `success`, updating both V2 and V3 UIs.

**Tests:** `src/components/inputs/dropzone/__tests__/dropzone.test.js` (new file)

## Verification Scenario

### TS-001: File Upload with 202 Async Processing
**Preconditions:** App is running, upload endpoint configured to return 202 for async processing

| Step | Action | Expected Result (after fix) |
|------|--------|-----------------------------|
| 1 | Upload a file that triggers 202 response | File stays in uploading/progress state — NOT shown as "Complete" — while server processes |
| 2 | Wait for polling to complete (server returns `status: 'complete'`) | File transitions to "Complete" / success state only now |
| 3 | Upload a file that triggers 200 response (sync) | File immediately shows "Complete" / success (no regression) |

## Progress

- [x] Task 1: Write Reproducing Test (RED)
- [x] Task 2: Implement Fix at Root Cause
- [x] Task 3: Quality Gate
      **Tasks:** 3 | **Done:** 3

**Note:** Build fails with pre-existing PostCSS/SCSS error in schedule-print/styles.module.scss (unrelated to dropzone fix). All 97 tests pass.

**Iteration 1 Fix:** Added polling guard (`file._pollingActive`) to prevent multiple intervals when server returns 202 for every chunk (not just the final one). Without this, each 202 chunk spawned a separate polling loop, causing request flood.

**Iteration 2 Fix:** Fixed request flood (216 HTTP 200 requests for single file) and resource leak on upload cancellation:
1. **Root cause**: `file._asyncProcessing = true` was being set for EVERY 202 response. Server behavior: intermediate chunks return HTTP 200, final chunk returns HTTP 202 with `file_id`. The original Iteration 1 code set the flag at line 340 (before the `else if(xhr?.status == 202)` check), causing the flag to be set even for 200 responses, which deferred `chunksUploaded` for all uploads.
2. **Fix**: Move `file._asyncProcessing = true` inside the `else if(xhr?.status == 202)` branch AND guard it with `if (fileId)` check. Now the flag is only set when we receive the final 202 chunk with `file_id`.
3. **XHR tracking**: Added `this.activeXHRs` Map to track all active XHR requests per file.
4. **Cancellation support**: Cancel pending XHRs when file is removed or component unmounts, preventing resource waste.
5. **Chunk throttling**: Added `maxConcurrentChunks` prop (default: 3) to DropzoneJS, V2, and V3. Wraps Dropzone's `_uploadData` with a concurrency-limited queue. When `parallelChunkUploads: true`, only N chunks upload concurrently instead of all at once. Queue drains as chunks complete via `onChunkComplete()` callback in xhr.onload/onerror.

## Tasks

### Task 1: Write Reproducing Test (RED)

**Objective:** Encode the Behavior Contract as a failing test BEFORE writing any fix code. Create test file for DropzoneJS component.
**Files:** `src/components/inputs/dropzone/__tests__/dropzone.test.js` (new)
**Entry point:** `DropzoneJS` component — specifically the `chunksUploaded` behavior when HTTP 202 is returned on the last chunk.
**Test cases:**
1. `test_dropzone_202_response_should_not_fire_success_immediately` — Simulate a chunked upload where the last chunk returns 202. Verify that the Dropzone `success` event does NOT fire immediately (the `onFileCompleted` callback in the consumer should not be triggered).
2. `test_dropzone_202_polling_complete_fires_success` — After 202 triggers polling and polling returns `status: 'complete'`, verify that the `success` event fires and `onUploadComplete` is called.
3. `test_dropzone_200_response_fires_success_immediately` — (Anti-regression) Simulate a synchronous upload where the last chunk returns 200 with `name` field. Verify `success` fires immediately as before.
**DoD:** Tests exist, run, and FAIL because the current code fires `success` immediately for 202.
**Verify:** `npx jest src/components/inputs/dropzone/__tests__/dropzone.test.js`

### Task 2: Implement Fix at Root Cause

**Objective:** Minimal change in `dropzone/index.js` that defers the `success` event for 202 responses until polling confirms completion.
**Files:** `src/components/inputs/dropzone/index.js`
**Strategy:**
1. In `getDjsConfig()`, after the `options.accept` assignment, add:
   ```javascript
   options.chunksUploaded = (file, done) => {
       if (file._asyncProcessing) {
           file._chunksUploadedDone = done;
           return;
       }
       done();
   };
   ```
2. In `setupEvents()` → `sending` handler → `xhr.onload`, add before `dropzoneOnLoad(e)`:
   ```javascript
   if (xhr?.status == 202) {
       file._asyncProcessing = true;
   }
   ```
3. In `pollUploadStatus`, add `file` parameter. When `data.status === 'complete'`:
   ```javascript
   if (file?._chunksUploadedDone) {
       file._chunksUploadedDone();
   }
   this.onUploadComplete(data);
   ```
4. Update the 202 branch in `xhr.onload` to pass `file` to `pollUploadStatus`.
**DoD:** Reproducing test PASSES. Full test suite PASSES. Diff touches only `src/components/inputs/dropzone/index.js`.
**Verify:** `npx jest src/components/inputs/dropzone/__tests__/dropzone.test.js && npx jest`

### Task 3: Quality Gate

**Objective:** Lint + build clean, full suite re-run.
**DoD:** Lint clean, build green, full suite green. No performance regressions (polling interval unchanged, no new allocations on hot paths).
**Verify:** `npx jest && npm run build`
