diff --git a/src/Components/Endpoints/src/Assets/LinkPreload.cs b/src/Components/Endpoints/src/Assets/LinkPreload.cs new file mode 100644 index 000000000000..e60c0140e53e --- /dev/null +++ b/src/Components/Endpoints/src/Assets/LinkPreload.cs @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.Endpoints; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components; + +/// +/// Represents link elements for preloading assets. +/// +public sealed class LinkPreload : IComponent +{ + private RenderHandle renderHandle; + private List? assets; + + [Inject] + internal ResourcePreloadService? Service { get; set; } + + void IComponent.Attach(RenderHandle renderHandle) + { + this.renderHandle = renderHandle; + } + + Task IComponent.SetParametersAsync(ParameterView parameters) + { + Service?.SetPreloadingHandler(PreloadAssets); + renderHandle.Render(RenderPreloadAssets); + return Task.CompletedTask; + } + + private void PreloadAssets(List assets) + { + if (this.assets != null) + { + return; + } + + this.assets = assets; + renderHandle.Render(RenderPreloadAssets); + } + + private void RenderPreloadAssets(RenderTreeBuilder builder) + { + if (assets == null) + { + return; + } + + for (var i = 0; i < assets.Count; i ++) + { + var asset = assets[i]; + builder.OpenElement(0, "link"); + builder.SetKey(assets[i]); + builder.AddAttribute(1, "href", asset.Url); + builder.AddAttribute(2, "rel", asset.PreloadRel); + if (!string.IsNullOrEmpty(asset.PreloadAs)) + { + builder.AddAttribute(3, "as", asset.PreloadAs); + } + if (!string.IsNullOrEmpty(asset.PreloadPriority)) + { + builder.AddAttribute(4, "fetchpriority", asset.PreloadPriority); + } + if (!string.IsNullOrEmpty(asset.PreloadCrossorigin)) + { + builder.AddAttribute(5, "crossorigin", asset.PreloadCrossorigin); + } + if (!string.IsNullOrEmpty(asset.Integrity)) + { + builder.AddAttribute(6, "integrity", asset.Integrity); + } + builder.CloseElement(); + } + } +} diff --git a/src/Components/Endpoints/src/Builder/ResourcePreloadCollection.cs b/src/Components/Endpoints/src/Builder/ResourcePreloadCollection.cs index 083deac91a3d..869682856251 100644 --- a/src/Components/Endpoints/src/Builder/ResourcePreloadCollection.cs +++ b/src/Components/Endpoints/src/Builder/ResourcePreloadCollection.cs @@ -1,20 +1,16 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Linq; -using System.Text; -using Microsoft.Extensions.Primitives; +using System.Diagnostics.CodeAnalysis; namespace Microsoft.AspNetCore.Components.Endpoints; internal class ResourcePreloadCollection { - private readonly Dictionary _storage = new(); + private readonly Dictionary> _storage = new(); public ResourcePreloadCollection(ResourceAssetCollection assets) { - var headerBuilder = new StringBuilder(); - var headers = new Dictionary>(); foreach (var asset in assets) { if (asset.Properties == null) @@ -38,63 +34,81 @@ public ResourcePreloadCollection(ResourceAssetCollection assets) continue; } - var header = CreateHeader(headerBuilder, asset.Url, asset.Properties); - if (!headers.TryGetValue(group, out var groupHeaders)) + var preloadAsset = CreateAsset(asset.Url, asset.Properties); + if (!_storage.TryGetValue(group, out var groupHeaders)) { - groupHeaders = headers[group] = new List<(int Order, string Value)>(); + groupHeaders = _storage[group] = new List(); } - groupHeaders.Add(header); + groupHeaders.Add(preloadAsset); } - foreach (var group in headers) + foreach (var group in _storage) { - _storage[group.Key ?? string.Empty] = group.Value.OrderBy(h => h.Order).Select(h => h.Value).ToArray(); + group.Value.Sort((a, b) => a.PreloadOrder.CompareTo(b.PreloadOrder)); } } - private static (int order, string header) CreateHeader(StringBuilder headerBuilder, string url, IEnumerable properties) + private static PreloadAsset CreateAsset(string url, IEnumerable properties) { - headerBuilder.Clear(); - headerBuilder.Append('<'); - headerBuilder.Append(url); - headerBuilder.Append('>'); - - int order = 0; + var resourceAsset = new PreloadAsset(url); foreach (var property in properties) { - if (property.Name.Equals("preloadrel", StringComparison.OrdinalIgnoreCase)) + if (property.Name.Equals("label", StringComparison.OrdinalIgnoreCase)) + { + resourceAsset.Label = property.Value; + } + else if (property.Name.Equals("integrity", StringComparison.OrdinalIgnoreCase)) + { + resourceAsset.Integrity = property.Value; + } + else if (property.Name.Equals("preloadgroup", StringComparison.OrdinalIgnoreCase)) + { + resourceAsset.PreloadGroup = property.Value; + } + else if (property.Name.Equals("preloadrel", StringComparison.OrdinalIgnoreCase)) { - headerBuilder.Append("; rel=").Append(property.Value); + resourceAsset.PreloadRel = property.Value; } else if (property.Name.Equals("preloadas", StringComparison.OrdinalIgnoreCase)) { - headerBuilder.Append("; as=").Append(property.Value); + resourceAsset.PreloadAs = property.Value; } else if (property.Name.Equals("preloadpriority", StringComparison.OrdinalIgnoreCase)) { - headerBuilder.Append("; fetchpriority=").Append(property.Value); + resourceAsset.PreloadPriority = property.Value; } else if (property.Name.Equals("preloadcrossorigin", StringComparison.OrdinalIgnoreCase)) { - headerBuilder.Append("; crossorigin=").Append(property.Value); - } - else if (property.Name.Equals("integrity", StringComparison.OrdinalIgnoreCase)) - { - headerBuilder.Append("; integrity=\"").Append(property.Value).Append('"'); + resourceAsset.PreloadCrossorigin = property.Value; } else if (property.Name.Equals("preloadorder", StringComparison.OrdinalIgnoreCase)) { - if (!int.TryParse(property.Value, out order)) + if (!int.TryParse(property.Value, out int order)) { order = 0; } + + resourceAsset.PreloadOrder = order; } } - return (order, headerBuilder.ToString()); + return resourceAsset; } - public bool TryGetLinkHeaders(string group, out StringValues linkHeaders) - => _storage.TryGetValue(group, out linkHeaders); + public bool TryGetAssets(string group, [MaybeNullWhen(false)] out List assets) + => _storage.TryGetValue(group, out assets); +} + +internal sealed class PreloadAsset(string url) +{ + public string Url { get; } = url; + public string? Label { get; set; } + public string? Integrity { get; set; } + public string? PreloadGroup { get; set; } + public string? PreloadRel { get; set; } + public string? PreloadAs { get; set; } + public string? PreloadPriority { get; set; } + public string? PreloadCrossorigin { get; set; } + public int PreloadOrder { get; set; } } diff --git a/src/Components/Endpoints/src/Builder/ResourcePreloadService.cs b/src/Components/Endpoints/src/Builder/ResourcePreloadService.cs new file mode 100644 index 000000000000..a5f073f409a9 --- /dev/null +++ b/src/Components/Endpoints/src/Builder/ResourcePreloadService.cs @@ -0,0 +1,15 @@ +// 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.Endpoints; + +internal class ResourcePreloadService +{ + private Action>? handler; + + public void SetPreloadingHandler(Action> handler) + => this.handler = handler; + + public void Preload(List assets) + => this.handler?.Invoke(assets); +} diff --git a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs index 219b2e33b9dc..dc365194fcbe 100644 --- a/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs +++ b/src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs @@ -73,6 +73,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection services.AddSupplyValueFromPersistentComponentStateProvider(); services.TryAddCascadingValue(sp => sp.GetRequiredService().HttpContext); services.TryAddScoped(); + services.TryAddScoped(); services.TryAddScoped(); RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(services, RenderMode.InteractiveWebAssembly); diff --git a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt index c84c2fdd7ae5..f58ed00616a4 100644 --- a/src/Components/Endpoints/src/PublicAPI.Unshipped.txt +++ b/src/Components/Endpoints/src/PublicAPI.Unshipped.txt @@ -1,3 +1,5 @@ #nullable enable +Microsoft.AspNetCore.Components.LinkPreload +Microsoft.AspNetCore.Components.LinkPreload.LinkPreload() -> void Microsoft.Extensions.DependencyInjection.RazorComponentsRazorComponentBuilderExtensions static Microsoft.Extensions.DependencyInjection.RazorComponentsRazorComponentBuilderExtensions.RegisterPersistentService(this Microsoft.Extensions.DependencyInjection.IRazorComponentsBuilder! builder, Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> Microsoft.Extensions.DependencyInjection.IRazorComponentsBuilder! diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs index c17f7cd53555..9e5dd6f8b643 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs @@ -12,7 +12,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -278,12 +277,6 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo { if (_httpContext.RequestServices.GetRequiredService().TryGetSettingsOnce(out var settings)) { - if (marker.Type is ComponentMarker.WebAssemblyMarkerType) - { - // Preload WebAssembly assets when using WebAssembly (not Auto) mode - AppendWebAssemblyPreloadHeaders(); - } - var settingsJson = JsonSerializer.Serialize(settings, ServerComponentSerializationSettings.JsonSerializationOptions); output.Write($""); } @@ -320,15 +313,6 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo } } - private void AppendWebAssemblyPreloadHeaders() - { - var preloads = _httpContext.GetEndpoint()?.Metadata.GetMetadata(); - if (preloads != null && preloads.TryGetLinkHeaders("webassembly", out var linkHeaders)) - { - _httpContext.Response.Headers.Link = StringValues.Concat(_httpContext.Response.Headers.Link, linkHeaders); - } - } - private static bool IsProgressivelyEnhancedNavigation(HttpRequest request) { // For enhanced nav, the Blazor JS code controls the "accept" header precisely, so we can be very specific about the format diff --git a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs index e3d498d139bd..a8bc7bd7e74d 100644 --- a/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs +++ b/src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs @@ -28,6 +28,7 @@ internal class SSRRenderModeBoundary : IComponent private RenderHandle _renderHandle; private IReadOnlyDictionary? _latestParameters; private ComponentMarkerKey? _markerKey; + private readonly HttpContext _httpContext; public IComponentRenderMode RenderMode { get; } @@ -38,6 +39,7 @@ public SSRRenderModeBoundary( { AssertRenderModeIsConfigured(httpContext, componentType, renderMode); + _httpContext = httpContext; _componentType = componentType; RenderMode = renderMode; _prerender = renderMode switch @@ -106,6 +108,12 @@ public Task SetParametersAsync(ParameterView parameters) ValidateParameters(_latestParameters); + if (RenderMode is InteractiveWebAssemblyRenderMode) + { + // Preload WebAssembly assets when using WebAssembly (not Auto) mode + PreloadWebAssemblyAssets(); + } + if (_prerender) { _renderHandle.Render(Prerender); @@ -114,6 +122,16 @@ public Task SetParametersAsync(ParameterView parameters) return Task.CompletedTask; } + private void PreloadWebAssemblyAssets() + { + var preloads = _httpContext.GetEndpoint()?.Metadata.GetMetadata(); + if (preloads != null && preloads.TryGetAssets("webassembly", out var preloadAssets)) + { + var service = _httpContext.RequestServices.GetRequiredService(); + service.Preload(preloadAssets); + } + } + private void ValidateParameters(IReadOnlyDictionary latestParameters) { foreach (var (name, value) in latestParameters) diff --git a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs index ac787b76bfe5..febc15871d01 100644 --- a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs +++ b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs @@ -142,25 +142,19 @@ public async Task CanPreload_WebAssembly_ResourceAssets() ); // Act - var result = await renderer.PrerenderComponentAsync(httpContext, typeof(SimpleComponent), new InteractiveWebAssemblyRenderMode(prerender: false), ParameterView.Empty); + var result = await renderer.PrerenderComponentAsync(httpContext, typeof(WebAssemblyPreloadWrapper), null, ParameterView.Empty); await renderer.Dispatcher.InvokeAsync(() => result.WriteTo(writer, HtmlEncoder.Default)); // Assert - Assert.Equal(2, httpContext.Response.Headers.Link.Count); - - var firstPreloadLink = httpContext.Response.Headers.Link[0]; - Assert.Contains("", firstPreloadLink); - Assert.Contains("rel=preload", firstPreloadLink); - Assert.Contains("as=script", firstPreloadLink); - Assert.Contains("fetchpriority=high", firstPreloadLink); - Assert.Contains("integrity=\"abcd\"", firstPreloadLink); + var output = writer.ToString(); - var secondPreloadLink = httpContext.Response.Headers.Link[1]; - Assert.Contains("", secondPreloadLink); - Assert.Contains("rel=preload", secondPreloadLink); - Assert.Contains("as=script", secondPreloadLink); - Assert.Contains("fetchpriority=high", secondPreloadLink); - Assert.Contains("integrity=\"abcd\"", secondPreloadLink); + Assert.Contains("href=\"first.js\"", output); + Assert.Contains("href=\"second.js\"", output); + Assert.DoesNotContain("nopreload.js", output); + Assert.Contains("rel=\"preload\"", output); + Assert.Contains("as=\"script\"", output); + Assert.Contains("fetchpriority=\"high\"", output); + Assert.Contains("integrity=\"abcd\"", output); } [Fact] @@ -1835,6 +1829,7 @@ private static ServiceCollection CreateDefaultServiceCollection() services.AddSingleton(); services.AddSingleton(_ => new SupplyParameterFromFormValueProvider(null, "")); services.AddScoped(); + services.AddScoped(); services.AddSingleton(new WebAssemblySettingsEmitter(new TestEnvironment(Environments.Development))); return services; } diff --git a/src/Components/Endpoints/test/TestComponents/WebAssemblyPreloadComponent.razor b/src/Components/Endpoints/test/TestComponents/WebAssemblyPreloadComponent.razor new file mode 100644 index 000000000000..d2e9b8acd99c --- /dev/null +++ b/src/Components/Endpoints/test/TestComponents/WebAssemblyPreloadComponent.razor @@ -0,0 +1 @@ +

WebAssemblyPreloadComponent

diff --git a/src/Components/Endpoints/test/TestComponents/WebAssemblyPreloadWrapper.razor b/src/Components/Endpoints/test/TestComponents/WebAssemblyPreloadWrapper.razor new file mode 100644 index 000000000000..d4224a9ae8ad --- /dev/null +++ b/src/Components/Endpoints/test/TestComponents/WebAssemblyPreloadWrapper.razor @@ -0,0 +1,4 @@ +@using Microsoft.AspNetCore.Components.Web + + + diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor index 3f3531f29052..4238ebdc95e9 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor @@ -5,6 +5,7 @@ + @*#if (SampleContent) ##endif*@