Skip to content

feat(bindx-uploader): let consumers retarget an upload before it writes - #106

Closed
matej21 wants to merge 2 commits into
mainfrom
feat/uploader-prepare-target
Closed

feat(bindx-uploader): let consumers retarget an upload before it writes#106
matej21 wants to merge 2 commits into
mainfrom
feat/uploader-prepare-target

Conversation

@matej21

@matej21 matej21 commented Sep 9, 2026

Copy link
Copy Markdown
Member

The missing seam

Uploader declared a full UploaderEvents contract but exposed none of it: the component hard-coded every handler to a noop, so UploaderProps was only { entity, fileType, children }. There was no supported way to intervene between "user dropped files" and "the uploader starts writing into the target entity", and no way to change which entity the upload lands on. MultiUploaderProps had the same gap.

Consumers were left overriding UploaderUploadFilesContext between Uploader and the dropzone, queueing the dropped files, forking the relation, remounting the uploader with a new key and replaying the queue — which leans on remount timing and on a private context staying stable.

The API

Two parts.

1. The event props are forwarded. Uploader and MultiUploader accept the UploaderEvents callbacks as optional props and pass them to the internal hooks. A user handler composes with the built-in behaviour rather than replacing it: accept-type resolution, the has-one disconnect and the extractor fill all still run. Only onError has a default (uploaderErrorHandler), which a supplied handler replaces.

2. Uploader gains prepareTarget.

prepareTarget?: (files: File[]) =>
  UploaderFillTarget<TEntity> | undefined | Promise<UploaderFillTarget<TEntity> | undefined>

type UploaderFillTarget<TEntity> = EntityRef<TEntity> | HasOneRef<TEntity>

It runs once per batch, after the files pass validation (the accept check and any user onBeforeUpload) and before the target is touched — in particular before $disconnect(). It receives the accepted files, and is skipped entirely when none passed, so a batch that is rejected outright never forks anything. The target it returns is the one that gets disconnected and filled; returning nothing keeps the entity prop's target, so existing usage is unchanged. A throw reports the batch through onError and abandons it.

What it unblocks

Copy-on-write. In a CMS media library a block references a shared, already-persisted asset; uploading a replacement must not mutate the shared row. The block can now fork to a fresh asset inside prepareTarget and return the fresh relation, and the upload lands there — no remount, no private context:

<Uploader
  entity={block.asset.image}
  fileType={imageFileType}
  prepareTarget={() => {
    block.asset.$disconnect()
    block.asset.$create()
    return block.asset.image
  }}
>

Re-reading the relation inside prepareTarget is what makes this work. HasOneHandle.entity does follow its own target when the related id changes, but the captured block.asset.image hangs off the old asset's EntityHandle, so forking block.asset leaves that chain pointing at the shared row.

Implementation

  • useUploaderDoUpload gained one batch-level step, onPrepareUpload(files), run between the prepare/filter phase and the upload loop. The prepare phase never writes to the entity — the first write is the $disconnect() in onStartUpload — so this is still ahead of everything that touches the target, while an unacceptable file can no longer trigger a fork.
  • useFillEntity owns the resolved target: it returns prepareUpload alongside the events and keeps the prepared target in a ref that the disconnect and the fill both read. The ref is reset on every batch.
  • Internal hook argument types (useFillEntity, useUploadState, useUploaderDoUpload) now take Partial<UploaderEvents>, matching the optional-chained calls those hooks already made. The public UploaderEvents type is unchanged.

MultiUploader does not take prepareTarget — every file already gets its own new item in the has-many, so there is nothing to fork.

Tests

packages/bindx-uploader/tests/uploader.test.tsx — the first end-to-end tests of these components (existing uploader tests only exercised the contexts). Eight tests over a small media-library schema on MockAdapter:

  • a user onBeforeUpload runs and can reject a file, with onError receiving the rejection;
  • user handlers compose with the internal fill instead of replacing it;
  • unchanged behaviour when neither prop is given;
  • prepareTarget retargets the upload to the forked asset, while a second subscription proves the shared asset keeps its image and its URL;
  • ordering: the accept check runs first, then prepareTarget, then the write;
  • a batch rejected by the accept check never calls prepareTarget and leaves the target connected and unmodified;
  • a mixed batch forks once and lands the accepted file on the prepared target;
  • MultiUploader forwards user handlers while still creating and filling its item.

All of them fail on main, and the two ordering-sensitive ones also fail against the first version of this branch.

bun run typecheck clean; bun run test 2034 pass / 0 fail; eslint clean.

Fixes #63

🤖 Generated with Claude Code

https://claude.ai/code/session_01Euwkf2wtutqvtE4YRutU5R

matej21 and others added 2 commits September 9, 2026 16:07
Uploader and MultiUploader hard-coded every UploaderEvents handler to a
noop, so there was no supported way to intervene between "files dropped"
and "the uploader writes into the target entity". Both now accept the
event callbacks as optional props and compose them with the internal
accept-type resolution, disconnect and extractor fill.

Uploader also gains prepareTarget: it runs once per batch, before the
target is disconnected or filled, and the target it returns is what the
upload lands on. This unblocks copy-on-write flows where a shared,
already-persisted row must be forked first instead of mutated in place.

Fixes #63

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Euwkf2wtutqvtE4YRutU5R
…idation

prepareTarget ran before the accept check, so dropping an unacceptable
file into an uploader forked the target and then rejected every file:
the relation ended up pointing at a fresh empty entity instead of the
shared one it had. Losing a reference by dropping the wrong file is a
worse outcome than the redundant work it was meant to avoid.

It now runs after the prepare/filter phase, receives the accepted files,
and is skipped when none passed. A throw still reports the batch through
onError and abandons it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Euwkf2wtutqvtE4YRutU5R
@matej21

matej21 commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

Consolidated into #109, merged there.

@matej21 matej21 closed this Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Uploader: no public pre-upload hook to retarget the target entity (copy-on-write upload flows)

1 participant