Skip to content

Commit 26080ab

Browse files
committed
Fix FileDownload logic
1 parent 2cd1262 commit 26080ab

File tree

4 files changed

+118
-158
lines changed

4 files changed

+118
-158
lines changed

src/Components/Web.JS/src/Rendering/BinaryMedia.ts

Lines changed: 59 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -315,121 +315,44 @@ export class BinaryMedia {
315315
element: HTMLElement,
316316
streamRef: { stream: () => Promise<ReadableStream<Uint8Array>> } | null,
317317
mimeType: string,
318-
cacheKey: string,
319318
totalBytes: number | null,
320-
fileName?: string | null,
321-
attemptNativePicker = true,
322-
): Promise<MediaLoadResult> {
323-
if (!element || !cacheKey) {
324-
return { success: false, fromCache: false, objectUrl: null, error: 'Invalid parameters' };
325-
}
326-
327-
const nativePickerAvailable = attemptNativePicker && typeof (window as unknown as { showSaveFilePicker?: unknown }).showSaveFilePicker === 'function';
328-
329-
// Try cache first
330-
let cacheHitBlob: Blob | null = null;
331-
let _fromCache = false;
332-
try {
333-
const cache = await this.getCache();
334-
if (cache) {
335-
const cached = await cache.match(encodeURIComponent(cacheKey));
336-
if (cached) {
337-
cacheHitBlob = await cached.blob();
338-
_fromCache = true;
339-
}
340-
}
341-
} catch (err) {
342-
this.logger.log(LogLevel.Debug, `Cache lookup failed (download): ${err}`);
343-
}
344-
345-
// If we have a cache hit and native picker is available, stream blob to file directly
346-
if (cacheHitBlob && nativePickerAvailable) {
347-
try {
348-
const handle = await (window as unknown as { showSaveFilePicker: (opts: any) => Promise<any> }).showSaveFilePicker({ suggestedName: fileName || cacheKey }); // eslint-disable-line @typescript-eslint/no-explicit-any
349-
const writer = await handle.createWritable();
350-
const stream = cacheHitBlob.stream();
351-
const result = await this.writeStreamToFile(element, stream as ReadableStream<Uint8Array>, writer, totalBytes);
352-
if (result === 'success') {
353-
return { success: true, fromCache: true, objectUrl: null };
354-
}
355-
// aborted treated as failure
356-
return { success: false, fromCache: true, objectUrl: null, error: 'Aborted' };
357-
} catch (pickerErr) {
358-
// User might have cancelled; fall back to anchor download if we still have blob
359-
this.logger.log(LogLevel.Debug, `Native picker path failed or cancelled: ${pickerErr}`);
360-
}
361-
}
362-
363-
if (cacheHitBlob && !nativePickerAvailable) {
364-
// Fallback anchor path using cached blob
365-
const url = URL.createObjectURL(cacheHitBlob);
366-
this.triggerDownload(url, (fileName || cacheKey));
367-
return { success: true, fromCache: true, objectUrl: url };
368-
}
369-
370-
if (!streamRef) {
371-
return { success: false, fromCache: false, objectUrl: null, error: 'No stream provided' };
319+
fileName: string,
320+
): Promise<boolean> {
321+
if (!element || !fileName || !streamRef) {
322+
return false;
372323
}
373324

374-
// Stream and optionally cache (dup logic from streamAndCreateUrl, without setting element attributes)
375325
this.loadingElements.add(element);
376326
const controller = new AbortController();
377327
this.controllers.set(element, controller);
378328

379329
try {
380330
const readable = await streamRef.stream();
381331

382-
// If native picker available, we can stream directly to file, optionally tee for cache
383-
if (nativePickerAvailable) {
332+
// Native picker direct-to-file streaming available
333+
if (typeof (window as any).showSaveFilePicker === 'function') { // eslint-disable-line @typescript-eslint/no-explicit-any
384334
try {
385-
const handle = await (window as unknown as { showSaveFilePicker: (opts: any) => Promise<any> }).showSaveFilePicker({ suggestedName: fileName || cacheKey }); // eslint-disable-line @typescript-eslint/no-explicit-any
386-
const writer = await handle.createWritable();
387-
388-
let workingStream: ReadableStream<Uint8Array> = readable;
389-
let cacheStream: ReadableStream<Uint8Array> | null = null;
390-
if (cacheKey) {
391-
const cache = await this.getCache();
392-
if (cache) {
393-
const tees = readable.tee();
394-
workingStream = tees[0];
395-
cacheStream = tees[1];
396-
cache.put(encodeURIComponent(cacheKey), new Response(cacheStream)).catch(err => {
397-
this.logger.log(LogLevel.Debug, `Failed to put cache entry (download/native): ${err}`);
398-
});
399-
}
400-
}
335+
const handle = await (window as any).showSaveFilePicker({ suggestedName: fileName}); // eslint-disable-line @typescript-eslint/no-explicit-any
401336

402-
const writeResult = await this.writeStreamToFile(element, workingStream, writer, totalBytes, controller);
337+
const writer = await handle.createWritable();
338+
const writeResult = await this.writeStreamToFile(element, readable, writer, totalBytes, controller);
403339
if (writeResult === 'success') {
404-
return { success: true, fromCache: false, objectUrl: null };
340+
return true;
405341
}
406342
if (writeResult === 'aborted') {
407-
return { success: false, fromCache: false, objectUrl: null, error: 'Aborted' };
343+
return false;
408344
}
409345
} catch (pickerErr) {
410-
this.logger.log(LogLevel.Debug, `Native picker streaming path failed or cancelled after selection: ${pickerErr}`);
411-
// Fall through to in-memory blob fallback
412-
}
413-
}
414-
415-
// In-memory path (existing logic)
416-
let displayStream: ReadableStream<Uint8Array> = readable;
417-
if (cacheKey) {
418-
const cache = await this.getCache();
419-
if (cache) {
420-
const [display, cacheStream] = readable.tee();
421-
displayStream = display;
422-
cache.put(encodeURIComponent(cacheKey), new Response(cacheStream)).catch(err => {
423-
this.logger.log(LogLevel.Debug, `Failed to put cache entry (download): ${err}`);
424-
});
346+
this.logger.log(LogLevel.Debug, `Native picker streaming path failed or cancelled: ${pickerErr}`);
425347
}
426348
}
427349

350+
// In-memory fallback: read all bytes then trigger anchor download
428351
const chunks: Uint8Array[] = [];
429352
let bytesRead = 0;
430-
for await (const chunk of this.iterateStream(displayStream, controller.signal)) {
353+
for await (const chunk of this.iterateStream(readable, controller.signal)) {
431354
if (controller.signal.aborted) {
432-
return { success: false, fromCache: false, objectUrl: null, error: 'Aborted' };
355+
return false;
433356
}
434357
chunks.push(chunk);
435358
bytesRead += chunk.byteLength;
@@ -440,17 +363,18 @@ export class BinaryMedia {
440363
}
441364

442365
if (bytesRead === 0) {
443-
return { success: false, fromCache: false, objectUrl: null, error: 'Empty stream' };
366+
return false;
444367
}
445368

446369
const combined = this.combineChunks(chunks);
447370
const blob = new Blob([combined], { type: mimeType });
448371
const url = URL.createObjectURL(blob);
449-
this.triggerDownload(url, fileName || cacheKey);
450-
return { success: true, fromCache: false, objectUrl: url };
372+
this.triggerDownload(url, fileName);
373+
374+
return true;
451375
} catch (error) {
452376
this.logger.log(LogLevel.Debug, `Error in downloadAsync: ${error}`);
453-
return { success: false, fromCache: false, objectUrl: null, error: String(error) };
377+
return false;
454378
} finally {
455379
if (this.controllers.get(element) === controller) {
456380
this.controllers.delete(element);
@@ -531,9 +455,11 @@ export class BinaryMedia {
531455
a.style.display = 'none';
532456
document.body.appendChild(a);
533457
a.click();
458+
534459
setTimeout(() => {
535460
try {
536-
document.body.removeChild(a);
461+
a.remove();
462+
URL.revokeObjectURL(url);
537463
} catch {
538464
// ignore
539465
}
@@ -543,18 +469,16 @@ export class BinaryMedia {
543469
}
544470
}
545471

546-
// Helper to stream data directly to a FileSystemWritableFileStream with progress & abort handling
547472
private static async writeStreamToFile(
548473
element: HTMLElement,
549474
stream: ReadableStream<Uint8Array>,
550475
writer: any, // eslint-disable-line @typescript-eslint/no-explicit-any
551476
totalBytes: number | null,
552477
controller?: AbortController
553478
): Promise<'success' | 'aborted' | 'error'> {
554-
const reader = stream.getReader();
555479
let written = 0;
556480
try {
557-
for (;;) {
481+
for await (const chunk of this.iterateStream(stream, controller?.signal)) {
558482
if (controller?.signal.aborted) {
559483
try {
560484
await writer.abort();
@@ -564,20 +488,44 @@ export class BinaryMedia {
564488
element.style.removeProperty('--blazor-media-progress');
565489
return 'aborted';
566490
}
567-
const { done, value } = await reader.read();
568-
if (done) {
569-
break;
570-
}
571-
if (value) {
572-
await writer.write(value);
573-
written += value.byteLength;
574-
if (totalBytes) {
575-
const progress = Math.min(1, written / totalBytes);
576-
element.style.setProperty('--blazor-media-progress', progress.toString());
491+
try {
492+
await writer.write(chunk);
493+
} catch (wErr) {
494+
if (controller?.signal.aborted) {
495+
try {
496+
await writer.abort();
497+
} catch {
498+
/* ignore */
499+
}
500+
return 'aborted';
577501
}
502+
return 'error';
503+
}
504+
written += chunk.byteLength;
505+
if (totalBytes) {
506+
const progress = Math.min(1, written / totalBytes);
507+
element.style.setProperty('--blazor-media-progress', progress.toString());
508+
}
509+
}
510+
511+
if (controller?.signal.aborted) {
512+
try {
513+
await writer.abort();
514+
} catch {
515+
/* ignore */
516+
}
517+
element.style.removeProperty('--blazor-media-progress');
518+
return 'aborted';
519+
}
520+
521+
try {
522+
await writer.close();
523+
} catch (closeErr) {
524+
if (controller?.signal.aborted) {
525+
return 'aborted';
578526
}
527+
return 'error';
579528
}
580-
await writer.close();
581529
return 'success';
582530
} catch (e) {
583531
try {
@@ -588,11 +536,6 @@ export class BinaryMedia {
588536
return controller?.signal.aborted ? 'aborted' : 'error';
589537
} finally {
590538
element.style.removeProperty('--blazor-media-progress');
591-
try {
592-
reader.releaseLock?.();
593-
} catch {
594-
/* ignore */
595-
}
596539
}
597540
}
598541
}

src/Components/Web/src/Media/FileDownload.cs

Lines changed: 28 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using Microsoft.AspNetCore.Components.Rendering;
5+
using Microsoft.JSInterop;
56

67
namespace Microsoft.AspNetCore.Components.Web.Media;
78

@@ -17,26 +18,26 @@ namespace Microsoft.AspNetCore.Components.Web.Media;
1718
public sealed class FileDownload : MediaComponentBase
1819
{
1920
/// <summary>
20-
/// Optional file name to suggest to the browser for the download.
21+
/// File name to suggest to the browser for the download. Must be provided.
2122
/// </summary>
22-
[Parameter] public string? FileName { get; set; }
23+
[Parameter, EditorRequired] public string FileName { get; set; } = default!;
2324

2425
/// <summary>
2526
/// Provides custom button text. Defaults to "Download".
2627
/// </summary>
2728
[Parameter] public string? ButtonText { get; set; }
2829

2930
/// <inheritdoc />
30-
protected override string TagName => "button"; // Render a button element
31+
protected override string TagName => "button";
3132

3233
/// <inheritdoc />
33-
protected override string TargetAttributeName => "data-download-object-url"; // Not an actual browser attribute; used for diagnostics.
34+
protected override string TargetAttributeName => string.Empty; // Not used – object URL not tracked for downloads.
3435

3536
/// <inheritdoc />
3637
protected override string MarkerAttributeName => "data-blazor-file-download";
3738

3839
/// <inheritdoc />
39-
protected override bool ShouldAutoLoad => false; // Manual trigger via click.
40+
protected override bool ShouldAutoLoad => false;
4041

4142
/// <summary>
4243
/// Builds the button element with click handler wiring.
@@ -45,68 +46,61 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
4546
{
4647
builder.OpenElement(0, TagName);
4748

48-
if (!string.IsNullOrEmpty(_currentObjectUrl))
49-
{
50-
builder.AddAttribute(1, TargetAttributeName, _currentObjectUrl);
51-
}
52-
53-
builder.AddAttribute(2, MarkerAttributeName, "");
49+
// Removed object URL attribute emission; not needed for downloads.
50+
builder.AddAttribute(1, MarkerAttributeName, "");
5451

5552
if (IsLoading)
5653
{
57-
builder.AddAttribute(3, "data-state", "loading");
54+
builder.AddAttribute(2, "data-state", "loading");
5855
}
5956
else if (_hasError)
6057
{
61-
builder.AddAttribute(3, "data-state", "error");
58+
builder.AddAttribute(2, "data-state", "error");
6259
}
6360

64-
builder.AddAttribute(4, "type", "button");
65-
builder.AddAttribute(5, "onclick", EventCallback.Factory.Create(this, OnClickAsync));
66-
builder.AddMultipleAttributes(6, AdditionalAttributes);
67-
builder.AddElementReferenceCapture(7, er => Element = er);
61+
builder.AddAttribute(3, "type", "button");
62+
builder.AddAttribute(4, "onclick", EventCallback.Factory.Create(this, OnClickAsync));
63+
builder.AddMultipleAttributes(5, AdditionalAttributes);
64+
builder.AddElementReferenceCapture(6, elementReference => Element = elementReference);
6865

69-
builder.AddContent(8, ButtonText ?? "Download");
66+
builder.AddContent(7, ButtonText ?? "Download");
7067

7168
builder.CloseElement();
7269
}
7370

7471
private async Task OnClickAsync()
7572
{
76-
if (Source is null || !IsInteractive)
73+
if (Source is null || !IsInteractive || string.IsNullOrWhiteSpace(FileName))
7774
{
7875
return;
7976
}
8077

81-
// Cancel any existing load
8278
CancelPreviousLoad();
8379
var token = ResetCancellationToken();
84-
_currentSource = Source;
8580
_hasError = false;
86-
_currentObjectUrl = null; // Always recreate for downloads
87-
RequestRender();
8881

8982
var source = Source;
9083

84+
using var streamRef = new DotNetStreamReference(source.Stream, leaveOpen: true);
85+
9186
try
9287
{
93-
var result = await InvokeBinaryMediaAsync(
94-
"Blazor._internal.BinaryMedia", // JS method we will add
95-
source,
88+
var result = await JSRuntime.InvokeAsync<Boolean>(
89+
"Blazor._internal.BinaryMedia.downloadAsync",
9690
token,
91+
Element,
92+
streamRef,
93+
source.MimeType,
94+
source.Length,
9795
FileName);
9896

99-
if (result is not null && _activeCacheKey == source.CacheKey && !token.IsCancellationRequested)
97+
if (result && !token.IsCancellationRequested)
10098
{
101-
if (result.Success)
102-
{
103-
_currentObjectUrl = result.ObjectUrl; // store created object URL for potential diagnostics
104-
}
105-
else
99+
if (!result)
106100
{
107101
_hasError = true;
108102
}
109-
RequestRender();
103+
Render();
110104
}
111105
}
112106
catch (OperationCanceledException)
@@ -115,7 +109,7 @@ private async Task OnClickAsync()
115109
catch
116110
{
117111
_hasError = true;
118-
RequestRender();
112+
Render();
119113
}
120114
}
121115
}

0 commit comments

Comments
 (0)