Skip to content

[Blazor] Use <LinkPreload /> component to preload assets #62225

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/Components/Endpoints/src/Assets/LinkPreload.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents link elements for preloading assets.
/// </summary>
public sealed class LinkPreload : IComponent
{
private RenderHandle renderHandle;
private List<PreloadAsset>? 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<PreloadAsset> 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();
}
}
}
78 changes: 46 additions & 32 deletions src/Components/Endpoints/src/Builder/ResourcePreloadCollection.cs
Original file line number Diff line number Diff line change
@@ -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<string, StringValues> _storage = new();
private readonly Dictionary<string, List<PreloadAsset>> _storage = new();

public ResourcePreloadCollection(ResourceAssetCollection assets)
{
var headerBuilder = new StringBuilder();
var headers = new Dictionary<string, List<(int Order, string Value)>>();
foreach (var asset in assets)
{
if (asset.Properties == null)
Expand All @@ -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<PreloadAsset>();
}

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<ResourceAssetProperty> properties)
private static PreloadAsset CreateAsset(string url, IEnumerable<ResourceAssetProperty> 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<PreloadAsset> 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; }
}
15 changes: 15 additions & 0 deletions src/Components/Endpoints/src/Builder/ResourcePreloadService.cs
Original file line number Diff line number Diff line change
@@ -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<List<PreloadAsset>>? handler;

public void SetPreloadingHandler(Action<List<PreloadAsset>> handler)
=> this.handler = handler;

public void Preload(List<PreloadAsset> assets)
=> this.handler?.Invoke(assets);
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection
services.AddSupplyValueFromPersistentComponentStateProvider();
services.TryAddCascadingValue(sp => sp.GetRequiredService<EndpointHtmlRenderer>().HttpContext);
services.TryAddScoped<WebAssemblySettingsEmitter>();
services.TryAddScoped<ResourcePreloadService>();

services.TryAddScoped<ResourceCollectionProvider>();
RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration<ResourceCollectionProvider>(services, RenderMode.InteractiveWebAssembly);
Expand Down
2 changes: 2 additions & 0 deletions src/Components/Endpoints/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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<TPersistentService>(this Microsoft.Extensions.DependencyInjection.IRazorComponentsBuilder! builder, Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> Microsoft.Extensions.DependencyInjection.IRazorComponentsBuilder!
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -278,12 +277,6 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo
{
if (_httpContext.RequestServices.GetRequiredService<WebAssemblySettingsEmitter>().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($"<!--Blazor-WebAssembly:{settingsJson}-->");
}
Expand Down Expand Up @@ -320,15 +313,6 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo
}
}

private void AppendWebAssemblyPreloadHeaders()
{
var preloads = _httpContext.GetEndpoint()?.Metadata.GetMetadata<ResourcePreloadCollection>();
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
Expand Down
18 changes: 18 additions & 0 deletions src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ internal class SSRRenderModeBoundary : IComponent
private RenderHandle _renderHandle;
private IReadOnlyDictionary<string, object?>? _latestParameters;
private ComponentMarkerKey? _markerKey;
private readonly HttpContext _httpContext;

public IComponentRenderMode RenderMode { get; }

Expand All @@ -38,6 +39,7 @@ public SSRRenderModeBoundary(
{
AssertRenderModeIsConfigured(httpContext, componentType, renderMode);

_httpContext = httpContext;
_componentType = componentType;
RenderMode = renderMode;
_prerender = renderMode switch
Expand Down Expand Up @@ -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);
Expand All @@ -114,6 +122,16 @@ public Task SetParametersAsync(ParameterView parameters)
return Task.CompletedTask;
}

private void PreloadWebAssemblyAssets()
{
var preloads = _httpContext.GetEndpoint()?.Metadata.GetMetadata<ResourcePreloadCollection>();
if (preloads != null && preloads.TryGetAssets("webassembly", out var preloadAssets))
{
var service = _httpContext.RequestServices.GetRequiredService<ResourcePreloadService>();
service.Preload(preloadAssets);
}
}

private void ValidateParameters(IReadOnlyDictionary<string, object?> latestParameters)
{
foreach (var (name, value) in latestParameters)
Expand Down
25 changes: 10 additions & 15 deletions src/Components/Endpoints/test/EndpointHtmlRendererTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(WebAssemblyPreloadComponent), 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("<first.js>", 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("<second.js>", 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]
Expand Down Expand Up @@ -1835,6 +1829,7 @@ private static ServiceCollection CreateDefaultServiceCollection()
services.AddSingleton<AntiforgeryStateProvider, EndpointAntiforgeryStateProvider>();
services.AddSingleton<ICascadingValueSupplier>(_ => new SupplyParameterFromFormValueProvider(null, ""));
services.AddScoped<ResourceCollectionProvider>();
services.AddScoped<ResourcePreloadService>();
services.AddSingleton(new WebAssemblySettingsEmitter(new TestEnvironment(Environments.Development)));
return services;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<LinkPreload />

<h3>WebAssemblyPreloadComponent</h3>
Loading