From 535f67dafaac854dce0a7e7281554f13f488ede2 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:12:14 +0200 Subject: [PATCH 1/3] Stream web files in evenly sized chunks and deprecate eager reading options readAsByteStream() read blob: URLs through the fetch body stream, whose chunk sizes are irregular, while withReadStream sliced the file into fixed 1,000,000 byte chunks with FileReader. Consumers such as RamFileData.fromStream from the archive package need evenly sized buffers, so they had to rely on withReadStream. Both paths now slice the Blob with slice() and arrayBuffer() into WebPlatformFile.streamChunkSize (1 MiB) pieces, so every chunk but the last has the same size. This also replaces the FileReader based reader, which looped forever when a read returned no result. withData, withReadStream and readSequential are deprecated, on both the fields and the constructor parameters, ahead of their removal in 14.0.0. Bumps file_picker_web to 4.1.0. Refs #2223 --- packages/file_picker_web/CHANGELOG.md | 5 ++ .../lib/src/file_picker_web.dart | 34 +--------- .../lib/src/file_picker_web_options.dart | 24 +++++++ .../lib/src/platform_file_web_fetch.dart | 66 +++++-------------- .../lib/src/web_platform_file.dart | 6 ++ packages/file_picker_web/pubspec.yaml | 2 +- .../test/file_picker_web_pick_test.dart | 28 +++++++- .../test/web_platform_file_read_test.dart | 27 ++++++++ 8 files changed, 107 insertions(+), 85 deletions(-) diff --git a/packages/file_picker_web/CHANGELOG.md b/packages/file_picker_web/CHANGELOG.md index b79de54f0..239c82887 100644 --- a/packages/file_picker_web/CHANGELOG.md +++ b/packages/file_picker_web/CHANGELOG.md @@ -1,3 +1,8 @@ +## 4.1.0 + +- `readAsByteStream()` now always emits evenly sized chunks of `WebPlatformFile.streamChunkSize` (1 MiB), except the last one, with or without `withReadStream`. With `withReadStream` the chunks used to be 1,000,000 bytes. [#2223](https://github.com/vicajilau/flutter_file_picker/issues/2223) +- Deprecated `withData`, `withReadStream` and `readSequential` in `FilePickerWebOptions`. Picked files are read on demand by `readAsBytes()` and `readAsByteStream()`. They will be removed in 14.0.0. + ## 4.0.1 - Fixed picking files on Safari (macOS and iOS) never returning the selection. [#2222](https://github.com/vicajilau/flutter_file_picker/issues/2222) diff --git a/packages/file_picker_web/lib/src/file_picker_web.dart b/packages/file_picker_web/lib/src/file_picker_web.dart index 8dce7f501..d994cde7b 100644 --- a/packages/file_picker_web/lib/src/file_picker_web.dart +++ b/packages/file_picker_web/lib/src/file_picker_web.dart @@ -9,6 +9,7 @@ import 'package:web/web.dart'; import 'file_picker_web_options.dart'; import 'indexed_task_runner.dart'; +import 'platform_file_web_fetch.dart'; import 'preload_policy.dart'; import 'web_file_input_session.dart'; import 'web_platform_file.dart'; @@ -20,7 +21,6 @@ import 'web_platform_file.dart'; /// in browser environments. class FilePickerWeb extends FilePickerPlatform { static const String _kFilePickerInputsDomId = '__file_picker_web-file-input'; - static const int _readStreamChunkSize = 1000 * 1000; // 1 MB late Element _target; @@ -158,7 +158,7 @@ class FilePickerWeb extends FilePickerPlatform { if (webOptions.withReadStream) { return _createWebPlatformFile( file: file, - readStream: _openFileReadStream(file), + readStream: streamBlobInChunks(file), ); } @@ -285,34 +285,4 @@ class FilePickerWeb extends FilePickerPlatform { ), }; } - - /// Opens a chunked byte stream reader for a web [File]. - Stream _openFileReadStream(File file) async* { - final reader = FileReader(); - - int start = 0; - while (start < file.size) { - final end = start + _readStreamChunkSize > file.size - ? file.size - : start + _readStreamChunkSize; - final blob = file.slice(start, end); - reader.readAsArrayBuffer(blob); - await EventStreamProviders.loadEvent.forTarget(reader).first; - final JSAny? readerResult = reader.result; - if (readerResult == null) { - continue; - } - - if (readerResult.isA()) { - yield (readerResult as JSArrayBuffer).toDart.asUint8List(); - start += _readStreamChunkSize; - continue; - } - - if (readerResult.isA()) { - yield Uint8List.fromList((readerResult as JSArray).toDart.cast()); - start += _readStreamChunkSize; - } - } - } } diff --git a/packages/file_picker_web/lib/src/file_picker_web_options.dart b/packages/file_picker_web/lib/src/file_picker_web_options.dart index b7a6f7a14..7a7e5b0a9 100644 --- a/packages/file_picker_web/lib/src/file_picker_web_options.dart +++ b/packages/file_picker_web/lib/src/file_picker_web_options.dart @@ -7,15 +7,27 @@ final class FilePickerWebOptions extends WebOptions { /// Files larger than 2 GB are never preloaded, since browsers cannot hold /// them in a single buffer. Their content can be read on demand through /// `PlatformFile.readAsBytes()` and `readAsByteStream()` instead. + @Deprecated( + 'Picked files are read on demand by PlatformFile.readAsBytes() and ' + 'readAsByteStream(). This option will be removed in 14.0.0.', + ) final bool withData; /// Whether to create a read stream for each picked file. + @Deprecated( + 'PlatformFile.readAsByteStream() already reads on demand in evenly sized ' + 'chunks. This option will be removed in 14.0.0.', + ) final bool withReadStream; /// Whether to read multiple files one at a time instead of concurrently. /// /// The result always preserves the original selection order, regardless /// of this setting. + @Deprecated( + 'Only affects the deprecated withData preload. This option will be ' + 'removed in 14.0.0.', + ) final bool readSequential; /// Whether to cancel upload when window loses focus. @@ -23,8 +35,20 @@ final class FilePickerWebOptions extends WebOptions { /// Creates an instance of [FilePickerWebOptions]. const FilePickerWebOptions({ + @Deprecated( + 'Picked files are read on demand by PlatformFile.readAsBytes() and ' + 'readAsByteStream(). This option will be removed in 14.0.0.', + ) this.withData = true, + @Deprecated( + 'PlatformFile.readAsByteStream() already reads on demand in evenly ' + 'sized chunks. This option will be removed in 14.0.0.', + ) this.withReadStream = false, + @Deprecated( + 'Only affects the deprecated withData preload. This option will be ' + 'removed in 14.0.0.', + ) this.readSequential = false, this.cancelUploadOnWindowBlur = true, }); diff --git a/packages/file_picker_web/lib/src/platform_file_web_fetch.dart b/packages/file_picker_web/lib/src/platform_file_web_fetch.dart index c416adfae..0000189a2 100644 --- a/packages/file_picker_web/lib/src/platform_file_web_fetch.dart +++ b/packages/file_picker_web/lib/src/platform_file_web_fetch.dart @@ -1,29 +1,16 @@ import 'dart:js_interop'; +import 'dart:math'; import 'dart:typed_data'; +import 'package:web/web.dart'; + @JS('fetch') external JSPromise _fetchJs(JSString url); /// Interop extension type representing a Web `Response` JS object. extension type _Response(JSObject _) implements JSObject { external JSPromise arrayBuffer(); - external JSObject? get body; -} - -/// Interop extension type representing a Web `ReadableStream` JS object. -extension type _ReadableStream(JSObject _) implements JSObject { - external JSObject getReader(); -} - -/// Interop extension type representing a Web `ReadableStreamDefaultReader` JS object. -extension type _Reader(JSObject _) implements JSObject { - external JSPromise read(); -} - -/// Interop extension type representing a Web `ReadableStreamReadResult` JS object. -extension type _ReadResult(JSObject _) implements JSObject { - external bool get done; - external JSUint8Array? get value; + external JSPromise blob(); } /// Fetches the bytes of a web-only path (`blob:` or `data:` URL). @@ -64,42 +51,21 @@ Stream? fetchStreamFromWebPath(String path) { return _streamFromWebPath(path); } -/// Reads a `blob:` or `data:` URL and emits its bytes as a stream. -/// -/// Uses `Response.body` (`ReadableStream`) when available; otherwise falls -/// back to a single in-memory `arrayBuffer()` chunk. -Stream _streamFromWebPath(String path) async* { - if (path.startsWith('data:')) { - final uriData = Uri.parse(path).data; - - if (uriData == null) { - throw FormatException('Invalid data: URL', path); - } - - yield uriData.contentAsBytes(); - return; - } +/// The size of the chunks emitted by [streamBlobInChunks]. +const int webStreamChunkSize = 1024 * 1024; +/// Reads a `blob:` or `data:` URL and emits its bytes in evenly sized chunks. +Stream _streamFromWebPath(String path) async* { final response = _Response(await _fetchJs(path.toJS).toDart); - final body = response.body; + yield* streamBlobInChunks(await response.blob().toDart); +} - // If there's no streaming body, fallback to arrayBuffer() - if (body == null) { - final buffer = await response.arrayBuffer().toDart; +/// Emits the content of [blob] in chunks of [webStreamChunkSize] bytes, +/// except the last one, which may be shorter. +Stream streamBlobInChunks(Blob blob) async* { + for (var start = 0; start < blob.size; start += webStreamChunkSize) { + final end = min(start + webStreamChunkSize, blob.size); + final buffer = await blob.slice(start, end).arrayBuffer().toDart; yield buffer.toDart.asUint8List(); - return; - } - - final readable = _ReadableStream(body); - final reader = _Reader(readable.getReader()); - - while (true) { - final result = _ReadResult(await reader.read().toDart); - if (result.done) break; - - final arr = result.value; - if (arr == null) break; - - yield arr.toDart; } } diff --git a/packages/file_picker_web/lib/src/web_platform_file.dart b/packages/file_picker_web/lib/src/web_platform_file.dart index d17475587..19c727725 100644 --- a/packages/file_picker_web/lib/src/web_platform_file.dart +++ b/packages/file_picker_web/lib/src/web_platform_file.dart @@ -94,7 +94,13 @@ base class WebPlatformFile extends PlatformFile { return xFile.readAsBytes(); } + /// The size of the chunks emitted by [readAsByteStream]. + static const int streamChunkSize = webStreamChunkSize; + /// Asynchronously opens a stream to read the file content in chunks. + /// + /// Every chunk is [streamChunkSize] bytes long except the last one, which + /// may be shorter, so consumers can rely on evenly sized buffers. @override Stream readAsByteStream() async* { final readStream = _readStream; diff --git a/packages/file_picker_web/pubspec.yaml b/packages/file_picker_web/pubspec.yaml index 1c9ed9cf2..9feab4080 100644 --- a/packages/file_picker_web/pubspec.yaml +++ b/packages/file_picker_web/pubspec.yaml @@ -1,6 +1,6 @@ name: file_picker_web description: Web platform implementation of the file_picker plugin, providing browser file selection, file streaming, and file loading. -version: 4.0.1 +version: 4.1.0 homepage: https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_web repository: https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_web topics: diff --git a/packages/file_picker_web/test/file_picker_web_pick_test.dart b/packages/file_picker_web/test/file_picker_web_pick_test.dart index 9fb704b16..9037abd3d 100644 --- a/packages/file_picker_web/test/file_picker_web_pick_test.dart +++ b/packages/file_picker_web/test/file_picker_web_pick_test.dart @@ -13,8 +13,13 @@ import 'package:web/web.dart'; void main() { setUpAll(() => FilePickerWeb.registerWith(webPluginRegistrar)); - Future> pick(List files) { - final result = FilePickerPlatform.instance.pickFiles(); + Future> pick( + List files, { + WebOptions webOptions = const WebOptions(), + }) { + final result = FilePickerPlatform.instance.pickFiles( + webOptions: webOptions, + ); final input = document.querySelector('flt-file-picker-inputs input')! @@ -45,4 +50,23 @@ void main() { expect(files.single.uri.scheme, 'blob'); expect(await files.single.readAsBytes(), isEmpty); }); + + for (final (label, options) in [ + ('default options', const FilePickerWebOptions()), + // ignore: deprecated_member_use_from_same_package + ('withReadStream', const FilePickerWebOptions(withReadStream: true)), + ]) { + test('picked files stream evenly sized chunks with $label', () async { + final content = 'a' * (WebPlatformFile.streamChunkSize + 10); + final files = await pick([ + fileWith(name: 'big.txt', content: content), + ], webOptions: options); + + final chunks = await files.single.readAsByteStream().toList(); + expect(chunks.map((chunk) => chunk.length), [ + WebPlatformFile.streamChunkSize, + 10, + ]); + }); + } } diff --git a/packages/file_picker_web/test/web_platform_file_read_test.dart b/packages/file_picker_web/test/web_platform_file_read_test.dart index e25c0d46d..2114f64c9 100644 --- a/packages/file_picker_web/test/web_platform_file_read_test.dart +++ b/packages/file_picker_web/test/web_platform_file_read_test.dart @@ -59,4 +59,31 @@ void main() { final chunks = await fileAt(url).readAsByteStream().toList(); expect(chunks.expand((chunk) => chunk).toList(), content); }); + + test('readAsByteStream() emits evenly sized chunks', () async { + const size = + WebPlatformFile.streamChunkSize * 2 + + WebPlatformFile.streamChunkSize ~/ 2; + // 251 is prime, so the byte pattern does not line up with the chunk + // boundaries (a multiple of 256) and swapped or repeated chunks still fail. + final large = Uint8List.fromList(List.generate(size, (i) => i % 251)); + final url = URL.createObjectURL(Blob([large.toJS].toJS)); + addTearDown(() => URL.revokeObjectURL(url)); + + final chunks = await fileAt(url).readAsByteStream().toList(); + + expect(chunks.map((chunk) => chunk.length), [ + WebPlatformFile.streamChunkSize, + WebPlatformFile.streamChunkSize, + WebPlatformFile.streamChunkSize ~/ 2, + ]); + expect(chunks.expand((chunk) => chunk).toList(), large); + }); + + test('readAsByteStream() emits nothing for an empty file', () async { + final url = URL.createObjectURL(Blob([].toJS)); + addTearDown(() => URL.revokeObjectURL(url)); + + expect(await fileAt(url).readAsByteStream().toList(), isEmpty); + }); } From 46fae6f375676d0557d018cd9266c6ddd05dc3cf Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:33:25 +0200 Subject: [PATCH 2/3] Update packages/file_picker_web/CHANGELOG.md Co-authored-by: Navaron Bracke --- packages/file_picker_web/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/file_picker_web/CHANGELOG.md b/packages/file_picker_web/CHANGELOG.md index 239c82887..8596955d5 100644 --- a/packages/file_picker_web/CHANGELOG.md +++ b/packages/file_picker_web/CHANGELOG.md @@ -1,7 +1,7 @@ ## 4.1.0 - `readAsByteStream()` now always emits evenly sized chunks of `WebPlatformFile.streamChunkSize` (1 MiB), except the last one, with or without `withReadStream`. With `withReadStream` the chunks used to be 1,000,000 bytes. [#2223](https://github.com/vicajilau/flutter_file_picker/issues/2223) -- Deprecated `withData`, `withReadStream` and `readSequential` in `FilePickerWebOptions`. Picked files are read on demand by `readAsBytes()` and `readAsByteStream()`. They will be removed in 14.0.0. +- Deprecated `withData`, `withReadStream` and `readSequential` in `FilePickerWebOptions`. Picked files are read on demand by `readAsBytes()` and `readAsByteStream()`. They will be removed in the next major version. ## 4.0.1 From 76bb0c91eaa28d4a535a919489eb545e110ae3c3 Mon Sep 17 00:00:00 2001 From: Victor Carreras <34163765+vicajilau@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:35:04 +0200 Subject: [PATCH 3/3] Address review feedback on the web chunking change Name the package in the deprecation messages, since 14.0.0 is the file_picker version and not the file_picker_web one. Split the chunking test into one test per option so the deprecated case can be deleted on its own in 14.0.0, and rename the pick helper to pickFiles. --- .../lib/src/file_picker_web_options.dart | 12 ++--- .../test/file_picker_web_pick_test.dart | 47 +++++++++++-------- 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/packages/file_picker_web/lib/src/file_picker_web_options.dart b/packages/file_picker_web/lib/src/file_picker_web_options.dart index 7a7e5b0a9..627d42ee4 100644 --- a/packages/file_picker_web/lib/src/file_picker_web_options.dart +++ b/packages/file_picker_web/lib/src/file_picker_web_options.dart @@ -9,14 +9,14 @@ final class FilePickerWebOptions extends WebOptions { /// `PlatformFile.readAsBytes()` and `readAsByteStream()` instead. @Deprecated( 'Picked files are read on demand by PlatformFile.readAsBytes() and ' - 'readAsByteStream(). This option will be removed in 14.0.0.', + 'readAsByteStream(). This option will be removed in file_picker v14.0.0.', ) final bool withData; /// Whether to create a read stream for each picked file. @Deprecated( 'PlatformFile.readAsByteStream() already reads on demand in evenly sized ' - 'chunks. This option will be removed in 14.0.0.', + 'chunks. This option will be removed in file_picker v14.0.0.', ) final bool withReadStream; @@ -26,7 +26,7 @@ final class FilePickerWebOptions extends WebOptions { /// of this setting. @Deprecated( 'Only affects the deprecated withData preload. This option will be ' - 'removed in 14.0.0.', + 'removed in file_picker v14.0.0.', ) final bool readSequential; @@ -37,17 +37,17 @@ final class FilePickerWebOptions extends WebOptions { const FilePickerWebOptions({ @Deprecated( 'Picked files are read on demand by PlatformFile.readAsBytes() and ' - 'readAsByteStream(). This option will be removed in 14.0.0.', + 'readAsByteStream(). This option will be removed in file_picker v14.0.0.', ) this.withData = true, @Deprecated( 'PlatformFile.readAsByteStream() already reads on demand in evenly ' - 'sized chunks. This option will be removed in 14.0.0.', + 'sized chunks. This option will be removed in file_picker v14.0.0.', ) this.withReadStream = false, @Deprecated( 'Only affects the deprecated withData preload. This option will be ' - 'removed in 14.0.0.', + 'removed in file_picker v14.0.0.', ) this.readSequential = false, this.cancelUploadOnWindowBlur = true, diff --git a/packages/file_picker_web/test/file_picker_web_pick_test.dart b/packages/file_picker_web/test/file_picker_web_pick_test.dart index 9037abd3d..86fdea20f 100644 --- a/packages/file_picker_web/test/file_picker_web_pick_test.dart +++ b/packages/file_picker_web/test/file_picker_web_pick_test.dart @@ -13,7 +13,7 @@ import 'package:web/web.dart'; void main() { setUpAll(() => FilePickerWeb.registerWith(webPluginRegistrar)); - Future> pick( + Future> pickFiles( List files, { WebOptions webOptions = const WebOptions(), }) { @@ -38,35 +38,42 @@ void main() { File([utf8.encode(content).toJS].toJS, name); test('picked files point at a readable blob: URL', () async { - final files = await pick([fileWith(name: 'a.txt', content: 'hello')]); + final files = await pickFiles([fileWith(name: 'a.txt', content: 'hello')]); expect(files.single.uri.scheme, 'blob'); expect(utf8.decode(await files.single.readAsBytes()), 'hello'); }); test('an empty picked file still gets a blob: URL', () async { - final files = await pick([fileWith(name: 'empty.txt', content: '')]); + final files = await pickFiles([fileWith(name: 'empty.txt', content: '')]); expect(files.single.uri.scheme, 'blob'); expect(await files.single.readAsBytes(), isEmpty); }); - for (final (label, options) in [ - ('default options', const FilePickerWebOptions()), - // ignore: deprecated_member_use_from_same_package - ('withReadStream', const FilePickerWebOptions(withReadStream: true)), - ]) { - test('picked files stream evenly sized chunks with $label', () async { - final content = 'a' * (WebPlatformFile.streamChunkSize + 10); - final files = await pick([ - fileWith(name: 'big.txt', content: content), - ], webOptions: options); - - final chunks = await files.single.readAsByteStream().toList(); - expect(chunks.map((chunk) => chunk.length), [ - WebPlatformFile.streamChunkSize, - 10, - ]); - }); + Future expectEvenChunks(List files) async { + final chunks = await files.single.readAsByteStream().toList(); + expect(chunks.map((chunk) => chunk.length), [ + WebPlatformFile.streamChunkSize, + 10, + ]); } + + String bigContent() => 'a' * (WebPlatformFile.streamChunkSize + 10); + + test('picked files stream evenly sized chunks', () async { + await expectEvenChunks( + await pickFiles([fileWith(name: 'big.txt', content: bigContent())]), + ); + }); + + test('picked files stream evenly sized chunks with withReadStream', () async { + await expectEvenChunks( + await pickFiles( + [fileWith(name: 'big.txt', content: bigContent())], + // ignore: deprecated_member_use_from_same_package + webOptions: const FilePickerWebOptions(withReadStream: true), + ), + ); + }); }