Skip to content

Commit ef0c523

Browse files
committed
Image component up to date with other brach
1 parent 81917f4 commit ef0c523

File tree

4 files changed

+136
-53
lines changed

4 files changed

+136
-53
lines changed

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

Lines changed: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export class BinaryMedia {
2929

3030
private static observer: MutationObserver | null = null;
3131

32+
private static controllers: WeakMap<HTMLElement, AbortController> = new WeakMap();
33+
3234
private static initializeObserver(): void {
3335
if (this.observer) {
3436
return;
@@ -96,6 +98,16 @@ export class BinaryMedia {
9698
this.loadingElements.delete(el);
9799
this.activeCacheKey.delete(el);
98100
}
101+
// Abort any in-flight stream tied to this element
102+
const controller = this.controllers.get(el);
103+
if (controller) {
104+
try {
105+
controller.abort();
106+
} catch {
107+
// ignore
108+
}
109+
this.controllers.delete(el);
110+
}
99111
}
100112

101113
/**
@@ -115,6 +127,20 @@ export class BinaryMedia {
115127

116128
this.initializeObserver();
117129

130+
// If there was a previous different key for this element, abort its in-flight operation
131+
const previousKey = this.activeCacheKey.get(element);
132+
if (previousKey && previousKey !== cacheKey) {
133+
const prevController = this.controllers.get(element);
134+
if (prevController) {
135+
try {
136+
prevController.abort();
137+
} catch {
138+
// ignore
139+
}
140+
this.controllers.delete(element);
141+
}
142+
}
143+
118144
this.activeCacheKey.set(element, cacheKey);
119145

120146
try {
@@ -180,6 +206,10 @@ export class BinaryMedia {
180206
): Promise<string | null> {
181207
this.loadingElements.add(element);
182208

209+
// Create and track an AbortController for this element
210+
const controller = new AbortController();
211+
this.controllers.set(element, controller);
212+
183213
const readable = await streamRef.stream();
184214
let displayStream = readable;
185215

@@ -196,35 +226,49 @@ export class BinaryMedia {
196226

197227
const chunks: Uint8Array[] = [];
198228
let bytesRead = 0;
229+
let aborted = false;
230+
let resultUrl: string | null = null;
199231

200-
for await (const chunk of this.iterateStream(displayStream)) {
201-
if (this.activeCacheKey.get(element) !== cacheKey) {
202-
return null;
203-
}
204-
205-
chunks.push(chunk);
206-
bytesRead += chunk.byteLength;
232+
try {
233+
for await (const chunk of this.iterateStream(displayStream, controller.signal)) {
234+
if (controller.signal.aborted) { // Stream aborted due to a new setImageAsync call with a key change
235+
aborted = true;
236+
break;
237+
}
238+
chunks.push(chunk);
239+
bytesRead += chunk.byteLength;
207240

208-
if (totalBytes) {
209-
const progress = Math.min(1, bytesRead / totalBytes);
210-
element.style.setProperty('--blazor-media-progress', progress.toString());
241+
if (totalBytes) {
242+
const progress = Math.min(1, bytesRead / totalBytes);
243+
element.style.setProperty('--blazor-media-progress', progress.toString());
244+
}
211245
}
212-
}
213246

214-
if (bytesRead === 0) {
215-
if (typeof totalBytes === 'number' && totalBytes > 0) {
216-
throw new Error('Stream was already consumed or at end position');
247+
if (!aborted) {
248+
if (bytesRead === 0) {
249+
if (typeof totalBytes === 'number' && totalBytes > 0) {
250+
throw new Error('Stream was already consumed or at end position');
251+
}
252+
resultUrl = null;
253+
} else {
254+
const combined = this.combineChunks(chunks);
255+
const blob = new Blob([combined], { type: mimeType });
256+
const url = URL.createObjectURL(blob);
257+
this.setUrl(element, url, cacheKey, targetAttr);
258+
resultUrl = url;
259+
}
260+
} else {
261+
resultUrl = null;
217262
}
218-
return null;
263+
} finally {
264+
if (this.controllers.get(element) === controller) {
265+
this.controllers.delete(element);
266+
}
267+
this.loadingElements.delete(element);
268+
element.style.removeProperty('--blazor-media-progress');
219269
}
220270

221-
const combined = this.combineChunks(chunks);
222-
const blob = new Blob([combined], { type: mimeType });
223-
const url = URL.createObjectURL(blob);
224-
225-
this.setUrl(element, url, cacheKey, targetAttr);
226-
227-
return url;
271+
return resultUrl;
228272
}
229273

230274
private static combineChunks(chunks: Uint8Array[]): Uint8Array {
@@ -291,23 +335,35 @@ export class BinaryMedia {
291335
return cache;
292336
}
293337

294-
private static async *iterateStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array, void, unknown> {
338+
private static async *iterateStream(stream: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<Uint8Array, void, unknown> {
295339
const reader = stream.getReader();
340+
let finished = false;
296341
try {
297342
while (true) {
343+
if (signal?.aborted) {
344+
try {
345+
await reader.cancel();
346+
} catch {
347+
// ignore
348+
}
349+
return;
350+
}
298351
const { done, value } = await reader.read();
299352
if (done) {
353+
finished = true;
300354
return;
301355
}
302356
if (value) {
303357
yield value;
304358
}
305359
}
306360
} finally {
307-
try {
308-
await reader.cancel();
309-
} catch {
310-
// ignore
361+
if (!finished) {
362+
try {
363+
await reader.cancel();
364+
} catch {
365+
// ignore
366+
}
311367
}
312368
try {
313369
reader.releaseLock?.();

src/Components/Web/src/Media/MediaComponentBase.cs

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Diagnostics.CodeAnalysis;
4+
using System.Diagnostics;
55
using Microsoft.AspNetCore.Components.Rendering;
66
using Microsoft.Extensions.Logging;
77
using Microsoft.JSInterop;
@@ -38,7 +38,7 @@ public abstract partial class MediaComponentBase : IComponent, IHandleAfterRende
3838
/// <summary>
3939
/// Gets the reference to the rendered HTML element for this media component.
4040
/// </summary>
41-
[DisallowNull] protected ElementReference? Element { get; set; }
41+
protected ElementReference? Element { get; set; }
4242

4343
/// <summary>
4444
/// Gets or sets the JS runtime used for interop with the browser to materialize media object URLs.
@@ -75,7 +75,7 @@ public abstract partial class MediaComponentBase : IComponent, IHandleAfterRende
7575
/// <summary>
7676
/// Gets or sets the media source.
7777
/// </summary>
78-
[Parameter] public MediaSource? Source { get; set; }
78+
[Parameter, EditorRequired] public required MediaSource Source { get; set; }
7979

8080
/// <summary>
8181
/// Unmatched attributes applied to the rendered element.
@@ -97,14 +97,19 @@ Task IComponent.SetParametersAsync(ParameterView parameters)
9797

9898
parameters.SetParameterProperties(this);
9999

100+
if (Source is null)
101+
{
102+
throw new InvalidOperationException("Image.Source is required.");
103+
}
104+
100105
if (!_initialized)
101106
{
102107
Render();
103108
_initialized = true;
104109
return Task.CompletedTask;
105110
}
106111

107-
if (Source != null && !string.Equals(previousSource?.CacheKey, Source.CacheKey, StringComparison.Ordinal))
112+
if (!HasSameKey(previousSource, Source))
108113
{
109114
Render();
110115
}
@@ -114,26 +119,24 @@ Task IComponent.SetParametersAsync(ParameterView parameters)
114119

115120
async Task IHandleAfterRender.OnAfterRenderAsync()
116121
{
117-
if (!IsInteractive || Source == null)
122+
var source = Source;
123+
if (!IsInteractive || source is null)
118124
{
119125
return;
120126
}
121127

122-
if (_currentSource != null && string.Equals(_currentSource.CacheKey, Source.CacheKey, StringComparison.Ordinal))
128+
if (_currentSource != null && HasSameKey(_currentSource, source))
123129
{
124130
return;
125131
}
126132

127-
try { _loadCts?.Cancel(); } catch { }
128-
_loadCts?.Dispose();
129-
_loadCts = new CancellationTokenSource();
130-
var token = _loadCts.Token;
131-
132-
_currentSource = Source;
133+
CancelPreviousLoad();
134+
var token = ResetCancellationToken();
133135

136+
_currentSource = source;
134137
try
135138
{
136-
await LoadMediaAsync(Source, token);
139+
await LoadMediaAsync(source, token);
137140
}
138141
catch (OperationCanceledException)
139142
{
@@ -142,7 +145,9 @@ async Task IHandleAfterRender.OnAfterRenderAsync()
142145

143146
private void Render()
144147
{
145-
if (!_hasPendingRender && _renderHandle.IsInitialized)
148+
Debug.Assert(_renderHandle.IsInitialized);
149+
150+
if (!_hasPendingRender)
146151
{
147152
_hasPendingRender = true;
148153
_renderHandle.Render(BuildRenderTree);
@@ -247,8 +252,6 @@ private async Task LoadMediaAsync(MediaSource? source, CancellationToken cancell
247252
}
248253
catch (OperationCanceledException)
249254
{
250-
// bubble up to caller
251-
throw;
252255
}
253256
catch (Exception ex)
254257
{
@@ -268,14 +271,33 @@ public ValueTask DisposeAsync()
268271
if (!_isDisposed)
269272
{
270273
_isDisposed = true;
274+
CancelPreviousLoad();
275+
}
276+
return new ValueTask();
277+
}
271278

272-
// Cancel any pending operations
273-
try { _loadCts?.Cancel(); } catch { }
274-
_loadCts?.Dispose();
275-
_loadCts = null;
279+
private void CancelPreviousLoad()
280+
{
281+
try
282+
{
283+
_loadCts?.Cancel();
284+
}
285+
catch
286+
{
276287
}
288+
_loadCts?.Dispose();
289+
_loadCts = null;
290+
}
277291

278-
return new ValueTask();
292+
private CancellationToken ResetCancellationToken()
293+
{
294+
_loadCts = new CancellationTokenSource();
295+
return _loadCts.Token;
296+
}
297+
298+
private static bool HasSameKey(MediaSource? a, MediaSource? b)
299+
{
300+
return a is not null && b is not null && string.Equals(a.CacheKey, b.CacheKey, StringComparison.Ordinal);
279301
}
280302

281303
private static partial class Log

src/Components/Web/src/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Microsoft.AspNetCore.Components.Web.Image.ImageSource.Stream.get -> System.IO.St
2323
Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(in Microsoft.JSInterop.Infrastructure.JSInvocationInfo invocationInfo) -> string!
2424
Microsoft.AspNetCore.Components.Web.Media.Image
2525
Microsoft.AspNetCore.Components.Web.Media.Image.Image() -> void
26+
Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.Source.get -> Microsoft.AspNetCore.Components.Web.Media.MediaSource!
2627
Microsoft.AspNetCore.Components.Web.Media.Video
2728
Microsoft.AspNetCore.Components.Web.Media.Video.Video() -> void
2829
Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase
@@ -39,7 +40,6 @@ Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.Logger.get -> Micro
3940
Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.LoggerFactory.get -> Microsoft.Extensions.Logging.ILoggerFactory!
4041
Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.LoggerFactory.set -> void
4142
Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.MediaComponentBase() -> void
42-
Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.Source.get -> Microsoft.AspNetCore.Components.Web.Media.MediaSource?
4343
Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.Source.set -> void
4444
Microsoft.AspNetCore.Components.Web.Media.MediaSource
4545
Microsoft.AspNetCore.Components.Web.Media.MediaSource.CacheKey.get -> string!
@@ -50,4 +50,5 @@ Microsoft.AspNetCore.Components.Web.Media.MediaSource.MimeType.get -> string!
5050
Microsoft.AspNetCore.Components.Web.Media.MediaSource.Stream.get -> System.IO.Stream!
5151
override Microsoft.AspNetCore.Components.Forms.InputHidden.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder) -> void
5252
override Microsoft.AspNetCore.Components.Forms.InputHidden.TryParseValueFromString(string? value, out string? result, out string? validationErrorMessage) -> bool
53+
virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool
5354
virtual Microsoft.AspNetCore.Components.Web.Media.MediaComponentBase.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder) -> void

src/Components/Web/test/Media/ImageTest.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,18 +70,22 @@ await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dic
7070
}
7171

7272
[Fact]
73-
public async Task NullSource_DoesNothing()
73+
public async Task NullSource_Throws()
7474
{
7575
var js = new FakeMediaJsRuntime(cacheHit: false);
7676
using var renderer = CreateRenderer(js);
7777
var comp = (Image)renderer.InstantiateComponent<Image>();
7878
var id = renderer.AssignRootComponentId(comp);
7979

80-
await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
80+
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
8181
{
82-
[nameof(Image.Source)] = null,
83-
}));
82+
await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary<string, object?>
83+
{
84+
[nameof(Image.Source)] = null,
85+
}));
86+
});
8487

88+
// Ensure no JS interop calls were made
8589
Assert.Equal(0, js.TotalInvocationCount);
8690
}
8791

0 commit comments

Comments
 (0)