Repository navigation
feat(attachments): add Attachments and the file drop zone - #124
pratikbuilds wants to merge 8 commits into
Conversation
|
Triage: Fail Asking a maintainer about:
Stacked on #123: only this pull request's own changes were checked. Notes:
Changed since the last verdict (Unsure at |
c2c582c to
20deb73
Compare
3934cca to
dfa43c8
Compare
TheGreatAxios
left a comment
There was a problem hiding this comment.
Automated multi-agent review: independent review + second-agent validation; only confirmed items listed (the top finding verified by an independently re-run repro).
- minor (VERIFIED by repro)
src/hooks/use-attachments.ts:99-110(with:89-97, 112-142) — a<Activity mode="hidden">hide spanning more than one tick revokes every preview URL while the host's list still holds the items, and nothing rebuilds: the mount effect's cleanup forks a microtask that revokes allownedURLs (Activity unmounts effects but preserves state), and on re-show the per-commit effect only prunes andadd()mints URLs for new files only. Result: re-showing leaves dead blob URLs behind every held attachment, silently degraded to type tiles byAttachmentPreview'sonError. Smallest fix: recreate object URLs from the helditem.filefor held ids whose URL was revoked (or skip revocation when the hide leaves the root mounted), with a regression test: hide → a tick → show → previews still fetchable. - minor a11y
src/ui/attachment-drop-zone.tsx:62-73— drag-over state is invisible to assistive tech: the overlay (including the "Drop files to attach" label) isaria-hidden, and the zone is a baredivwith no role/name and no live-region twin ofdata-over. The pointer-only design is deliberate and documented, but an sr-onlyrole="status"twin of the label plus a way for the host to name the zone would stop the total silence. - minor (test gap)
src/hooks/use-file-drop.ts:314-321— thedisabledoption is implemented but never tested (mountTargethas nodisabledparameter; the only "disabled" test coversusePreventStrayDrops(false)). One enter/over/leave/drop test withdisabled: true, assertingover === false,onFilesuncalled, nopreventDefault. - nits confirmed —
AttachmentPreviewswallows a hostaria-label(attachments.tsx:503-514; deliberate but asymmetric withAttachmentRemove'slabelprop);focusAfterExpandassumesAttachmentMorerenders last (:686-694; document the contract); bothProvider value={{…}}objects are fresh every render (:234-243,:350— wrap inuseMemo); the uncontrolled-expanded half (AttachmentsState,defaultExpanded, children-as-function) is public surface with zero consumers at the series tip (make private or exercise it); fouruse-file-dropexports (carriesFiles,DroppedFiles,FileDropTargetProps,UseFileDropResult) are public with zero consumers (un-export or defer).
0fc6401 to
ea5bea3
Compare
dfa43c8 to
6a1cf29
Compare
|
Thanks for the review. Here's what happened to each point. Fixed1. Hiding attachments inside
2.
3. The drop zone gives screen readers nothing (minor)
4. Nits
Not changed, and why
Commit: |
6a1cf29 to
41c2f8d
Compare
ea5bea3 to
3545aaf
Compare
cf5b934 to
a3e9b62
Compare
TheGreatAxios
left a comment
There was a problem hiding this comment.
Review · Request changes
Adds attachment chips and a file drop zone, with count, size, and duplicate limits.
Findings
-
src/hooks/use-attachments.ts:138— Nameless files skip the duplicate check, then collide with a realUntitled.checkAttachmentskeys on the rawfile.name(attachment-limits.ts:28) beforeaddstores""as"Untitled". Two empty-named files of the same size are both accepted, and a later file that is actually namedUntitledwith that size is refused. Run the limit check on the name that will be stored. -
src/hooks/use-file-drop.ts:344— A drop on a nested picker is taken twice.FileInputhandlesdropand callsonFileswithout stopping the bubble (file-input.tsx:55-60). The zone then reads the same files and callsonFilesagain, so the story's picker path (attachments.stories.tsx:304-307) accepts the file and then reportsduplicate. Skip a drop whosedefaultPreventedis already set. -
src/hooks/use-file-drop.ts:281— A folder walk that started while the zone was enabled still delivers afterdisabledflips.takeschecksdisabledonly on the event (:318-319).latestdoes not keepdisabled, and thereadDroppedFilescallback (:353-358) delivers unconditionally. Re-checkdisabledbeforedeliver. -
src/hooks/use-file-drop.ts:353— A folder walk that finishes after unmount leaks the object URL. Unmount revokes URLs in a microtask (use-attachments.ts:109-112) andaddnever checksmounted. IfreadEntriesresolves after that microtask,addcallsURL.createObjectURL(use-attachments.ts:134) and nothing revokes it. Do not callonFilesonce the drop hook has unmounted, or haveaddrefuse work whenmountedis false.
a3e9b62 to
b988ab0
Compare
9b71d32 to
e85396c
Compare
b988ab0 to
4cf8269
Compare
|
Thanks for the review. All four points are fixed. 1. Nameless files skipped the duplicate check
2. A drop on a picker inside the zone was taken twice
3. A folder walk still delivered after the zone was disabled
4. A folder walk that finished after unmount leaked an object URL
Commit: |
Attachments renders files as a stack above the input, tiles in a message, or a list, with preview, info, remove and more parts. The host owns the list: useAttachments applies the count, size and duplicate limits, builds previews and revokes every object URL it made once its attachment leaves the list. useFileDrop and AttachmentDropZone accept files and whole folders, and a stray drop elsewhere never replaces the page.
A hook hidden by Activity for longer than a tick cannot tell the hide from an unmount, so useAttachments revokes its previews while the host still holds the items, and the previews came back blank. Attachment now rebuilds the picture from the item's file when its url fails, owning that url and revoking it on unmount or hide. The hook's docs say the held url itself stays dead, so code that reads it should make its own from the file or keep the hook above the Activity. No test passed disabled to useFileDrop, and the test named for it covered the stray-drop guard instead; both are now tested. AttachmentPreview takes a label for its open button, as AttachmentRemove already does. The docs say AttachmentMore renders last and how to name the drop zone as a region, carriesFiles is no longer public, and tests exercise the uncontrolled fold.
A drop on a FileInput inside the drop zone reached both the picker and the zone, so the file was attached and then refused as already there, or attached twice by a host that does not de-duplicate. The zone now leaves a drop a child already handled to that child. A folder walk could also outlive the zone: it still delivered after the zone was disabled, and after unmount it added files whose preview URLs nothing would revoke. A read that finishes once the zone is disabled or unmounted is now dropped, and useAttachments makes no preview URL once it has unmounted. A nameless file was checked for duplicates under its empty name but held as "Untitled", so two of them could slip in and a real Untitled was refused. The check now uses the name the file is held under.
The drop zone left any drop a child had already handled to that child, and FileInput handles every drop. A folder dropped on the picker inside the zone therefore reached only the picker, which passed on the single unreadable entry the browser lists for a folder, instead of the files inside it. useFileDrop now reads drops in the capture phase, before any child sees them, and marks them taken. FileInput leaves a drop that a target around it already took, so every drop inside the zone, the picker included, is walked and reaches the zone's onFiles once.
Attachment rebuilt a picture from its file only after the url failed to load, and a missing url never fails. useAttachments makes no preview url once it has unmounted or been hidden for longer than a tick, so an image added in that window showed as its type for as long as it was held. An image or video with no url is now shown from a url made from its file, revoked on unmount or hide like a rebuilt one, and falls back to its type if that fails. Other kinds get no url made.
useAttachments stored its list and limits for add during render, so a render that never committed, such as a transition that suspends, left its list behind. An add from an event in that window checked limits and duplicates against a list that was not on screen. The hook now stores them in a layout effect, so add always sees the committed list plus whatever it accepted since.
The comment on why the StrictMode replay comes after the previews are made sat above the add-after-unmount test instead of the replay test it explains.
Attachments, Attachment and AttachmentPreview read ref from their props, which only React 19 passes to a function component. On React 18, which the peer range still covers, a host's ref never reached the list, the item or the preview. The three are now wrapped in forwardRef, so the ref reaches the element on both versions. Their props types are unchanged.
4cf8269 to
35e8e05
Compare
e85396c to
246f9ce
Compare
Round 3 fixesA fresh review pass over the stack turned up the points below; each is fixed in a new commit at the end of this branch, and the stack is rebased onto it.
|
Attachments renders files as a stack above the input, tiles in a message, or a list, with preview, info, remove and more parts. The host owns the list: useAttachments applies the count, size and duplicate limits, builds previews and revokes every object URL it made once its attachment leaves the list. useFileDrop and AttachmentDropZone accept files and whole folders, and a stray drop elsewhere never replaces the page.
Stacked on #123: review only this commit. Part of #94.
Notes for review
release(ids)before moving attachments into a sent message, so the message keeps its previews (otherwise their object URLs are revoked).returnFocusTo.Verification
bun run typecheckandbun run linton its own (the stack is checked commit by commit).bun run check,bun run build,bun run dep-guard,bun run contrast-testandbun run test:e2e(Playwright specs and the Storybook axe sweep in light and dark) pass.Closes #104