Repository navigation
feat(swift)!: read pasted and dropped files lazily, off the main thread - #138
Merged
Merged
Conversation
tmm
force-pushed
the
swift-lazy-data-transfer-files
branch
2 times, most recently
from
November 14, 2026 16:01
cde8e97 to
3594ac1
Compare
A DataTransferFile held its bytes in a public data: [UInt8], so building a paste's or a drop's DataTransfer read every file into memory on the main thread before PASTE_COMMAND or DROP_COMMAND ran, even when no handler wanted files (#112). A web File is a lazy Blob read through promises (arrayBuffer(), bytes()), and the playground's mediaFileReader awaits them. DataTransferFile now has a size and an async bytes(), and the loaders capture where each file lives instead of its contents: - macOS: a file URL is stat'ed for its size and read off the main thread when bytes() is called. An image on the pasteboard with no file behind it is read from the pasteboard on the main actor when asked, and throws once the pasteboard has changed. - iOS: UIKit lets an item provider load only during the paste or drop (performDrop is "the only opportunity"), so each file's loadFileRepresentation starts then, the file is moved into a temporary directory off the main thread, and bytes() waits for it; size is nil. The temporary file is removed once no DataTransferFile holds it. Paste reads only the text types it needs instead of UIPasteboard.items, which loaded every representation of every item on the main thread. - iOS drops and pastes now carry files of any type, not only images (a PDF from Files was lost), and the drop interaction accepts them. This is a clean break, not an API next to data: the cost was in building the DataTransfer, so only a lazy file removes it. DataTransferFile.data and its Equatable conformance are gone; DataTransferFile(name:type:data:) still makes an in-memory file, and init(name:type:size:load:) a lazy one. The playground's DragDropPaste reads with await file.bytes() and builds the data URLs off the main thread. Minor changeset, DESIGN.md, JSParity and a platform-clipboard deviation (native/pasteboard-files) updated. Amp-Thread-ID: https://ampcode.com/threads/T-01a1d884-2859-717d-8ddd-80ee5e237c13
tmm
force-pushed
the
swift-lazy-data-transfer-files
branch
from
November 14, 2026 16:31
3594ac1 to
1f3ad98
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #112. Rebased onto main after #137 (macOS refused files dropped from Finder), which ships in 0.1.10. Merge only after Swift 0.1.10 is released: this is a
minorchangeset, and merging it earlier would turn the open version PR into 0.2.0.What changes
Pasted and dropped files are no longer read into memory on the main thread. As a web
Fileis a lazyBlob, aDataTransferFilenow has asizeand reads its contents only when a handler awaitsbytes(). This is the approved proposal: a clean break, not an API next todata, since the cost was in building theDataTransferitself.@concurrentread whenbytes()is called. Folders are skipped, as before. An image on the pasteboard with no file behind it (copied from an app) is read from the pasteboard on the main actor when asked. It throws once the pasteboard has changed, as a webFilethrowsNotReadableErrorfor a changed file.UIPasteboard.items, which loads every representation of every item on the main thread. Files come fromitemProviders. iOS drops and pastes now carry files of any type, not only images (a PDF from Files was lost before), andcanHandleacceptspublic.data.DragDropPasteawaitsbytes()in order, asmediaFileReaderdoes, and builds the data URLs off the main thread.DataTransferFile.dataand itsEquatableconformance are removed. Docs:DESIGN.md,JSParity.md, theDRAG_DROP_PASTEdoc comment, and aplatform-clipboarddeviation,native/pasteboard-files.Where the proposal was wrong
performDrop] only and not at any other time" (Apple's docs), and a later read fails silently. A paste read outside the paste would also bring up the paste permission prompt. So on iOS each file'sloadFileRepresentationstarts during the paste or drop, the file is moved into a temporary directory off the main thread, andbytes()waits for it. The temporary file is removed once noDataTransferFileholds it (checked: 1 copy while held, 0 after release). Files load from disk rather than into memory, so a dropped 2 GB video won't get the app killed.sizeisnilon iOS, sosize: Int?is needed, as the proposal had it.loadFileRepresentationcosts about 2.5 ms per file on the calling thread, against 0.05 ms forloadDataRepresentation. The data version would hold every dropped file in memory, which is what this change removes. The handler latency and memory gains are below.Performance
Measured on a Mac satellite (12-core, macOS 27, Xcode 27.3) and the iOS 27.2 Simulator, against main.
LexicalBenchmarks (release, main and branch run alternately, 3 runs each): the same within noise; none of these paths changed. For example, insert a character p50 0.544–0.546 ms on main and 0.551–0.555 ms on the branch; load to rendered storage p50 103–111 ms and 110–111 ms; Enter p50 0.88–0.90 ms and 0.90–0.95 ms. Every target was met.
Paste and drop of 4 × 256 MiB files (the view's
performDragOperation/paste(_:)with a handler that takes the files; scratch harness, not committed):purge)iOS drop:
DROP_COMMANDnow runs after 12–14 ms instead of 159–204 ms, because it no longer waits for 1 GiB to load into memory, and nothing is held in memory until a handler reads.Before and after (macOS playground: 8 × 1 GiB clips and a PNG dragged from Finder, cold cache, then a click and typing)
Video. "Before" is main plus #137, because main refuses the drop altogether. In the before take, the drag cursor stays stuck over the editor for about 3.8 s, then the image and the typed text appear together. In the after take they appear within a second of the drop. iOS has no visible change in the playground: drops already loaded off the main thread, and paste time went from about 50 ms to 4 ms. The table above has the iOS numbers.
Tests
readsADroppedFileOnlyWhenItsBytesAreRead: the drop's file has its name, type and size. The file is changed after the drop andbytes()returns the new contents, then it's deleted andbytes()throws. Dragover still has no files.readsAPastedImageOnlyWhileThePasteboardHoldsIt: a pasteboard PNG reads when asked, and throws once the pasteboard has changed.pastesAnImageAsAFileanddropsAFileThatIsNotAnImage: a PDF provider becomesReport.pdf(application/pdf) with its bytes, and a text provider adds no file. Neither passes on main.UIImagestill arrives asimage.pngand decodes.swift format lint --strict,swift build --build-tests,swift teston macOS (all 28 test runs pass);LexicalUIKitTestson the iOS 27.2 Simulator;pnpm --filter lexical-conformance run testandcheck-ledger; the Linux build and the rich-text and utils file tests;scripts/api-breaks.sh, which reportsDataTransferFile.dataand!=as no longer public and allows them under the pending minor changeset.Migration (0.2.0 release notes)
Read a file's contents with
try await file.bytes()where you readfile.data, as upstream'smediaFileReaderawaits eachFile. ADRAG_DROP_PASTEhandler that needs the bytes returnstrueand reads them in aTask.DataTransferFile(name:type:data:)still makes an in-memory file, andDataTransferFile(name:type:size:load:)makes a lazy one.DataTransferFileis no longerEquatable, so compare files bynameandtype.