Dentolize · Upload Progress Walkthrough
On this pageBusiness viewTechnical view

Streaming the bytes instead of buffering them

Business view

Reporting upload progress only works if the phone's operating system tells the app about bytes as they leave — and, for one of the app's two upload routes, it wasn't being asked to. The fix wasn't just "add a progress bar"; it required changing how the file is sent so the operating system has something to report in the first place.

Dentolize stores uploaded files in one of two places depending on how a clinic is set up: a presigned Amazon S3 upload (the default), or the clinic's own Google Drive (for clinics that have connected it). Before this PR, the Google Drive route already streamed the file from disk and could report progress. The S3 route did not — it built the whole request in memory and handed it to the phone's network layer as one unit, and on iOS in particular, the OS only reports "0% or 100%" for a request built that way. That's why an S3 upload showed nothing but a spinner no matter how large the file was, while Drive already behaved slightly better.

This PR brings the S3 route in line with how Drive is sent: the file is streamed from a file on disk rather than buffered in memory, so the OS can report bytes as they actually leave the device.

Technical view

Both routes live in handleUploadFile (packages/clinic-mobile/src/components/dashboard/patients/patient/files/handleUploadFile.js), which picks a route based on user.company.googleDriveAuth (handleUploadFile.js:38) and returns the same shape either way ({ data: { signS3: { url } } }).

Google Drive route (handleUploadFile.js:136-148)

Previously used FileSystem.uploadAsync(...), a one-shot call with no progress callback. Now uses FileSystem.createUploadTask(url, uri, options, onProgress), whose fourth argument fires with { totalBytesSent, totalBytesExpectedToSend } as the PUT to Drive's resumable session URL streams. The task's .uploadAsync() is then awaited to get the same response object as before.

S3 route (handleUploadFile.js:171-199)

This is the route that changed behavior, not just implementation. Previously:

const formData = new FormData()
Object.keys(postReq.fields).forEach(key => formData.append(key, postReq.fields[key]))
formData.append('Content-Type', fileType)
formData.append('file', { uri, name: fileName, type: fileType })

await fetch(postReq.url, {
  headers: { Accept: 'application/json', 'Content-Type': 'multipart/form-data' },
  method: 'POST',
  body: formData
})

FormData bodies like this are buffered by the runtime before being handed to the network stack, and — per the PR description — iOS reports the whole body as sent in one event rather than incrementally, which is why this route never showed real progress.

Now the S3 POST is sent the same way as the Drive PUT, via FileSystem.createUploadTask, configured for a multipart body:

const uploadTask = FileSystem.createUploadTask(
  postReq.url,
  uri,
  {
    httpMethod: 'POST',
    uploadType: FileSystem.FileSystemUploadType.MULTIPART,
    fieldName: 'file',
    mimeType: fileType,
    parameters: { ...postReq.fields, 'Content-Type': fileType }
  },
  ({ totalBytesSent, totalBytesExpectedToSend }) =>
    onProgress?.({ sent: totalBytesSent, total: totalBytesExpectedToSend || fileSize || 0 })
)

await uploadTask.uploadAsync()

Two details matter here for S3 specifically:

  • Field order. S3's presigned POST policy requires the signed form fields to

appear before the file part in the multipart body. createUploadTask's parameters option is written ahead of the file field it streams (handleUploadFile.js:193), which is what makes this work — the old FormData version happened to satisfy this too by construction order.

  • Fallback total. If the platform doesn't report totalBytesExpectedToSend

(falsy), the callback falls back to the fileSize the caller already knew, and finally to 0 (handleUploadFile.js:145, :196) — this is what lets useUploadProgress.start() show an immediate (if approximate) percentage before the first real progress event arrives, then get corrected once the platform's own number comes in on subsequent calls (see Knowing how far along an upload is).

Both callbacks are wired to the same onProgress prop passed into handleUploadFile, so callers don't need to know which route was taken — they just provide one onProgress handler and get consistent { sent, total } updates either way.