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*@