import { FC, InputHTMLAttributes, ReactNode } from "react";
//#region src/uploader/errors.d.ts
type UploadErrorType = "REJECTED" | "REQUEST" | "PART" | "PART_SIZES" | "COMPLETE" | "ABORTED";
interface UploadErrorOptions {
message?: string;
cause?: unknown;
/** Name of the file the failure concerns. Feeds the default message. */
fileName?: string;
/** HTTP status for `PART` failures. `0` means no response at all: network error or stall. */
status?: number;
/** Parsed `Retry-After`, in ms. Honored by the retry policy when present. */
retryAfterMs?: number;
}
/**
* The single error type the uploader surfaces. `type` discriminates the failure source; when the
* uploader wraps an application-level error (a rejected `requestUpload`, a transport failure), the
* original is preserved on the standard `cause` property.
*
* Consumers may also throw `UploadError` from `requestUpload` / `completeUpload` — e.g.
* `throw new UploadError("REQUEST", { message: "Daily quota reached." })` — and it passes through
* unwrapped, so the message reaches `file.error` verbatim.
*/
declare class UploadError extends Error {
readonly type: UploadErrorType;
readonly fileName?: string;
readonly status?: number;
readonly retryAfterMs?: number;
/**
* Whether this is a bug in the integration rather than a runtime condition. Dev-bug errors are
* logged unconditionally in addition to being surfaced, and are never retried — retrying a
* contract violation only hides it.
*/
get dev(): boolean;
constructor(type: UploadErrorType, options?: UploadErrorOptions);
}
/**
* Wrap anything thrown by a consumer callback. An existing `UploadError` passes through unchanged
* so a deliberate `throw new UploadError(...)` keeps its type and message. Otherwise a thrown
* `Error`'s own message is preferred over the generic default — consumers throw from
* `requestUpload` precisely to say something specific ("Files cannot be larger than 10 MB"), and
* replacing that with "Could not start the upload" would throw away the only useful part.
*/
declare const toUploadError: (error: unknown, type: UploadErrorType, options?: UploadErrorOptions) => UploadError;
//#endregion
//#region src/uploader/uploader.types.d.ts
/**
* A part's lifecycle. Separate from `FileStatus`: parts have no signing or completion phase, and
* they have a retry-backoff phase (`WAITING`) that files don't.
*
* `WAITING` exists so backoff costs no concurrency — only `UPLOADING` holds a part slot. It is a
* real status rather than `QUEUED` plus a `retryAt` timestamp because the scheduler's eligibility
* test has to be reactive, and MobX cannot invalidate a computed that reads the clock. The wait is
* therefore expressed as a timer that flips the status, not as a comparison against `Date.now()`.
*
* There is no `CANCELED`: a canceled part is disposed and its file removed from the collection, so
* no observer can ever see one.
*/
type PartStatus = "QUEUED" | "UPLOADING" | "WAITING" | "COMPLETED" | "FAILED";
/**
* A file's lifecycle. One member per phase with a distinct concurrency budget or a distinct set of
* legal transitions:
*
* - `PENDING` — accepted, waiting for a slot. No network activity.
* - `REQUESTING` — `requestUpload` is in flight. No parts exist yet.
* - `UPLOADING` — parts exist. The only status whose parts are eligible for part slots.
* - `COMPLETING` — every part uploaded and `completeUpload` is in flight. Without this phase a
* `completeUpload` failure would have to retract an already-emitted value.
* - `COMPLETED` — terminal success, and the only status that contributes to `values`.
* - `FAILED` — terminal failure; re-enterable via `retry()`.
*
* There is no `QUEUED`: "parts exist but none has started" is not a phase, it is `UPLOADING` at
* `progress === 0`. There is no `CANCELED`: a canceled upload is removed from `uploads`, which is
* the single source of truth for whether an upload exists.
*/
type FileStatus = "PENDING" | "REQUESTING" | "UPLOADING" | "COMPLETING" | "COMPLETED" | "FAILED";
/** One signed part URL and the exact number of bytes the server signed it for. */
interface UploadPart {
url: string;
/**
* Exact byte length of this part. **Not advisory** — the uploader slices the file with a running
* offset from these sizes and never derives them itself.
*
* Backends that sign each part URL with `Content-Length` in `signableHeaders` reject any other
* split with `403 SignatureDoesNotMatch`, because `Content-Length` is a forbidden header: the
* browser derives it from the blob and a script cannot override it. Sizes must sum exactly to
* `file.size`; a mismatch fails the file with `UploadError("PART_SIZES")` and is logged as an
* integration bug.
*/
size: number;
}
/** What `requestUpload` resolves to. */
interface UploadRequestResult {
/**
* Server-side identifier, handed back to `completeUpload` / `cancelUpload` and emitted as the
* form value. Opaque to the uploader — an S3 key, a UUID, anything.
*/
id: string;
/**
* Canonical filename, carried explicitly rather than parsed out of `id`. Wins over the local
* `File.name` once known, so server-side normalization or de-duplication shows up in the UI.
*/
name: string;
/** One entry per part, in order. An empty array is legal for a zero-byte file. */
parts: UploadPart[];
}
/**
* A completed upload, as emitted to `onChange` and accepted by `value`.
*
* `name` is required rather than optional, and a bare id is deliberately **not** accepted. The name is
* not derivable from the id — an id may be a storage key that happens to end in a filename, or an
* opaque uuid that doesn't — so a library that guessed would render a uuid as a filename for half its
* consumers. Persist the name alongside the id in whatever holds your form value, or fetch it when the
* form loads.
*/
interface UploadValue {
id: string;
name: string;
}
/** Everything in `uploads`: files this uploader is uploading, plus rehydrated completed uploads. */
type Upload = FileModel | CompletedUploadModel;
/**
* The surface every `Upload` shares, so list UIs need no `instanceof` branching. The reference
* implementation's two upload classes had no common shape, which is how single-file replacement
* ended up cancelling only the in-flight files and leaving a rehydrated upload behind.
*/
interface UploadLike {
/** Stable client-side identity, for React keys. Never the server id. */
readonly key: string;
readonly name: string;
readonly extension: string;
readonly status: FileStatus;
/** 0–100. Never `NaN`. */
readonly progress: number;
/** The server's identifier, once known. */
readonly uploadId: string | undefined;
/** The `{ id, name }` pair emitted to `onChange`, or `undefined` until the upload completes. */
readonly value: UploadValue | undefined;
readonly error: UploadError | undefined;
remove(): void;
activate(): void;
dispose(): void;
}
/**
* Transport for a single part attempt. Must reject with `UploadError("ABORTED")` when `signal`
* aborts, and report cumulative bytes sent through `onProgress`.
*/
type UploadPartFn = (signal: AbortSignal, part: PartModel, onProgress: (loaded: number) => void) => Promise;
interface UploaderConfig {
/**
* Ask the server for an upload id and one signed URL per part. The `signal` aborts when the file
* is removed or the uploader is disposed — forward it to your fetch/client so a cancel during
* signing doesn't leave a request in flight.
*
* Not auto-retried: it is your API call through your client, which likely has its own retry
* policy. Throw `UploadError` to control the message the user sees; any other throw is wrapped as
* `UploadError("REQUEST", { cause })` and keeps its own message. Recover with `file.retry()`.
*/
requestUpload: (signal: AbortSignal, file: FileModel) => Promise;
/**
* Optional client-driven finalization (e.g. `POST /uploads/:id/complete`) for backends that don't
* complete the multipart upload themselves. Called at most once per file, after every part has
* uploaded; the file sits in `COMPLETING` until it resolves and only then reaches `COMPLETED`, so
* a failure surfaces as `FAILED` instead of retracting an already-emitted value. A rejection
* produces `UploadError("COMPLETE", { cause })`; `file.retry()` re-issues just this call.
*
* **Must be idempotent.** If the uploader is disposed mid-call, `activate()` re-issues it — an
* aborted request gives no evidence about whether the server processed it, and re-issuing an
* idempotent call is strictly better than silently dropping an upload.
*/
completeUpload?: (signal: AbortSignal, file: FileModel) => Promise;
/**
* Best-effort server-side cleanup when an upload that never completed is removed (e.g.
* `DELETE /uploads/:id`). Only called for files that obtained an `uploadId` and have not reached
* `COMPLETED` — there is nothing to abort otherwise.
*
* Deliberately gets no `AbortSignal`: it is issued during teardown and must outlive the model.
* Rejections are reported through `onError` and never block removal.
*/
cancelUpload?: (file: FileModel) => void | Promise;
/**
* Fires when a `COMPLETED` upload is removed, for consumers that want to delete the durable
* object. `cancelUpload` covers only abandonment of an upload still in flight.
*/
onRemove?: (value: UploadValue, uploader: UploaderModel) => void;
/**
* Override the part transport. Defaults to `xhrPutUpload` (XHR, because `fetch` still cannot
* report upload progress). Also the seam that makes the uploader testable without a DOM.
*/
uploadPart?: UploadPartFn;
/**
* Reject a file before it is added. Return a message to reject it, or nothing to accept it.
* Called once per file in order, *after* earlier files in the same batch have been added — so a
* count rule can read `uploader.uploads.length` and see them.
*
* Rejections surface as `UploadError("REJECTED")` through `onError` and never create an upload,
* so a refused file cannot appear as a failed row.
*/
validate?: (file: File, uploader: UploaderModel) => string | undefined | void;
/**
* Passed straight through to the hidden `` rendered by ``.
* Not re-checked by the model: the file dialog is the filter. If you need a hard guarantee (or
* you call `addFiles` yourself), enforce it in `validate`.
*/
accept?: string;
/**
* Whether the uploader holds more than one upload. Default `false`.
*
* Sets the hidden input's `multiple` attribute, and — unlike `accept` — also governs the
* collection: in single-file mode `addFiles` *replaces* what is there rather than accumulating, so
* the previous upload is removed (and cancelled or reported through `onRemove`) first.
*/
multiple?: boolean;
/** Passed straight through to the hidden input's `capture` attribute. */
capture?: boolean | "user" | "environment";
/**
* How many uploads the field holds at once. Default: unlimited when `multiple` is set, `1` when it
* isn't. Picks past the cap are refused with `UploadError("REJECTED")` through `onError`, and
* `uploader.full` / `uploader.remainingSlots` let your UI gate on the same number.
*
* This lives here rather than in `validate` because it is the one limit that depends on the
* *collection* rather than on the file: only the uploader can see both the files it is uploading and
* the already-uploaded ones rehydrated from `value`. A design system's own file cap counts only local
* `File`s, so it will happily accept a second file when one already exists server-side — leave that
* setting open and let this one do the work.
*
* Limits that depend only on the file — type, size — belong to whatever does the selecting, since it
* can reject before the uploader ever sees them. Use `accept` / `validate`, or your design system's
* equivalents.
*
* The cap governs *picking*, not rehydration: `value` is authoritative, so a controlled value longer
* than `maxFiles` is applied in full rather than silently truncated.
*/
maxFiles?: number;
/**
* How many part uploads may be in flight at once, across all files. Default `4`.
*
* This also throttles signing: a file is only requested when the already-signed work can't keep
* the pipeline busy without it, which keeps the number of signed-but-uncompleted uploads at
* roughly this number and stops presigned URLs expiring while queued behind large files. Parts in
* retry backoff hold no slot.
*/
concurrency?: number;
/**
* Hard cap on signed-but-uncompleted uploads, for backends that advertise one (and reject the
* next request outright). Default: unlimited, since `concurrency` already bounds this to roughly
* its own value — set it when the backend's limit must be *guaranteed* rather than approximated.
*/
maxPendingUploads?: number;
/** Total attempts per part, including the first. Default `4`. */
maxPartAttempts?: number;
/** First backoff window in ms, doubling per attempt with equal jitter. Default `500`. */
retryBaseMs?: number;
/**
* Backoff ceiling in ms. Default `8000`. With the defaults the three retry delays sum to at most
* 3.5s, and nothing is ever awaited after the decision to fail.
*/
retryCapMs?: number;
/**
* Abort and retry a part attempt after this long with no upload-progress event. Default `60000`;
* `0` disables. A *stall* budget rather than `xhr.timeout`'s total-request budget, which no
* single value can set correctly for both a 1 MB and a 500 MB part.
*/
stallTimeoutMs?: number;
/**
* Override which part failures are retried. Default: no response (`status === 0`), 408, 429, and
* 5xx other than 501. Everything else is fatal — notably 403, an expired or mismatched presign,
* which fails identically no matter how many times it is retried.
*/
isRetryable?: (error: UploadError) => boolean;
/**
* The controlled value: uploads that already exist server-side. Reconciled by `id`; uploads still
* in flight are never touched. Passing the key at all (even as `undefined`) makes the uploader
* controlled, so `undefined` and `[]` both mean "no uploads".
*/
value?: UploadValue[];
/**
* Fires when the set of completed uploads changes. Compared structurally, so a fresh array of
* fresh objects with the same contents is not a change. Requires `activate()`.
*/
onChange?: (values: UploadValue[], uploader: UploaderModel) => void;
/**
* Every failure, including files refused by `validate` — which have no model to carry an `error`,
* so this is the only way they become visible. Also receives failures already reflected on
* `file.error`, for toasts and telemetry.
*/
onError?: (error: UploadError, file: FileModel | undefined, uploader: UploaderModel) => void;
}
/** Constructor data for a `FileModel`. */
interface FileConfig {
file: File;
}
/** Constructor data for a `PartModel`. */
interface PartConfig {
index: number;
url: string;
blob: Blob;
}
/** Constructor data for a `CompletedUploadModel`. */
interface CompletedUploadConfig {
id: string;
name: string;
}
//#endregion
//#region src/uploader/part.model.d.ts
/**
* One part of a multipart upload: a slice of the file plus the URL it was signed for.
*
* A part is a **single-attempt unit**. It does not loop over retries itself — a retryable failure
* parks it in `WAITING`, releasing its concurrency slot for the duration of the backoff, and a timer
* flips it back to `QUEUED` for the uploader's scheduler to pick up. The retry "loop" is therefore
* the scheduler, which is what makes backoff free of concurrency cost and every intermediate state
* inspectable. (The reference implementation slept inside `upload()` with the status still
* `UPLOADING`, so a part waiting 3.3s held one of only four slots.)
*/
declare class PartModel {
readonly file: FileModel;
readonly config: PartConfig;
status: PartStatus;
/**
* Bytes confirmed on the wire. Bytes rather than a percentage: with exact server-supplied part
* sizes the parts are not equal-sized, so only a byte-weighted roll-up reports the file's real
* progress.
*/
loaded: number;
/** 1-based count of attempts started. */
attempt: number;
error: UploadError | undefined;
private controller;
private retryTimer;
get index(): number;
get url(): string;
get blob(): Blob;
get size(): number;
/** 0–100 for this part alone. Zero-guarded: a zero-byte part is 0% until it completes. */
get progress(): number;
constructor(file: FileModel, config: PartConfig);
/**
* @internal Scheduler entry point: `QUEUED` -> `UPLOADING`. Sets the status synchronously, before
* the first await, so the scheduler's slot accounting is correct the moment this returns.
*/
start(): void;
/** @internal `FAILED` -> `QUEUED` with the attempt counter reset, for `file.retry()`. */
requeue(): void;
/**
* @internal Abandon the current attempt and become eligible again. Used when the file fails or the
* uploader is parked. Object-store part PUTs are not range-resumable, so the part restarts from 0.
*/
abort(): void;
/** Release every resource. Leaves the part in a resumable status; pairs with the uploader's `activate`. */
dispose(): void;
private attemptUpload;
/** `UPLOADING` -> `WAITING`. Releases the part slot; the timer re-queues. */
private wait;
/** `WAITING` -> `QUEUED`, from the backoff timer. */
private queue;
private settle;
private setLoaded;
}
//#endregion
//#region src/uploader/file.model.d.ts
/**
* One local `File` being uploaded: its server identity, its parts, and its phase.
*
* `status` is **explicit observable state**, not a derivation from the parts. `PENDING` and
* `REQUESTING` both have zero parts, `COMPLETING` and `COMPLETED` both have all parts complete, and
* `COMPLETED` has to be sticky because it is what gets emitted into the consumer's form value — a
* derivation would un-complete a file the moment any part model was touched. Parts still *drive* the
* status, but through the uploader's scheduler rather than through a getter.
*/
declare class FileModel implements UploadLike {
readonly uploader: UploaderModel;
readonly config: FileConfig;
/** Stable client identity for React keys; distinct from the server's `uploadId`. */
readonly key: string;
status: FileStatus;
parts: PartModel[];
uploadId: string | undefined;
error: UploadError | undefined;
/** The server's canonical name, once known. Falls back to the local `File.name`. */
private serverName;
private controller;
private completeRequested;
private objectUrlValue;
get file(): File;
get size(): number;
get type(): string;
get isImage(): boolean;
get isVideo(): boolean;
get previewable(): boolean;
/**
* A blob URL for previewing the file, minted on first read and revoked by `dispose`.
*
* Deliberately **not** a `computed`. A computed's body must be pure, and `URL.createObjectURL`
* allocates a document-scoped handle; worse, computeds suspend when unobserved and recompute on
* the next read, so a computed here mints a fresh blob URL every time a preview unmounts and
* remounts and leaks the previous one for the page's lifetime. This is a plain getter over
* readonly, non-observable state memoized into a plain field, so it can never invalidate: it mints
* at most once per activate/dispose cycle and `dispose` revokes exactly what was minted.
*
* `undefined` rather than `""` for non-previewable files, so consumers don't render ``
* (which requests the current page).
*/
get objectUrl(): string | undefined;
get name(): string;
get extension(): string;
get value(): UploadValue | undefined;
get queuedParts(): PartModel[];
get activeParts(): PartModel[];
get waitingParts(): PartModel[];
get completedParts(): PartModel[];
get failedParts(): PartModel[];
/** Vacuously true for a zero-part file, which is how a zero-byte upload completes. */
get partsComplete(): boolean;
/** Bytes confirmed on the wire across every part. */
get loaded(): number;
/**
* 0–100, weighted by **bytes** rather than by part count — with exact server-supplied sizes the
* parts are unequal, so averaging their percentages misreports. Zero-guarded: the reference
* divided by `parts.length` and rendered `NaN` for the entire pre-signing phase.
*/
get progress(): number;
/** Whether this file has reached a terminal status. */
get settled(): boolean;
constructor(uploader: UploaderModel, config: FileConfig);
/**
* @internal Scheduler entry point: `PENDING` -> `REQUESTING`. Sets the status synchronously,
* before the first await, so the scheduler's slot accounting is correct the moment this returns.
*/
startRequest(): void;
/** @internal Scheduler entry point: `UPLOADING` -> `COMPLETING`. Fires `completeUpload` once. */
startComplete(): void;
/** @internal Terminal success. */
complete(): void;
/** @internal Terminal failure. */
fail(error: UploadError): void;
/**
* Try a failed upload again. Re-signs when no id was ever obtained, re-issues just the completion
* call when that is what failed, and otherwise re-queues the failed parts.
*/
retry(): void;
/** Remove this upload from the uploader, cancelling it server-side if it started. */
remove(): void;
/** Re-arm anything `dispose` released. Transient phases are re-issued from the top. */
activate(): void;
/**
* Release every resource and park the work: the in-flight request is aborted, parts are aborted,
* and the preview URL is revoked. Transient phases are rolled back to resumable ones rather than
* failed, so `activate` can pick the upload back up.
*/
dispose(): void;
private runRequest;
private runComplete;
/** `REQUESTING` -> `UPLOADING`, slicing the file against the server's exact part sizes. */
private applyRequestResult;
}
//#endregion
//#region src/uploader/uploader.model.d.ts
/**
* Owns the list of uploads and the scheduling of all network work.
*
* Scheduling is an explicit, synchronous, idempotent `pump()` action rather than a reaction. The
* reference implementation used a self-triggering `autorun` that read the part queues and mutated
* part status, which is a category error — a reaction's contract is "state to outside world", not
* "state to state" — and it made the number of reaction passes an emergent property of the code
* (O(N) passes to dispatch N parts, each recomputing every queue), untestable without leaning on
* MobX's scheduler, and unable to express two concurrency budgets at once. `pump()` is called from
* every transition point, so no free slot ever goes unfilled.
*/
declare class UploaderModel {
readonly config: UploaderConfig;
/** Every upload, in display order: files being uploaded plus rehydrated completed uploads. */
uploads: Upload[];
private active;
private pumping;
private pumpQueued;
private changeReactionDisposer;
get concurrency(): number;
get maxPendingUploads(): number;
/**
* How many uploads the field holds at once, counting rehydrated ones. Defaults from `multiple`:
* unlimited when it is set, `1` when it isn't.
*/
get maxFiles(): number;
get maxPartAttempts(): number;
get stallTimeoutMs(): number;
/** Only the uploads that have a local `File` behind them. */
get files(): FileModel[];
/** Every upload that has reached `COMPLETED`, in display order. */
get completedUploads(): Upload[];
/** The form value: one `{ id, name }` per completed upload. */
get values(): UploadValue[];
/** Just the ids, for consumers whose field stores bare identifiers. */
get ids(): string[];
get activeParts(): PartModel[];
get queuedParts(): PartModel[];
/** Files whose signing request is in flight; each is a prospective part the pipeline can't see yet. */
get requestingFiles(): FileModel[];
/**
* Uploads that exist server-side but are not finished — the count a backend's pending-upload limit
* applies to.
*
* `REQUESTING` files count even though they have no id yet: the request that is in flight is what
* *creates* the server-side pending upload, so excluding them would let successive pumps sign past
* `maxPendingUploads` while the first requests were still resolving.
*/
get pendingUploads(): FileModel[];
/**
* Whether the field is at `maxFiles`. Gate your browse control on this rather than keeping your own
* count — it is the same number the model refuses additions with, and it counts both kinds of upload.
*/
get full(): boolean;
/** How many more uploads will be accepted. `Infinity` when unlimited. */
get remainingSlots(): number;
/** Whether any upload is still working. Use this instead of peeking at part counts. */
get uploading(): boolean;
/** Whether any upload has failed. */
get failed(): boolean;
/** Whether anything is not yet completed — the "block submit" flag. */
get invalid(): boolean;
/** Every error currently attached to an upload. */
get errors(): UploadError[];
/** Total bytes across every file being uploaded. */
get size(): number;
/** Bytes confirmed on the wire across every file. */
get loaded(): number;
/**
* 0–100 across every non-failed file, weighted by bytes so a 1 GB file doesn't count the same as a
* 1 KB one. Zero-guarded — the reference computed `Math.floor(0 / 0)` and returned `NaN` whenever
* there was nothing to upload.
*/
get progress(): number;
constructor(config: UploaderConfig);
/**
* (Re)arm the `onChange` reaction and resume scheduling. Idempotent. Pairs with `dispose` —
* `useUploader` calls both across effect cycles, so a StrictMode dev remount (mount, cleanup,
* mount against the same model) resumes rather than leaving the uploader parked.
*/
activate(): void;
/**
* Release every resource and park the work: in-flight requests and part uploads are aborted, retry
* and stall timers cleared, preview URLs revoked, the `onChange` reaction dropped, and nothing new
* is scheduled.
*
* Uploads are left in the collection with resumable statuses — call `activate` to pick them back
* up, or `clear` to abandon them. A destructive dispose would abort every in-flight upload on a
* StrictMode dev remount.
*/
dispose(): void;
/**
* **Add** files to whatever is already here. Accepts a `FileList` (from an ``'s change event),
* an array, or any iterable of `File`.
*
* Each file goes through `config.validate` in order, after earlier files in the batch have been
* added — so a count rule sees them.
*
* A file already in the list is skipped rather than uploaded twice, matched by {@link isSameFile}
* — so `setFiles` and `addFiles` agree about identity, and re-picking the same file can't produce
* two uploads of the same bytes, two server-side pending uploads and two entries in the form value.
* Skips are reported through `onError` as `UploadError("REJECTED")` rather than dropped silently.
*
* This is the *delta* API, for a selection layer that reports only what was newly picked — which is
* what an `` change event gives you (and what `` uses). If your
* selection layer owns the list and hands back the whole thing on every change, use
* {@link UploaderModel.setFiles} instead: that also removes files the layer dropped, which this
* cannot see.
*/
addFiles(files: FileList | Iterable): void;
/**
* **Reconcile** the picked files to exactly this set — additions and removals both.
*
* This is the API for a selection layer that owns the file list and reports the whole thing on every
* change rather than a delta. Chakra UI's and Ark UI's `FileUpload` work this way: `onFileAccept`
* fires from the machine's `acceptedFiles` binding, so it receives the complete accepted list — and
* it fires on deletions too, not just additions. Passing that to `addFiles` would duplicate every
* file already present.
*
* Files are matched to existing uploads by reference, then by name + size + type (see
* {@link isSameFile}) — the same identity `@zag-js/file-utils` uses, so both layers agree about what
* "the same file" is. Matched uploads keep their position and their in-flight state, so a
* reconciliation never restarts an upload that is already running. Uploads whose file is absent are
* removed, cancelling them server-side if they had started. New files are appended through
* `config.validate`.
*
* Rehydrated completed uploads are left alone: they have no `File`, they aren't part of the selection
* layer's list, and they are owned by the controlled `value`. So `setFiles([])` clears the picked
* files without discarding uploads that already exist server-side.
*/
setFiles(files: FileList | Iterable): void;
/** Add a rehydrated upload that already exists server-side. */
addCompletedUpload(value: UploadValue): CompletedUploadModel;
/**
* Reconcile the controlled value, matching **by `id` only**.
*
* Only `COMPLETED` uploads take part in the removal diff. `values` contains nothing else, so an
* upload still in flight was never in the parent's value and cannot be something the parent
* "dropped" — which is what keeps a `value` echo from cancelling work in progress, even after the
* upload has been signed and therefore has an `uploadId`.
*
* Being a single action means the diff cannot emit a half-applied list — the reference's bare
* `addCompletedUpload` loop ended a MobX batch per push, so `onChange` could fire mid-reconciliation.
*/
applyValue(value: UploadValue[]): void;
/**
* Remove an upload. A file that started server-side but never completed is cancelled through
* `config.cancelUpload`; a completed one is reported to `config.onRemove`.
*/
removeUpload(upload: Upload): void;
/** Remove every upload, of either kind, through the one `removeUpload` path. */
clear(): void;
/** Retry every failed upload. */
retryAll(): void;
/** @internal Surface an error to `config.onError`. */
reportError(error: UploadError, file?: FileModel): void;
/** @internal The default retryable-status policy, overridable via `config.isRetryable`. */
isRetryableError(error: UploadError): boolean;
/**
* The module's only scheduling primitive: inspect the current state, fill whatever concurrency
* slots are free, return. Synchronous, idempotent, and safe to call at any time — nothing else in
* the module starts network work.
*/
pump(): void;
private addFile;
/**
* One scheduling pass. The order is load-bearing:
*
* 1. `settleFiles` advances files whose parts are done (or one of which failed), releasing slots —
* so a freed slot is reused in this same pass rather than the next one.
* 2. `startPendingFiles` signs files, creating the parts pass 3 needs.
* 3. `startQueuedParts` fills the part slots.
*/
private pumpOnce;
/** `UPLOADING` -> `FAILED` / `COMPLETED` / `COMPLETING`, driven by part aggregates. */
private settleFiles;
/**
* `PENDING` -> `REQUESTING`.
*
* A file is signed only when the already-signed work can't keep the part pipeline busy without it.
* Counting `requestingFiles` is what makes this self-limiting: without that term, a hundred picked
* files would all sign on the first tick (none has produced parts yet), which is exactly what a
* backend's pending-upload limit rejects. With it, signing stays one step ahead of the pipeline —
* so the signing round trip overlaps the tail of the previous file and there is no bubble — while
* the number of signed-but-unfinished uploads stays near `concurrency`.
*/
private startPendingFiles;
/**
* `QUEUED` -> `UPLOADING`, in `(file order, part index)` order.
*
* Files drain one at a time rather than interleaving: an upload is worthless to the consumer until
* every part lands, so FIFO over indivisible jobs minimizes mean completion time — ten interleaved
* files give you nothing usable until the end, ten drained gives you the first at a tenth of the
* time. It also finalizes uploads earlier and wastes no bytes when a queued file is cancelled.
*/
private startQueuedParts;
}
//#endregion
//#region src/uploader/completed-upload.model.d.ts
/**
* An upload that already exists server-side, rehydrated from the controlled `value` — no blob, no
* parts, nothing in flight.
*
* Implements `UploadLike` with literal answers (`status` is always `"COMPLETED"`, `progress` always
* 100) so list UIs, `values`, `invalid` and `clear` treat it exactly like a `FileModel` and no
* `instanceof` branch is needed anywhere.
*
* `name` is carried explicitly. The reference implementation derived it from the identifier with
* `uploadKey.replace(/^.*[\\/]/, "")`, which only worked because that backend's client-facing id was
* the bucket key; against an opaque id it renders the id itself and yields no extension.
*/
declare class CompletedUploadModel implements UploadLike {
readonly uploader: UploaderModel;
readonly config: CompletedUploadConfig;
/** Stable client identity for React keys; distinct from the server's `uploadId`. */
readonly key: string;
/** The parent owns this: a `value` update may rename an upload the uploader never uploaded. */
private currentName;
get uploadId(): string;
get name(): string;
get extension(): string;
get status(): FileStatus;
get progress(): number;
get error(): UploadError | undefined;
get value(): UploadValue;
constructor(uploader: UploaderModel, config: CompletedUploadConfig);
setName(name: string): void;
remove(): void;
/** No-op: there is nothing to arm. Present so the `Upload` union is uniform. */
activate(): void;
/** No-op: no blob, no request, no timers. */
dispose(): void;
}
//#endregion
//#region src/uploader/components/uploader-root.d.ts
interface UploaderRootProps {
uploader: UploaderModel;
/**
* Escape hatch for the hidden file input (`id`, `name`, `form`, …). Spread *before* the library's
* own attributes. `accept`, `multiple` and `capture` come from `uploader.config` so there is one
* place to set them, and `type`/`onChange` are the component's own.
*/
inputProps?: Omit, "type" | "value" | "onChange" | "accept" | "multiple" | "capture">;
children?: ReactNode;
}
/**
* Owns the hidden `` — and therefore the only way to open the file dialog, which
* it publishes on the context as `openFileDialog` — and provides the model to everything below.
*
* Renders **no wrapper element**: an uploader has no structural DOM requirement the way a virtualized
* table's scroll viewport does, so a div here would only be a box you have to style around. Wrap the
* children in your own container.
*/
declare const UploaderRoot: FC;
//#endregion
//#region src/uploader/components/uploader-uploads.d.ts
interface UploaderUploadsProps {
/** Renders one upload — in-flight and already-completed alike. */
children: (upload: Upload) => ReactNode;
}
/**
* Renders every upload through your render prop, in display order, keyed on `upload.key`.
*
* Emits no DOM element of its own, so it drops straight into whatever list markup you already have.
* Reading `uploader.uploads` yourself inside an `observer` is equivalent — this only saves reaching
* for the context and remembering which identity is the stable one.
*/
declare const UploaderUploads: FC;
//#endregion
//#region src/uploader/components/namespace.d.ts
/**
* Compound namespace for the uploader skeleton. Consumers compose these into their own closed
* component (styles + defaults captured once), e.g. `…`.
*/
declare const Uploader: {
Root: import("react").FC;
Uploads: import("react").FC;
};
//#endregion
//#region src/uploader/uploader.context.d.ts
/**
* What the parts below `` need: the model, plus the one thing only the root can
* provide — a handle on its hidden ``. Opening the file dialog is a DOM
* capability, not model state, so it rides the context rather than the model.
*/
interface UploaderContextValue {
uploader: UploaderModel;
/** Opens the root's hidden file input. Must be called from within a user gesture. */
openFileDialog: () => void;
}
declare const uploaderContext: import("react").Context;
declare const useUploaderContext: () => UploaderContextValue;
declare const UploaderProvider: import("react").Provider;
//#endregion
//#region src/uploader/uploader.util.d.ts
/** Default first-retry window; see `UploaderConfig.retryBaseMs`. */
declare const RETRY_BASE_MS = 500;
/** Default backoff ceiling; see `UploaderConfig.retryCapMs`. */
declare const RETRY_CAP_MS = 8000;
/** A server's `Retry-After` is honored up to this; beyond it we would stall the whole queue. */
declare const MAX_RETRY_AFTER_MS = 30000;
/** Default stall budget; see `UploaderConfig.stallTimeoutMs`. */
declare const STALL_TIMEOUT_MS = 60000;
/**
* A stable client-side identity for an upload. Used only for React keys — it is never transmitted,
* persisted, or compared against anything server-side, so it needs uniqueness within the page and
* nothing more.
*
* A plain counter rather than `crypto.randomUUID()`: that is unavailable outside a secure context
* (plain HTTP on a LAN address is enough to make it `undefined`), and a random id would differ
* between a server render and hydration, remounting every rehydrated row. A counter is deterministic,
* so the same sequence of constructions yields the same keys on both sides.
*
* `useId` can't serve here — it is a hook returning one id per component, while uploads are
* constructed from event handlers, and the models deliberately don't depend on React at all.
*/
declare const nextUploadKey: () => string;
/**
* Whether two `File`s should be treated as the same selection: reference identity first, then
* name + size + type.
*
* The structural fallback matters for interop — it is exactly the comparison
* `@zag-js/file-utils`' `isFileEqual` uses, so a design system's file list and the uploader's agree
* about what "the same file" means. Reference equality alone would churn whenever the selection layer
* hands back re-created `File` objects (Zag's `transformFiles` does this).
*/
declare const isSameFile: (a: File, b: File) => boolean;
/**
* The filename's extension, lowercased, or `""` when there isn't one.
*
* Deliberately reports what the name says and normalizes nothing — the reference implementation
* rewrote `jpeg` to `jpg` for one backend's content-type table, which is an application concern.
*/
declare const getFileExtension: (fileName: string) => string;
/**
* Whether another attempt against the same signed URL could plausibly succeed.
*
* - `0` — no response at all: network error, or our own stall abort
* - `408` — server-side request timeout
* - `429` — rate limited (`Retry-After` honored when present)
* - `5xx` except `501` — transient server failure
*
* Everything else is fatal, most importantly `403`: an expired or mismatched presign fails
* identically forever, so retrying it only delays the error.
*/
declare const isRetryableStatus: (status: number) => boolean;
/** `Retry-After` as ms; accepts delta-seconds or an HTTP-date. */
declare const parseRetryAfter: (header: string | null) => number | undefined;
interface RetryDelayOptions {
baseMs?: number;
capMs?: number;
retryAfterMs?: number;
}
/**
* Equal-jitter exponential backoff: half the window fixed, half random. Full jitter can return ~0ms,
* which just re-hammers a server that is already struggling; a fixed delay synchronizes every part
* of every file into a thundering herd.
*
* With the defaults (base 500, factor 2, cap 8000) a part's three retry delays are 250–500ms,
* 500–1000ms and 1000–2000ms, so backoff totals at most 3.5s.
*
* @param attempt 1-based number of the attempt that just failed.
*/
declare const retryDelayMs: (attempt: number, options?: RetryDelayOptions) => number;
/**
* The default part transport: a plain `PUT` of the part's blob to its signed URL.
*
* XHR rather than `fetch` because `fetch` still cannot report upload progress in most browsers.
*
* Every terminal path settles the promise — `onload`, `onerror`, `ontimeout`, `onabort`, and the
* stall timer. The reference implementation registered only `onload` and `onerror`, so an aborted
* part's promise never settled: the part stayed `UPLOADING` forever, permanently holding a
* concurrency slot, and enough cancellations deadlocked the whole uploader.
*/
declare const xhrPutUpload: UploadPartFn;
//#endregion
//#region src/uploader/use-uploader.d.ts
declare const useUploader: (config: UploaderConfig) => UploaderModel;
//#endregion
export { CompletedUploadConfig, CompletedUploadModel, FileConfig, FileModel, FileStatus, MAX_RETRY_AFTER_MS, PartConfig, PartModel, PartStatus, RETRY_BASE_MS, RETRY_CAP_MS, RetryDelayOptions, STALL_TIMEOUT_MS, Upload, UploadError, UploadErrorOptions, UploadErrorType, UploadLike, UploadPart, UploadPartFn, UploadRequestResult, UploadValue, Uploader, UploaderConfig, UploaderContextValue, UploaderModel, UploaderProvider, UploaderRoot, UploaderRootProps, UploaderUploads, UploaderUploadsProps, getFileExtension, isRetryableStatus, isSameFile, nextUploadKey, parseRetryAfter, retryDelayMs, toUploadError, uploaderContext, useUploader, useUploaderContext, xhrPutUpload };
//# sourceMappingURL=uploader.d.mts.map