diff --git a/src/Components/Web.JS/src/GlobalExports.ts b/src/Components/Web.JS/src/GlobalExports.ts index 89d5d5535fd5..9e131bd13dbd 100644 --- a/src/Components/Web.JS/src/GlobalExports.ts +++ b/src/Components/Web.JS/src/GlobalExports.ts @@ -19,6 +19,7 @@ import { attachWebRendererInterop } from './Rendering/WebRendererInteropMethods' import { WebStartOptions } from './Platform/WebStartOptions'; import { RuntimeAPI } from '@microsoft/dotnet-runtime'; import { JSEventRegistry } from './Services/JSEventRegistry'; +import { BinaryImageComponent } from './Rendering/BinaryImageComponent'; // TODO: It's kind of hard to tell which .NET platform(s) some of these APIs are relevant to. // It's important to know this information when dealing with the possibility of mulitple .NET platforms being available. @@ -50,6 +51,7 @@ export interface IBlazor { navigationManager: typeof navigationManagerInternalFunctions | any; domWrapper: typeof domFunctions; Virtualize: typeof Virtualize; + BinaryImageComponent: typeof BinaryImageComponent; PageTitle: typeof PageTitle; forceCloseConnection?: () => Promise; InputFile?: typeof InputFile; @@ -111,6 +113,7 @@ export const Blazor: IBlazor = { NavigationLock, getJSDataStreamChunk: getNextChunk, attachWebRendererInterop, + BinaryImageComponent, }, }; diff --git a/src/Components/Web.JS/src/Rendering/BinaryImageComponent.ts b/src/Components/Web.JS/src/Rendering/BinaryImageComponent.ts new file mode 100644 index 000000000000..d5fe77695fea --- /dev/null +++ b/src/Components/Web.JS/src/Rendering/BinaryImageComponent.ts @@ -0,0 +1,378 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +import { Logger, LogLevel } from '../Platform/Logging/Logger'; +import { ConsoleLogger } from '../Platform/Logging/Loggers'; + +export interface ImageLoadResult { + success: boolean; + fromCache: boolean; + objectUrl: string | null; + error?: string; +} + +/** + * Provides functionality for rendering binary image data in Blazor components. + */ +export class BinaryImageComponent { + private static readonly CACHE_NAME = 'blazor-image-cache'; + + private static cachePromise?: Promise = undefined; + + private static logger: Logger = new ConsoleLogger(LogLevel.Warning); + + private static loadingImages: Set = new Set(); + + private static activeCacheKey: WeakMap = new WeakMap(); + + private static trackedImages: WeakMap = new WeakMap(); + + private static observersByParent: WeakMap = new WeakMap(); + + private static controllers: WeakMap = new WeakMap(); + + private static initializeParentObserver(parent: Element): void { + if (this.observersByParent.has(parent)) { + return; + } + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + // Handle removed nodes within this parent subtree + if (mutation.type === 'childList') { + for (const node of Array.from(mutation.removedNodes)) { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + + if (element.tagName === 'IMG' && this.trackedImages.has(element as HTMLImageElement)) { + this.revokeTrackedUrl(element as HTMLImageElement); + } + + // Any tracked descendants + element.querySelectorAll('img').forEach((img) => { + if (this.trackedImages.has(img as HTMLImageElement)) { + this.revokeTrackedUrl(img as HTMLImageElement); + } + }); + } + } + } + + // Handle src attribute changes on tracked images + if (mutation.type === 'attributes' && (mutation as MutationRecord).attributeName === 'src') { + const img = (mutation.target as Element) as HTMLImageElement; + if (this.trackedImages.has(img)) { + const tracked = this.trackedImages.get(img); + if (tracked && img.src !== tracked.url) { + this.revokeTrackedUrl(img); + } + } + } + } + }); + + observer.observe(parent, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['src'], + }); + + this.observersByParent.set(parent, observer); + } + + private static revokeTrackedUrl(img: HTMLImageElement): void { + const tracked = this.trackedImages.get(img); + if (tracked) { + try { + URL.revokeObjectURL(tracked.url); + } catch { + // ignore + } + this.trackedImages.delete(img); + this.loadingImages.delete(img); + this.activeCacheKey.delete(img); + } + + // Abort any in-flight stream tied to this element + const controller = this.controllers.get(img); + if (controller) { + try { + controller.abort(); + } catch { + // ignore + } + this.controllers.delete(img); + } + } + + /** + * Single entry point for setting image - handles cache check and streaming + */ + public static async setImageAsync( + imgElement: HTMLImageElement, + streamRef: { stream: () => Promise> } | null, + mimeType: string, + cacheKey: string, + totalBytes: number | null + ): Promise { + if (!imgElement || !cacheKey) { + return { success: false, fromCache: false, objectUrl: null, error: 'Invalid parameters' }; + } + + // Ensure we are observing this image's parent + const parent = imgElement.parentElement; + if (parent) { + this.initializeParentObserver(parent); + } + + // If there was a previous different key for this element, abort its in-flight operation + const previousKey = this.activeCacheKey.get(imgElement); + if (previousKey && previousKey !== cacheKey) { + const prevController = this.controllers.get(imgElement); + if (prevController) { + try { + prevController.abort(); + } catch { + // ignore + } + this.controllers.delete(imgElement); + } + } + + this.activeCacheKey.set(imgElement, cacheKey); + + try { + // Try cache first + try { + const cache = await this.getCache(); + if (cache) { + const cachedResponse = await cache.match(encodeURIComponent(cacheKey)); + if (cachedResponse) { + const blob = await cachedResponse.blob(); + const url = URL.createObjectURL(blob); + + this.setImageUrl(imgElement, url, cacheKey); + + return { success: true, fromCache: true, objectUrl: url }; + } + } + } catch (err) { + this.logger.log(LogLevel.Debug, `Cache lookup failed: ${err}`); + } + + if (streamRef) { + const url = await this.streamAndCreateUrl(imgElement, streamRef, mimeType, cacheKey, totalBytes); + if (url) { + return { success: true, fromCache: false, objectUrl: url }; + } + } + + return { success: false, fromCache: false, objectUrl: null, error: 'No/empty stream provided and not in cache' }; + } catch (error) { + this.logger.log(LogLevel.Debug, `Error in setImageAsync: ${error}`); + return { success: false, fromCache: false, objectUrl: null, error: String(error) }; + } + } + + private static setImageUrl(imgElement: HTMLImageElement, url: string, cacheKey: string): void { + const tracked = this.trackedImages.get(imgElement); + if (tracked) { + try { + URL.revokeObjectURL(tracked.url); + } catch { + // ignore + } + } + + this.trackedImages.set(imgElement, { url, cacheKey }); + + imgElement.src = url; + + this.setupEventHandlers(imgElement, cacheKey); + } + + private static async streamAndCreateUrl( + imgElement: HTMLImageElement, + streamRef: { stream: () => Promise> }, + mimeType: string, + cacheKey: string, + totalBytes: number | null + ): Promise { + this.loadingImages.add(imgElement); + + // Create and track an AbortController for this element + const controller = new AbortController(); + this.controllers.set(imgElement, controller); + + const readable = await streamRef.stream(); + let displayStream = readable; + + if (cacheKey) { + const cache = await this.getCache(); + if (cache) { + const [display, cacheStream] = readable.tee(); + displayStream = display; + + cache.put(encodeURIComponent(cacheKey), new Response(cacheStream)).catch(err => { + this.logger.log(LogLevel.Debug, `Failed to cache: ${err}`); + }); + } + } + + const chunks: Uint8Array[] = []; + let bytesRead = 0; + + for await (const chunk of this.iterateStream(displayStream, controller.signal)) { + if (controller.signal.aborted) { // Stream aborted in a new setImageAsync call due to cache key change + if (this.controllers.get(imgElement) === controller) { + this.controllers.delete(imgElement); + } + this.loadingImages.delete(imgElement); + imgElement.style.removeProperty('--blazor-image-progress'); + return null; + } + + chunks.push(chunk); + bytesRead += chunk.byteLength; + + if (totalBytes) { + const progress = Math.min(1, bytesRead / totalBytes); + imgElement.style.setProperty('--blazor-image-progress', progress.toString()); + } + } + + if (bytesRead === 0) { + if (typeof totalBytes === 'number' && totalBytes > 0) { + throw new Error('Stream was already consumed or at end position'); + } + if (this.controllers.get(imgElement) === controller) { + this.controllers.delete(imgElement); + } + this.loadingImages.delete(imgElement); + imgElement.style.removeProperty('--blazor-image-progress'); + return null; + } + + const combined = this.combineChunks(chunks); + const blob = new Blob([combined], { type: mimeType }); + const url = URL.createObjectURL(blob); + + this.setImageUrl(imgElement, url, cacheKey); + + if (this.controllers.get(imgElement) === controller) { + this.controllers.delete(imgElement); + } + + return url; + } + + private static combineChunks(chunks: Uint8Array[]): Uint8Array { + if (chunks.length === 1) { + return chunks[0]; + } + + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const combined = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + return combined; + } + + private static setupEventHandlers( + imgElement: HTMLImageElement, + cacheKey: string | null = null + ): void { + const onLoad = (_e: Event) => { + if (!cacheKey || BinaryImageComponent.activeCacheKey.get(imgElement) === cacheKey) { + BinaryImageComponent.loadingImages.delete(imgElement); + imgElement.style.removeProperty('--blazor-image-progress'); + } + imgElement.removeEventListener('error', onError); + }; + + const onError = (_e: Event) => { + if (!cacheKey || BinaryImageComponent.activeCacheKey.get(imgElement) === cacheKey) { + BinaryImageComponent.loadingImages.delete(imgElement); + imgElement.style.removeProperty('--blazor-image-progress'); + } + imgElement.removeEventListener('load', onLoad); + }; + + imgElement.addEventListener('load', onLoad, { once: true }); + imgElement.addEventListener('error', onError, { once: true }); + } + + /** + * Opens or creates the cache storage + */ + private static async getCache(): Promise { + if (!('caches' in window)) { + this.logger.log(LogLevel.Warning, 'Cache API not supported in this browser'); + return null; + } + + if (!this.cachePromise) { + this.cachePromise = (async () => { + try { + return await caches.open(this.CACHE_NAME); + } catch (error) { + this.logger.log(LogLevel.Debug, `Failed to open cache: ${error}`); + return null; + } + })(); + } + + const cache = await this.cachePromise; + // If opening failed previously, allow retry next time + if (!cache) { + this.cachePromise = undefined; + } + return cache; + } + + /** + * Async iterator over a ReadableStream that ensures proper cancellation when iteration stops early. + */ + private static async *iterateStream(stream: ReadableStream, signal?: AbortSignal): AsyncGenerator { + const reader = stream.getReader(); + let finished = false; + + try { + while (true) { + if (signal?.aborted) { + try { + await reader.cancel(); + } catch { + // ignore + } + return; + } + const { done, value } = await reader.read(); + if (done) { + finished = true; + return; + } + if (value) { + yield value; + } + } + } finally { + if (!finished) { + try { + await reader.cancel(); + } catch { + // ignore + } + } + try { + reader.releaseLock?.(); + } catch { + // ignore + } + } + } +} diff --git a/src/Components/Web/src/Image/Image.cs b/src/Components/Web/src/Image/Image.cs new file mode 100644 index 000000000000..e55f56dd0e27 --- /dev/null +++ b/src/Components/Web/src/Image/Image.cs @@ -0,0 +1,291 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.JSInterop; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.Web.Image; + +/* This is equivalent to a .razor file containing: + * + * + * + */ +/// +/// A component that efficiently renders images from non-HTTP sources like byte arrays. +/// +public partial class Image : IComponent, IHandleAfterRender, IAsyncDisposable +{ + private RenderHandle _renderHandle; + private string? _currentObjectUrl; + private bool _hasError; + private bool _isDisposed; + private bool _initialized; + private bool _hasPendingRender; + private string? _activeCacheKey; + private ImageSource? _currentSource; + private CancellationTokenSource? _loadCts; + private bool IsLoading => _currentSource != null && string.IsNullOrEmpty(_currentObjectUrl) && !_hasError; + + private bool IsInteractive => _renderHandle.IsInitialized && + _renderHandle.RendererInfo.IsInteractive; + + [DisallowNull] private ElementReference? Element { get; set; } + + [Inject] private IJSRuntime JSRuntime { get; set; } = default!; + + [Inject] private ILogger Logger { get; set; } = default!; + + /// + /// Gets or sets the source for the image. + /// + [Parameter, EditorRequired] public ImageSource? Source { get; set; } + + /// + /// Gets or sets the attributes for the image. + /// + [Parameter(CaptureUnmatchedValues = true)] public Dictionary? AdditionalAttributes { get; set; } + + void IComponent.Attach(RenderHandle renderHandle) + { + if (_renderHandle.IsInitialized) + { + throw new InvalidOperationException("Component is already attached to a render handle."); + } + _renderHandle = renderHandle; + } + + Task IComponent.SetParametersAsync(ParameterView parameters) + { + var previousSource = Source; + + parameters.SetParameterProperties(this); + if (Source is null) + { + throw new InvalidOperationException("Image.Source is required."); + } + + // Initialize on first parameters set + if (!_initialized) + { + Render(); + _initialized = true; + return Task.CompletedTask; + } + + if (!HasSameKey(previousSource, Source)) + { + Render(); + } + + return Task.CompletedTask; + } + + async Task IHandleAfterRender.OnAfterRenderAsync() + { + var source = Source; + if (!IsInteractive || source is null) + { + return; + } + + if (_currentSource != null && HasSameKey(_currentSource, source)) + { + return; + } + + CancelPreviousLoad(); + var token = ResetCancellationToken(); + + _currentSource = source; + + try + { + await LoadImage(source, token); + } + catch (OperationCanceledException) + { + } + } + + private void Render() + { + Debug.Assert(_renderHandle.IsInitialized); + + if (!_hasPendingRender) + { + _hasPendingRender = true; + _renderHandle.Render(BuildRenderTree); + _hasPendingRender = false; + } + } + + private void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "img"); + + if (!string.IsNullOrEmpty(_currentObjectUrl)) + { + builder.AddAttribute(1, "src", _currentObjectUrl); + } + + builder.AddAttribute(2, "data-blazor-image", ""); + + var showInitialLoad = Source != null && _currentSource == null && string.IsNullOrEmpty(_currentObjectUrl) && !_hasError; + + if (IsLoading || showInitialLoad) + { + builder.AddAttribute(3, "data-state", "loading"); + } + else if (_hasError) + { + builder.AddAttribute(3, "data-state", "error"); + } + + builder.AddMultipleAttributes(4, AdditionalAttributes); + builder.AddElementReferenceCapture(5, elementReference => Element = elementReference); + + builder.CloseElement(); + } + + private struct ImageLoadResult + { + public bool Success { get; set; } + public bool FromCache { get; set; } + public string? ObjectUrl { get; set; } + public string? Error { get; set; } + } + + private async Task LoadImage(ImageSource source, CancellationToken cancellationToken) + { + if (!IsInteractive) + { + return; + } + + _activeCacheKey = source.CacheKey; + + try + { + Log.BeginLoad(Logger, source.CacheKey); + + cancellationToken.ThrowIfCancellationRequested(); + + using var streamRef = new DotNetStreamReference(source.Stream, leaveOpen: true); + + var result = await JSRuntime.InvokeAsync( + "Blazor._internal.BinaryImageComponent.setImageAsync", + cancellationToken, + Element, + streamRef, + source.MimeType, + source.CacheKey, + source.Length); + + if (_activeCacheKey == source.CacheKey && !cancellationToken.IsCancellationRequested) + { + if (result.Success) + { + _currentObjectUrl = result.ObjectUrl; + _hasError = false; + + if (result.FromCache) + { + Log.CacheHit(Logger, source.CacheKey); + } + else + { + Log.StreamStart(Logger, source.CacheKey); + } + + Log.LoadSuccess(Logger, source.CacheKey); + } + else + { + _hasError = true; + Log.LoadFailed(Logger, source.CacheKey, new InvalidOperationException(result.Error ?? "Image load failed")); + } + + Render(); + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Log.LoadFailed(Logger, source.CacheKey, ex); + if (_activeCacheKey == source.CacheKey && !cancellationToken.IsCancellationRequested) + { + _currentObjectUrl = null; + _hasError = true; + Render(); + } + } + } + + /// + public ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + + // Cancel any pending operations + CancelPreviousLoad(); + } + + return new ValueTask(); + } + + private void CancelPreviousLoad() + { + try + { + _loadCts?.Cancel(); + } + catch + { + } + + _loadCts?.Dispose(); + _loadCts = null; + } + + private CancellationToken ResetCancellationToken() + { + _loadCts = new CancellationTokenSource(); + return _loadCts.Token; + } + + private static bool HasSameKey(ImageSource? a, ImageSource? b) + { + return a is not null && b is not null && string.Equals(a.CacheKey, b.CacheKey, StringComparison.Ordinal); + } + + private static partial class Log + { + [LoggerMessage(1, LogLevel.Debug, "Begin load for key '{CacheKey}'", EventName = "BeginLoad")] + public static partial void BeginLoad(ILogger logger, string cacheKey); + + [LoggerMessage(2, LogLevel.Debug, "Loaded image from cache for key '{CacheKey}'", EventName = "CacheHit")] + public static partial void CacheHit(ILogger logger, string cacheKey); + + [LoggerMessage(3, LogLevel.Debug, "Streaming image for key '{CacheKey}'", EventName = "StreamStart")] + public static partial void StreamStart(ILogger logger, string cacheKey); + + [LoggerMessage(4, LogLevel.Debug, "Image load succeeded for key '{CacheKey}'", EventName = "LoadSuccess")] + public static partial void LoadSuccess(ILogger logger, string cacheKey); + + [LoggerMessage(5, LogLevel.Debug, "Image load failed for key '{CacheKey}'", EventName = "LoadFailed")] + public static partial void LoadFailed(ILogger logger, string cacheKey, Exception exception); + + [LoggerMessage(6, LogLevel.Debug, "Revoked image URL on dispose", EventName = "RevokedUrl")] + public static partial void RevokedUrl(ILogger logger); + } +} diff --git a/src/Components/Web/src/Image/ImageSource.cs b/src/Components/Web/src/Image/ImageSource.cs new file mode 100644 index 000000000000..d5848494374f --- /dev/null +++ b/src/Components/Web/src/Image/ImageSource.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Components.Web.Image; + +/// +/// Represents a single-use source for image data. An corresponds to +/// exactly one image load. It holds a single underlying that will be +/// consumed by the image component. Reuse of an instance for multiple components or multiple +/// loads is not supported. +/// +public class ImageSource +{ + /// + /// Gets the MIME type of the image. + /// + public string MimeType { get; } + + /// + /// Gets the cache key for the image. Always non-null. + /// + public string CacheKey { get; } + + /// + /// Gets the underlying stream. + /// + public Stream Stream { get; } + + /// + /// Gets the length of the image data in bytes if known. + /// + public long? Length { get; } + + /// + /// Initializes a new instance of with byte array data. + /// A non-writable is created over the provided data. The byte + /// array reference is not copied, so callers should not mutate it afterwards. + /// + public ImageSource(byte[] data, string mimeType, string cacheKey) + { + ArgumentNullException.ThrowIfNull(data); + ArgumentNullException.ThrowIfNull(mimeType); + ArgumentNullException.ThrowIfNull(cacheKey); + + MimeType = mimeType; + CacheKey = cacheKey; + Stream = new MemoryStream(data, writable: false); + Length = data.LongLength; + } + + /// + /// Initializes a new instance of from an existing stream. + /// The stream reference is retained (not copied). The caller retains ownership and is + /// responsible for disposal after the image has loaded. The stream must remain readable + /// for the duration of the load. + /// + /// The readable stream positioned at the beginning. + /// The image MIME type. + /// The cache key. + public ImageSource(Stream stream, string mimeType, string cacheKey) + { + ArgumentNullException.ThrowIfNull(stream); + ArgumentNullException.ThrowIfNull(mimeType); + ArgumentNullException.ThrowIfNull(cacheKey); + + Stream = stream; + MimeType = mimeType; + CacheKey = cacheKey; + if (stream.CanSeek) + { + Length = stream.Length; + } + else + { + Length = null; + } + } +} diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 5b85eaf45fdc..b7195556de51 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -3,7 +3,21 @@ Microsoft.AspNetCore.Components.Forms.InputHidden Microsoft.AspNetCore.Components.Forms.InputHidden.Element.get -> Microsoft.AspNetCore.Components.ElementReference? Microsoft.AspNetCore.Components.Forms.InputHidden.Element.set -> void Microsoft.AspNetCore.Components.Forms.InputHidden.InputHidden() -> void +Microsoft.AspNetCore.Components.Web.Image.Image +Microsoft.AspNetCore.Components.Web.Image.Image.AdditionalAttributes.get -> System.Collections.Generic.Dictionary? +Microsoft.AspNetCore.Components.Web.Image.Image.AdditionalAttributes.set -> void +Microsoft.AspNetCore.Components.Web.Image.Image.DisposeAsync() -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.Components.Web.Image.Image.Image() -> void +Microsoft.AspNetCore.Components.Web.Image.Image.Source.get -> Microsoft.AspNetCore.Components.Web.Image.ImageSource? +Microsoft.AspNetCore.Components.Web.Image.Image.Source.set -> void +Microsoft.AspNetCore.Components.Web.Image.ImageSource +Microsoft.AspNetCore.Components.Web.Image.ImageSource.CacheKey.get -> string! +Microsoft.AspNetCore.Components.Web.Image.ImageSource.ImageSource(byte[]! data, string! mimeType, string! cacheKey) -> void +Microsoft.AspNetCore.Components.Web.Image.ImageSource.ImageSource(System.IO.Stream! stream, string! mimeType, string! cacheKey) -> void +Microsoft.AspNetCore.Components.Web.Image.ImageSource.Length.get -> long? +Microsoft.AspNetCore.Components.Web.Image.ImageSource.MimeType.get -> string! +Microsoft.AspNetCore.Components.Web.Image.ImageSource.Stream.get -> System.IO.Stream! Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(in Microsoft.JSInterop.Infrastructure.JSInvocationInfo invocationInfo) -> string! override Microsoft.AspNetCore.Components.Forms.InputHidden.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder) -> void override Microsoft.AspNetCore.Components.Forms.InputHidden.TryParseValueFromString(string? value, out string? result, out string? validationErrorMessage) -> bool -virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool \ No newline at end of file +virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool diff --git a/src/Components/Web/test/Image/ImageTest.cs b/src/Components/Web/test/Image/ImageTest.cs new file mode 100644 index 000000000000..69bab977f8f1 --- /dev/null +++ b/src/Components/Web/test/Image/ImageTest.cs @@ -0,0 +1,254 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using System.Linq; +using System.Text.Json; +using System.IO; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; +using Xunit; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components.Web.Image.Tests; + +/// +/// Unit tests for the Image component +/// +public class ImageTest +{ + private static readonly byte[] PngBytes = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjqK6u/g8ABVcCcYoGhmwAAAAASUVORK5CYII="); + + [Fact] + public async Task LoadsImage_InvokesSetImageAsync_WhenSourceProvided() + { + var js = new FakeImageJsRuntime(cacheHit: false); + using var renderer = CreateRenderer(js); + var comp = (Image)renderer.InstantiateComponent(); + var id = renderer.AssignRootComponentId(comp); + + var source = new ImageSource(PngBytes, "image/png", cacheKey: "png-1"); + await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary + { + [nameof(Image.Source)] = source, + })); + + Assert.Equal(1, js.Count("Blazor._internal.BinaryImageComponent.setImageAsync")); + } + + [Fact] + public async Task SkipsReload_OnSameCacheKey() + { + var js = new FakeImageJsRuntime(cacheHit: false); + using var renderer = CreateRenderer(js); + var comp = (Image)renderer.InstantiateComponent(); + var id = renderer.AssignRootComponentId(comp); + + var s1 = new ImageSource(new byte[10], "image/png", cacheKey: "same"); + await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary + { + [nameof(Image.Source)] = s1, + })); + + var s2 = new ImageSource(new byte[20], "image/png", cacheKey: "same"); + await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary + { + [nameof(Image.Source)] = s2, + })); + + // Implementation skips reloading when cache key unchanged. + Assert.Equal(1, js.Count("Blazor._internal.BinaryImageComponent.setImageAsync")); + } + + [Fact] + public async Task NullSource_Throws() + { + var js = new FakeImageJsRuntime(cacheHit: false); + using var renderer = CreateRenderer(js); + var comp = (Image)renderer.InstantiateComponent(); + var id = renderer.AssignRootComponentId(comp); + + await Assert.ThrowsAsync(async () => + { + await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary + { + [nameof(Image.Source)] = null, + })); + }); + + // Ensure no JS interop calls were made + Assert.Equal(0, js.TotalInvocationCount); + } + + [Fact] + public async Task ParameterChange_DifferentCacheKey_Reloads() + { + var js = new FakeImageJsRuntime(cacheHit: false); + using var renderer = CreateRenderer(js); + var comp = (Image)renderer.InstantiateComponent(); + var id = renderer.AssignRootComponentId(comp); + var s1 = new ImageSource(new byte[4], "image/png", "key-a"); + await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary + { + [nameof(Image.Source)] = s1, + })); + var s2 = new ImageSource(new byte[6], "image/png", "key-b"); + await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary + { + [nameof(Image.Source)] = s2, + })); + Assert.Equal(2, js.Count("Blazor._internal.BinaryImageComponent.setImageAsync")); + } + + [Fact] + public async Task ChangingSource_CancelsPreviousLoad() + { + var js = new FakeImageJsRuntime(cacheHit: false) { DelayOnFirstSetCall = true }; + using var renderer = CreateRenderer(js); + var comp = (Image)renderer.InstantiateComponent(); + var id = renderer.AssignRootComponentId(comp); + + var s1 = new ImageSource(new byte[10], "image/png", "k1"); + await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary + { + [nameof(Image.Source)] = s1, + })); + + var s2 = new ImageSource(new byte[10], "image/png", "k2"); + await renderer.RenderRootComponentAsync(id, ParameterView.FromDictionary(new Dictionary + { + [nameof(Image.Source)] = s2, + })); + + // Give a tiny bit of time for cancellation to propagate + for (var i = 0; i < 10 && js.CapturedTokens.Count < 2; i++) + { + await Task.Delay(10); + } + + Assert.NotEmpty(js.CapturedTokens); + Assert.True(js.CapturedTokens.First().IsCancellationRequested); + + // Two invocations total (first canceled, second completes) + Assert.Equal(2, js.Count("Blazor._internal.BinaryImageComponent.setImageAsync")); + } + + private static TestRenderer CreateRenderer(IJSRuntime js) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(js); + return new InteractiveTestRenderer(services.BuildServiceProvider()); + } + + private sealed class InteractiveTestRenderer : TestRenderer + { + public InteractiveTestRenderer(IServiceProvider serviceProvider) : base(serviceProvider) { } + protected internal override RendererInfo RendererInfo => new RendererInfo("Test", isInteractive: true); + } + + private sealed class FakeImageJsRuntime : IJSRuntime + { + public sealed record Invocation(string Identifier, object?[] Args, CancellationToken Token); + private readonly ConcurrentQueue _invocations = new(); + private readonly ConcurrentDictionary _memoryCache = new(); + private readonly bool _forceCacheHit; + + public FakeImageJsRuntime(bool cacheHit) { _forceCacheHit = cacheHit; } + + public int TotalInvocationCount => _invocations.Count; + public int Count(string id) => _invocations.Count(i => i.Identifier == id); + public IReadOnlyList CapturedTokens => _invocations.Select(i => i.Token).ToList(); + public void MarkCached(string cacheKey) => _memoryCache[cacheKey] = true; + + // Simulation flags + public bool DelayOnFirstSetCall { get; set; } + public bool ForceFail { get; set; } + public bool FailOnce { get; set; } = true; + public bool FailIfTotalBytesIsZero { get; set; } + private bool _failUsed; + private int _setCalls; + + public ValueTask InvokeAsync(string identifier, object?[]? args) + => InvokeAsync(identifier, CancellationToken.None, args ?? Array.Empty()); + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) + { + args ??= Array.Empty(); + _invocations.Enqueue(new Invocation(identifier, args, cancellationToken)); + + if (identifier == "Blazor._internal.BinaryImageComponent.setImageAsync") + { + _setCalls++; + var cacheKey = args.Length >= 4 ? args[3] as string : null; + var hasStream = args.Length >= 2 && args[1] != null; + long? totalBytes = null; + if (args.Length >= 5 && args[4] != null) + { + try { totalBytes = Convert.ToInt64(args[4], System.Globalization.CultureInfo.InvariantCulture); } catch { totalBytes = null; } + } + + if (DelayOnFirstSetCall && _setCalls == 1) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cancellationToken.Register(() => tcs.TrySetException(new OperationCanceledException(cancellationToken))); + return new ValueTask(tcs.Task); + } + + var shouldFail = (ForceFail && (!_failUsed || !FailOnce)) + || (FailIfTotalBytesIsZero && (totalBytes.HasValue && totalBytes.Value == 0)); + if (ForceFail) + { + _failUsed = true; + } + + var fromCache = !shouldFail && cacheKey != null && (_forceCacheHit || _memoryCache.ContainsKey(cacheKey)); + if (!fromCache && hasStream && !string.IsNullOrEmpty(cacheKey) && !shouldFail) + { + _memoryCache[cacheKey!] = true; + } + + var t = typeof(TValue); + object? instance = Activator.CreateInstance(t, nonPublic: true); + if (instance is null) + { + return ValueTask.FromResult(default(TValue)!); + } + + var setProp = (string name, object? value) => + { + var p = t.GetProperty(name, System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance); + p?.SetValue(instance, value); + }; + + if (shouldFail) + { + setProp("Success", false); + setProp("FromCache", false); + setProp("ObjectUrl", null); + setProp("Error", "simulated-failure"); + } + else + { + setProp("Success", hasStream || fromCache); + setProp("FromCache", fromCache); + setProp("ObjectUrl", (hasStream || fromCache) && cacheKey != null ? $"blob:{cacheKey}:{Guid.NewGuid()}" : null); + setProp("Error", null); + } + + return ValueTask.FromResult((TValue)instance); + } + + return ValueTask.FromResult(default(TValue)!); + } + } +} diff --git a/src/Components/test/E2ETest/Tests/ImageTest.cs b/src/Components/test/E2ETest/Tests/ImageTest.cs new file mode 100644 index 000000000000..d58746ab9fd4 --- /dev/null +++ b/src/Components/test/E2ETest/Tests/ImageTest.cs @@ -0,0 +1,344 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Globalization; +using BasicTestApp; +using BasicTestApp.ImageTest; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using OpenQA.Selenium.Support.UI; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class ImageTest : ServerTestBase> +{ + public ImageTest( + BrowserFixture browserFixture, + ToggleExecutionModeServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + Navigate(ServerPathBase); + Browser.MountTestComponent(); + } + + private void ClearImageCache() + { + var ok = (bool)((IJavaScriptExecutor)Browser).ExecuteAsyncScript(@" + var done = arguments[0]; + (async () => { + try { + if ('caches' in window) { + await caches.delete('blazor-image-cache'); + } + // Reset memoized cache promise if present + try { + const root = Blazor && Blazor._internal && Blazor._internal.BinaryImageComponent; + if (root && 'cachePromise' in root) { + root.cachePromise = undefined; + } + } catch {} + done(true); + } catch (e) { + done(false); + } + })(); + "); + Assert.True(ok, "Failed to clear image cache"); + } + + [Fact] + public void CanLoadPngImage() + { + Browser.FindElement(By.Id("load-png")).Click(); + + Browser.Equal("PNG basic loaded", () => Browser.FindElement(By.Id("current-status")).Text); + + var imageElement = Browser.FindElement(By.Id("png-basic")); + + Assert.NotNull(imageElement); + + var src = imageElement.GetAttribute("src"); + Assert.True(!string.IsNullOrEmpty(src), "Image src should not be empty"); + Assert.True(src.StartsWith("blob:", StringComparison.Ordinal), $"Expected blob URL, but got: {src}"); + + var marker = imageElement.GetAttribute("data-blazor-image"); + Assert.NotNull(marker); + + var state = imageElement.GetAttribute("data-state"); + + Assert.True(string.IsNullOrEmpty(state), $"Expected data-state to be cleared after load, but found '{state}'"); + } + + [Fact] + public void CanLoadJpgImageFromStream() + { + Browser.FindElement(By.Id("load-jpg-stream")).Click(); + + Browser.Equal("JPG from stream loaded", () => Browser.FindElement(By.Id("current-status")).Text); + + var imageElement = Browser.FindElement(By.Id("jpg-stream")); + Assert.NotNull(imageElement); + + var src = imageElement.GetAttribute("src"); + Assert.True(!string.IsNullOrEmpty(src), "Image src should not be empty"); + Assert.True(src.StartsWith("blob:", StringComparison.Ordinal), $"Expected blob URL, but got: {src}"); + } + + [Fact] + public void CanChangeDynamicImageSource() + { + // First click - initialize with PNG + Browser.FindElement(By.Id("change-source")).Click(); + Browser.Equal("Dynamic source initialized with PNG", () => Browser.FindElement(By.Id("current-status")).Text); + + // Verify the image element exists and has a blob URL + var imageElement = Browser.FindElement(By.Id("dynamic-source")); + Assert.NotNull(imageElement); + + var firstSrc = imageElement.GetAttribute("src"); + Assert.True(!string.IsNullOrEmpty(firstSrc), "Image src should not be empty"); + Assert.True(firstSrc.StartsWith("blob:", StringComparison.Ordinal), $"Expected blob URL, but got: {firstSrc}"); + + // Second click - change to JPG + Browser.FindElement(By.Id("change-source")).Click(); + Browser.Equal("Dynamic source changed to JPG", () => Browser.FindElement(By.Id("current-status")).Text); + + // Verify the image source has changed + var secondSrc = imageElement.GetAttribute("src"); + Assert.True(!string.IsNullOrEmpty(secondSrc), "Image src should not be empty after change"); + Assert.True(secondSrc.StartsWith("blob:", StringComparison.Ordinal), $"Expected blob URL, but got: {secondSrc}"); + Assert.NotEqual(firstSrc, secondSrc); + + // Third click - change back to PNG + Browser.FindElement(By.Id("change-source")).Click(); + Browser.Equal("Dynamic source changed to PNG", () => Browser.FindElement(By.Id("current-status")).Text); + + // Verify the image source has changed again + var thirdSrc = imageElement.GetAttribute("src"); + Assert.True(!string.IsNullOrEmpty(thirdSrc), "Image src should not be empty after second change"); + Assert.True(thirdSrc.StartsWith("blob:", StringComparison.Ordinal), $"Expected blob URL, but got: {thirdSrc}"); + Assert.NotEqual(secondSrc, thirdSrc); + } + + [Fact] + public void ErrorImage_SetsErrorState() + { + Browser.FindElement(By.Id("load-error")).Click(); + Browser.Equal("Error image loaded", () => Browser.FindElement(By.Id("current-status")).Text); + + var errorImg = Browser.FindElement(By.Id("error-image")); + + Browser.Equal("error", () => Browser.FindElement(By.Id("error-image")).GetAttribute("data-state")); + var src = errorImg.GetAttribute("src"); + Assert.True(string.IsNullOrEmpty(src) || !src.StartsWith("blob:", StringComparison.Ordinal)); + } + + [Fact] + public void ImageRenders_WithCorrectDimensions() + { + Browser.FindElement(By.Id("load-png")).Click(); + Browser.Equal("PNG basic loaded", () => Browser.FindElement(By.Id("current-status")).Text); + + var imageElement = Browser.FindElement(By.Id("png-basic")); + + // Wait for actual dimensions to be set + Browser.True(() => + { + var width = imageElement.GetAttribute("naturalWidth"); + return !string.IsNullOrEmpty(width) && int.Parse(width, CultureInfo.InvariantCulture) > 0; + }); + + var naturalWidth = int.Parse(imageElement.GetAttribute("naturalWidth"), CultureInfo.InvariantCulture); + var naturalHeight = int.Parse(imageElement.GetAttribute("naturalHeight"), CultureInfo.InvariantCulture); + + Assert.Equal(1, naturalWidth); + Assert.Equal(1, naturalHeight); + } + + [Fact] + public void Image_CompletesLoad_AfterArtificialDelay() + { + // Patch setImageAsync to introduce a delay before delegating to original + ((IJavaScriptExecutor)Browser).ExecuteScript(@" + (function(){ + const root = Blazor && Blazor._internal && Blazor._internal.BinaryImageComponent; + if (!root) return; + if (!window.__origSetImageAsync) { + window.__origSetImageAsync = root.setImageAsync; + root.setImageAsync = async function(...args){ + await new Promise(r => setTimeout(r, 500)); + return window.__origSetImageAsync.apply(this, args); + }; + } + })();"); + + Browser.FindElement(By.Id("load-png")).Click(); + + var imageElement = Browser.FindElement(By.Id("png-basic")); + Browser.True(() => + { + var src = imageElement.GetAttribute("src"); + return !string.IsNullOrEmpty(src) && src.StartsWith("blob:", StringComparison.Ordinal); + }); + Browser.Equal("PNG basic loaded", () => Browser.FindElement(By.Id("current-status")).Text); + + // Restore + ((IJavaScriptExecutor)Browser).ExecuteScript(@" + (function(){ + const root = Blazor && Blazor._internal && Blazor._internal.BinaryImageComponent; + if (root && window.__origSetImageAsync) { + root.setImageAsync = window.__origSetImageAsync; + delete window.__origSetImageAsync; + } + })();"); + } + + [Fact] + public void ImageCache_PersistsAcrossPageReloads() + { + ClearImageCache(); + + Browser.FindElement(By.Id("load-cached-jpg")).Click(); + Browser.Equal("Cached JPG loaded", () => Browser.FindElement(By.Id("current-status")).Text); + var firstImg = Browser.FindElement(By.Id("cached-jpg")); + Browser.True(() => !string.IsNullOrEmpty(firstImg.GetAttribute("src"))); + var firstSrc = firstImg.GetAttribute("src"); + Assert.StartsWith("blob:", firstSrc, StringComparison.Ordinal); + + Browser.Navigate().Refresh(); + Navigate(ServerPathBase); + Browser.MountTestComponent(); + + // Re‑instrument after refresh so we see cache vs stream on the second load + ((IJavaScriptExecutor)Browser).ExecuteScript(@" + (function(){ + const root = Blazor && Blazor._internal && Blazor._internal.BinaryImageComponent; + if (!root) return; + window.__cacheHits = 0; + window.__streamCalls = 0; + if (!window.__origSetImageAsync){ + window.__origSetImageAsync = root.setImageAsync; + root.setImageAsync = async function(...a){ + const result = await window.__origSetImageAsync.apply(this, a); + if (result && result.fromCache) window.__cacheHits++; + if (result && result.success && !result.fromCache) window.__streamCalls++; + return result; + }; + } + })();"); + + // Second load should hit cache + Browser.FindElement(By.Id("load-cached-jpg")).Click(); + Browser.Equal("Cached JPG loaded", () => Browser.FindElement(By.Id("current-status")).Text); + var secondImg = Browser.FindElement(By.Id("cached-jpg")); + Browser.True(() => !string.IsNullOrEmpty(secondImg.GetAttribute("src"))); + var secondSrc = secondImg.GetAttribute("src"); + Assert.StartsWith("blob:", secondSrc, StringComparison.Ordinal); + + var hits = (long)((IJavaScriptExecutor)Browser).ExecuteScript("return window.__cacheHits || 0;"); + var streamCalls = (long)((IJavaScriptExecutor)Browser).ExecuteScript("return window.__streamCalls || 0;"); + + Assert.Equal(1, hits); + Assert.Equal(0, streamCalls); + Assert.NotEqual(firstSrc, secondSrc); + + // Restore + ((IJavaScriptExecutor)Browser).ExecuteScript(@" + (function(){ + const root = Blazor && Blazor._internal && Blazor._internal.BinaryImageComponent; + if (root && window.__origSetImageAsync){ root.setImageAsync = window.__origSetImageAsync; delete window.__origSetImageAsync; } + delete window.__cacheHits; + delete window.__streamCalls; + })();"); + } + + [Fact] + public void RapidSourceChanges_MaintainsConsistency() + { + // Initialize dynamic image + Browser.FindElement(By.Id("change-source")).Click(); + Browser.Equal("Dynamic source initialized with PNG", () => Browser.FindElement(By.Id("current-status")).Text); + + var imageElement = Browser.FindElement(By.Id("dynamic-source")); + Browser.True(() => !string.IsNullOrEmpty(imageElement.GetAttribute("src"))); + var initialSrc = imageElement.GetAttribute("src"); + + // Simulate user quickly clicking + for (int i = 0; i < 10; i++) + { + Browser.FindElement(By.Id("change-source")).Click(); + } + + Browser.True(() => + { + var status = Browser.FindElement(By.Id("current-status")).Text; + var src = imageElement.GetAttribute("src"); + var state = imageElement.GetAttribute("data-state"); + if (string.IsNullOrEmpty(src) || !src.StartsWith("blob:", StringComparison.Ordinal)) + { + return false; + } + + if (state == "loading" || state == "error") + { + return false; + } + + return status.Contains("Dynamic source changed to PNG") || status.Contains("Dynamic source changed to JPG"); + }); + + var finalSrc = imageElement.GetAttribute("src"); + Assert.False(string.IsNullOrEmpty(finalSrc)); + Assert.StartsWith("blob:", finalSrc, StringComparison.Ordinal); + + Assert.NotEqual(initialSrc, finalSrc); + } + + [Fact] + public void UrlRevoked_WhenImageRemovedFromDom() + { + // Load an image and capture its blob URL + Browser.FindElement(By.Id("load-png")).Click(); + Browser.Equal("PNG basic loaded", () => Browser.FindElement(By.Id("current-status")).Text); + var imageElement = Browser.FindElement(By.Id("png-basic")); + var blobUrl = imageElement.GetAttribute("src"); + Assert.False(string.IsNullOrEmpty(blobUrl)); + Assert.StartsWith("blob:", blobUrl, StringComparison.Ordinal); + + // MutationObserver should revoke the URL + ((IJavaScriptExecutor)Browser).ExecuteScript("document.getElementById('png-basic').remove();"); + + // Poll until fetch fails, indicating the URL has been revoked + Browser.True(() => + { + try + { + var ok = (bool)((IJavaScriptExecutor)Browser).ExecuteAsyncScript(@" + var callback = arguments[arguments.length - 1]; + var url = arguments[0]; + (async () => { + try { + await fetch(url); + callback(false); // still reachable + } catch { + callback(true); // revoked or unreachable + } + })(); + ", blobUrl); + return ok; + } + catch + { + return false; + } + }); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj index d3be5f099dfa..390c68947f1c 100644 --- a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj +++ b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj @@ -13,7 +13,7 @@ true true - + @@ -26,6 +26,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/ImageTest/ImageTestComponent.razor b/src/Components/test/testassets/BasicTestApp/ImageTest/ImageTestComponent.razor new file mode 100644 index 000000000000..97d78c8e5f2a --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/ImageTest/ImageTestComponent.razor @@ -0,0 +1,226 @@ +@using System.IO +@using Microsoft.AspNetCore.Components.Web.Image +@using Microsoft.JSInterop +@inject IJSRuntime JS + +@implements IAsyncDisposable + +

Image Component Tests

+ +
+

Test Controls

+ + + + + + + +
+ Status: @_currentStatus +
+
+ +@if (_pngBasic) +{ +

PNG basic

+ +} + +@if (_jpgStream) +{ +

JPG Stream

+ +} + +@if (_dynamicSource) +{ +

Dynamic Source Image

+ +} + +@if (_pair1Visible) +{ +

Pair Image 1

+ +} +@if (_pair2Visible) +{ +

Pair Image 2

+ +} + +@if (_errorImage) +{ +

Error Image

+ +} + +@if (_cachedJpgVisible) +{ +

Cached JPG

+ +} + +@code { + private Image _pngBasicRef; + private Image _jpgStreamRef; + private Image _dynamicSourceRef; + private Image _pair1Ref; + private Image _pair2Ref; + private Image _errorRef; + private Image _cachedJpgRef; + + private ImageSource _basicImageSource = new ImageSource(new byte[0], "", ""); + private ImageSource _jpgStreamSource = new ImageSource(new byte[0], "", ""); + private ImageSource _dynamicImageSource = new ImageSource(new byte[0], "", ""); + private ImageSource _pairSource = new ImageSource(new byte[0], "", ""); + private ImageSource _errorSource = new ImageSource(new byte[0], "", ""); + private ImageSource _cachedJpgSource = new ImageSource(new byte[0], "", ""); + + private bool _pngBasic = false; + private bool _jpgStream = false; + private bool _dynamicSource = false; + private bool _pair1Visible = false; + private bool _pair2Visible = false; + private bool _errorImage = false; + private bool _cachedJpgVisible = false; + + private string _currentStatus = "Ready"; + private bool _isCurrentlyShowingPng = true; // Track which image is currently shown in dynamic test + + // Test image data - 1x1 gray PNG and JPG byte arrays + private static readonly byte[] TestPngData = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjqK6u/g8ABVcCcYoGhmwAAAAASUVORK5CYII="); + + private static readonly byte[] TestJpgData = Convert.FromBase64String("/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AN//Z"); + + protected override void OnInitialized() + { + _currentStatus = "Component initialized"; + } + + private async Task LoadJpgFromStream() + { + _currentStatus = "Loading JPG from stream..."; + var stream = new MemoryStream(TestJpgData); + _jpgStreamSource = new ImageSource(stream, "image/jpeg", "test-jpg-stream"); + await Task.Delay(10); + _jpgStream = true; + _currentStatus = "JPG from stream loaded"; + + } + + private async Task ChangeDynamicImageSource() + { + if (!_dynamicSource) + { + // First time - show the component with PNG + _currentStatus = "Loading dynamic source (PNG)..."; + _dynamicImageSource = new ImageSource(TestPngData, "image/png", "dynamic-png"); + _dynamicSource = true; + _isCurrentlyShowingPng = true; + await Task.Delay(10); + _currentStatus = "Dynamic source initialized with PNG"; + } + else + { + // Toggle between PNG and JPG + if (_isCurrentlyShowingPng) + { + _currentStatus = "Changing to JPG..."; + _dynamicImageSource = new ImageSource(TestJpgData, "image/jpeg", "dynamic-jpg"); + _isCurrentlyShowingPng = false; + await Task.Delay(10); + _currentStatus = "Dynamic source changed to JPG"; + } + else + { + _currentStatus = "Changing to PNG..."; + _dynamicImageSource = new ImageSource(TestPngData, "image/png", "dynamic-png"); + _isCurrentlyShowingPng = true; + await Task.Delay(10); + _currentStatus = "Dynamic source changed to PNG"; + } + } + } + private async Task LoadPairSequence() + { + _currentStatus = "Loading pair sequence..."; + _pairSource = new ImageSource(TestJpgData, "image/jpeg", "pair-cache"); + _pair1Visible = true; + await Task.Delay(50); + _pair2Visible = true; + await Task.Delay(10); + _currentStatus = "Pair second loaded"; + } + + private async Task ReloadPng() + { + if (_pngBasic) + { + _currentStatus = "Reloading PNG..."; + _pngBasic = false; + await Task.Delay(10); + } + else + { + _currentStatus = "Loading PNG..."; + } + + _basicImageSource = new ImageSource(TestPngData, "image/png", "test-png-basic"); + _pngBasic = true; + await Task.Delay(10); + _currentStatus = "PNG basic loaded"; + } + + private async Task LoadErrorImage() + { + _currentStatus = "Loading error image..."; + // Create a stream then seek to end so component throws when validating position + var ms = new MemoryStream(new byte[] { 1, 2, 3, 4 }); + ms.Seek(ms.Length, SeekOrigin.Begin); + _errorSource = new ImageSource(ms, "image/png", "error-key"); + _errorImage = true; + await Task.Delay(10); + _currentStatus = "Error image loaded"; + } + + private async Task LoadCachedJpg() + { + _currentStatus = "Loading cached JPG..."; + _cachedJpgSource = new ImageSource(TestJpgData, "image/jpeg", "single-cached-jpg"); + _cachedJpgVisible = true; + await Task.Delay(10); + _currentStatus = "Cached JPG loaded"; + } + + public async ValueTask DisposeAsync() + { + _currentStatus = "Disposing component..."; + + if (_dynamicSourceRef != null) + await _dynamicSourceRef.DisposeAsync(); + if (_jpgStreamRef != null) + await _jpgStreamRef.DisposeAsync(); + if (_pngBasicRef != null) + await _pngBasicRef.DisposeAsync(); + if (_pair1Ref != null) + await _pair1Ref.DisposeAsync(); + if (_pair2Ref != null) + await _pair2Ref.DisposeAsync(); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index 6e8d20b391a2..0a993eac0a3a 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -46,6 +46,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/_Imports.razor b/src/Components/test/testassets/BasicTestApp/_Imports.razor index a71616835c7b..4568ae32abaf 100644 --- a/src/Components/test/testassets/BasicTestApp/_Imports.razor +++ b/src/Components/test/testassets/BasicTestApp/_Imports.razor @@ -1,2 +1,3 @@ @using Microsoft.AspNetCore.Components.Web -@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.Web.Image