diff --git a/src/Components/Endpoints/src/Builder/ResourceCollectionConvention.cs b/src/Components/Endpoints/src/Builder/ResourceCollectionConvention.cs index 2415687031ec..7606ff07abd7 100644 --- a/src/Components/Endpoints/src/Builder/ResourceCollectionConvention.cs +++ b/src/Components/Endpoints/src/Builder/ResourceCollectionConvention.cs @@ -49,6 +49,7 @@ public void ApplyConvention(EndpointBuilder eb) if (_collection != null && _collectionImportMap != null) { eb.Metadata.Add(_collection); + eb.Metadata.Add(new ResourcePreloadCollection(_collection)); if (_collectionUrl != null) { diff --git a/src/Components/Endpoints/src/Builder/ResourcePreloadCollection.cs b/src/Components/Endpoints/src/Builder/ResourcePreloadCollection.cs new file mode 100644 index 000000000000..083deac91a3d --- /dev/null +++ b/src/Components/Endpoints/src/Builder/ResourcePreloadCollection.cs @@ -0,0 +1,100 @@ +// 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; + +namespace Microsoft.AspNetCore.Components.Endpoints; + +internal class ResourcePreloadCollection +{ + 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) + { + continue; + } + + // Use preloadgroup property to identify assets that should be preloaded + string? group = null; + foreach (var property in asset.Properties) + { + if (property.Name.Equals("preloadgroup", StringComparison.OrdinalIgnoreCase)) + { + group = property.Value ?? string.Empty; + break; + } + } + + if (group == null) + { + continue; + } + + var header = CreateHeader(headerBuilder, asset.Url, asset.Properties); + if (!headers.TryGetValue(group, out var groupHeaders)) + { + groupHeaders = headers[group] = new List<(int Order, string Value)>(); + } + + groupHeaders.Add(header); + } + + foreach (var group in headers) + { + _storage[group.Key ?? string.Empty] = group.Value.OrderBy(h => h.Order).Select(h => h.Value).ToArray(); + } + } + + private static (int order, string header) CreateHeader(StringBuilder headerBuilder, string url, IEnumerable properties) + { + headerBuilder.Clear(); + headerBuilder.Append('<'); + headerBuilder.Append(url); + headerBuilder.Append('>'); + + int order = 0; + foreach (var property in properties) + { + if (property.Name.Equals("preloadrel", StringComparison.OrdinalIgnoreCase)) + { + headerBuilder.Append("; rel=").Append(property.Value); + } + else if (property.Name.Equals("preloadas", StringComparison.OrdinalIgnoreCase)) + { + headerBuilder.Append("; as=").Append(property.Value); + } + else if (property.Name.Equals("preloadpriority", StringComparison.OrdinalIgnoreCase)) + { + headerBuilder.Append("; fetchpriority=").Append(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('"'); + } + else if (property.Name.Equals("preloadorder", StringComparison.OrdinalIgnoreCase)) + { + if (!int.TryParse(property.Value, out order)) + { + order = 0; + } + } + } + + return (order, headerBuilder.ToString()); + } + + public bool TryGetLinkHeaders(string group, out StringValues linkHeaders) + => _storage.TryGetValue(group, out linkHeaders); +} diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs index be1b910c6f6c..79239c6d6db2 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Components.Endpoints; @@ -275,6 +276,12 @@ 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($""); } @@ -311,6 +318,15 @@ 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/test/EndpointHtmlRendererTest.cs b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs index f77d6f38ac5c..e54298c42208 100644 --- a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs +++ b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Net.Http; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.RegularExpressions; @@ -71,6 +70,78 @@ public async Task CanRender_ParameterlessComponent_ClientMode() Assert.Empty(httpContext.Items); } + [Fact] + public async Task CanPreload_WebAssembly_ResourceAssets() + { + // Arrange + var httpContext = GetHttpContext(); + var writer = new StringWriter(); + + httpContext.SetEndpoint( + new Endpoint( + ctx => Task.CompletedTask, + new EndpointMetadataCollection([ + new ResourcePreloadCollection( + new ResourceAssetCollection([ + new ResourceAsset("second.js", [ + new ResourceAssetProperty("preloadrel", "preload"), + new ResourceAssetProperty("preloadas", "script"), + new ResourceAssetProperty("preloadpriority", "high"), + new ResourceAssetProperty("preloadcrossorigin", "anonymous"), + new ResourceAssetProperty("integrity", "abcd"), + new ResourceAssetProperty("preloadorder", "2"), + new ResourceAssetProperty("preloadgroup", "webassembly") + ]), + new ResourceAsset("first.js", [ + new ResourceAssetProperty("preloadrel", "preload"), + new ResourceAssetProperty("preloadas", "script"), + new ResourceAssetProperty("preloadpriority", "high"), + new ResourceAssetProperty("preloadcrossorigin", "anonymous"), + new ResourceAssetProperty("integrity", "abcd"), + new ResourceAssetProperty("preloadorder", "1"), + new ResourceAssetProperty("preloadgroup", "webassembly") + ]), + new ResourceAsset("preload-nowebassembly.js", [ + new ResourceAssetProperty("preloadrel", "preload"), + new ResourceAssetProperty("preloadas", "script"), + new ResourceAssetProperty("preloadpriority", "high"), + new ResourceAssetProperty("preloadcrossorigin", "anonymous"), + new ResourceAssetProperty("integrity", "abcd"), + new ResourceAssetProperty("preloadorder", "1"), + new ResourceAssetProperty("preloadgroup", "abcd") + ]), + new ResourceAsset("nopreload.js", [ + new ResourceAssetProperty("integrity", "abcd") + ]) + ]) + ) + ]), + "TestEndpoint" + ) + ); + + // Act + var result = await renderer.PrerenderComponentAsync(httpContext, typeof(SimpleComponent), new InteractiveWebAssemblyRenderMode(prerender: false), 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 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); + } + [Fact] public async Task CanPrerender_ParameterlessComponent_ClientMode() { diff --git a/src/Shared/Components/ResourceCollectionResolver.cs b/src/Shared/Components/ResourceCollectionResolver.cs index a0b39aa8208e..f5a56cbdb20a 100644 --- a/src/Shared/Components/ResourceCollectionResolver.cs +++ b/src/Shared/Components/ResourceCollectionResolver.cs @@ -42,9 +42,21 @@ public ResourceAssetCollection ResolveResourceCollection() #if !MVC_VIEWFEATURES string? label = null; string? integrity = null; + string? preloadRel = null; + string? preloadAs = null; + string? preloadPriority = null; + string? preloadCrossorigin = null; + string? preloadOrder = null; + string? preloadGroup = null; #else string label = null; string integrity = null; + string preloadRel = null; + string preloadAs = null; + string preloadPriority = null; + string preloadCrossorigin = null; + string preloadOrder = null; + string preloadGroup = null; #endif // If there's a selector this means that this is an alternative representation for a resource, so skip it. @@ -59,15 +71,44 @@ public ResourceAssetCollection ResolveResourceCollection() label = property.Value; foundProperties++; } - else if (property.Name.Equals("integrity", StringComparison.OrdinalIgnoreCase)) { integrity = property.Value; foundProperties++; } + else if (property.Name.Equals("preloadrel", StringComparison.OrdinalIgnoreCase)) + { + preloadRel = property.Value; + foundProperties++; + } + else if (property.Name.Equals("preloadas", StringComparison.OrdinalIgnoreCase)) + { + preloadAs = property.Value; + foundProperties++; + } + else if (property.Name.Equals("preloadpriority", StringComparison.OrdinalIgnoreCase)) + { + preloadPriority = property.Value; + foundProperties++; + } + else if (property.Name.Equals("preloadcrossorigin", StringComparison.OrdinalIgnoreCase)) + { + preloadCrossorigin = property.Value; + foundProperties++; + } + else if (property.Name.Equals("preloadorder", StringComparison.OrdinalIgnoreCase)) + { + preloadOrder = property.Value; + foundProperties++; + } + else if (property.Name.Equals("preloadgroup", StringComparison.OrdinalIgnoreCase)) + { + preloadGroup = property.Value; + foundProperties++; + } } - AddResource(resources, descriptor, label, integrity, foundProperties); + AddResource(resources, descriptor, label, integrity, preloadRel, preloadAs, preloadPriority, preloadCrossorigin, preloadOrder, preloadGroup, foundProperties); } } @@ -97,11 +138,23 @@ private static void AddResource( #if !MVC_VIEWFEATURES string? label, string? integrity, + string? preloadRel, + string? preloadAs, + string? preloadPriority, + string? preloadCrossorigin, + string? preloadOrder, + string? preloadGroup, #else string label, string integrity, + string preloadRel, + string preloadAs, + string preloadPriority, + string preloadCrossorigin, + string preloadOrder, + string preloadGroup, #endif - int foundProperties) + int foundProperties) { if (label != null || integrity != null) { @@ -115,6 +168,30 @@ private static void AddResource( { properties[index++] = new ResourceAssetProperty("integrity", integrity); } + if (preloadRel != null) + { + properties[index++] = new ResourceAssetProperty("preloadrel", preloadRel); + } + if (preloadAs != null) + { + properties[index++] = new ResourceAssetProperty("preloadas", preloadAs); + } + if (preloadPriority != null) + { + properties[index++] = new ResourceAssetProperty("preloadpriority", preloadPriority); + } + if (preloadCrossorigin != null) + { + properties[index++] = new ResourceAssetProperty("preloadcrossorigin", preloadCrossorigin); + } + if (preloadOrder != null) + { + properties[index++] = new ResourceAssetProperty("preloadorder", preloadOrder); + } + if (preloadGroup != null) + { + properties[index++] = new ResourceAssetProperty("preloadgroup", preloadGroup); + } resources.Add(new ResourceAsset(descriptor.Route, properties)); }