Skip to content

Commit fe7fe16

Browse files
authored
[Blazor] Use <LinkPreload /> component to preload assets (#62225)
1 parent 78e5147 commit fe7fe16

File tree

11 files changed

+174
-63
lines changed

11 files changed

+174
-63
lines changed
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Components.Endpoints;
5+
using Microsoft.AspNetCore.Components.Rendering;
6+
7+
namespace Microsoft.AspNetCore.Components;
8+
9+
/// <summary>
10+
/// Represents link elements for preloading assets.
11+
/// </summary>
12+
public sealed class LinkPreload : IComponent
13+
{
14+
private RenderHandle renderHandle;
15+
private List<PreloadAsset>? assets;
16+
17+
[Inject]
18+
internal ResourcePreloadService? Service { get; set; }
19+
20+
void IComponent.Attach(RenderHandle renderHandle)
21+
{
22+
this.renderHandle = renderHandle;
23+
}
24+
25+
Task IComponent.SetParametersAsync(ParameterView parameters)
26+
{
27+
Service?.SetPreloadingHandler(PreloadAssets);
28+
renderHandle.Render(RenderPreloadAssets);
29+
return Task.CompletedTask;
30+
}
31+
32+
private void PreloadAssets(List<PreloadAsset> assets)
33+
{
34+
if (this.assets != null)
35+
{
36+
return;
37+
}
38+
39+
this.assets = assets;
40+
renderHandle.Render(RenderPreloadAssets);
41+
}
42+
43+
private void RenderPreloadAssets(RenderTreeBuilder builder)
44+
{
45+
if (assets == null)
46+
{
47+
return;
48+
}
49+
50+
for (var i = 0; i < assets.Count; i ++)
51+
{
52+
var asset = assets[i];
53+
builder.OpenElement(0, "link");
54+
builder.SetKey(assets[i]);
55+
builder.AddAttribute(1, "href", asset.Url);
56+
builder.AddAttribute(2, "rel", asset.PreloadRel);
57+
if (!string.IsNullOrEmpty(asset.PreloadAs))
58+
{
59+
builder.AddAttribute(3, "as", asset.PreloadAs);
60+
}
61+
if (!string.IsNullOrEmpty(asset.PreloadPriority))
62+
{
63+
builder.AddAttribute(4, "fetchpriority", asset.PreloadPriority);
64+
}
65+
if (!string.IsNullOrEmpty(asset.PreloadCrossorigin))
66+
{
67+
builder.AddAttribute(5, "crossorigin", asset.PreloadCrossorigin);
68+
}
69+
if (!string.IsNullOrEmpty(asset.Integrity))
70+
{
71+
builder.AddAttribute(6, "integrity", asset.Integrity);
72+
}
73+
builder.CloseElement();
74+
}
75+
}
76+
}
Lines changed: 46 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,16 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4-
using System.Linq;
5-
using System.Text;
6-
using Microsoft.Extensions.Primitives;
4+
using System.Diagnostics.CodeAnalysis;
75

86
namespace Microsoft.AspNetCore.Components.Endpoints;
97

108
internal class ResourcePreloadCollection
119
{
12-
private readonly Dictionary<string, StringValues> _storage = new();
10+
private readonly Dictionary<string, List<PreloadAsset>> _storage = new();
1311

1412
public ResourcePreloadCollection(ResourceAssetCollection assets)
1513
{
16-
var headerBuilder = new StringBuilder();
17-
var headers = new Dictionary<string, List<(int Order, string Value)>>();
1814
foreach (var asset in assets)
1915
{
2016
if (asset.Properties == null)
@@ -38,63 +34,81 @@ public ResourcePreloadCollection(ResourceAssetCollection assets)
3834
continue;
3935
}
4036

41-
var header = CreateHeader(headerBuilder, asset.Url, asset.Properties);
42-
if (!headers.TryGetValue(group, out var groupHeaders))
37+
var preloadAsset = CreateAsset(asset.Url, asset.Properties);
38+
if (!_storage.TryGetValue(group, out var groupHeaders))
4339
{
44-
groupHeaders = headers[group] = new List<(int Order, string Value)>();
40+
groupHeaders = _storage[group] = new List<PreloadAsset>();
4541
}
4642

47-
groupHeaders.Add(header);
43+
groupHeaders.Add(preloadAsset);
4844
}
4945

50-
foreach (var group in headers)
46+
foreach (var group in _storage)
5147
{
52-
_storage[group.Key ?? string.Empty] = group.Value.OrderBy(h => h.Order).Select(h => h.Value).ToArray();
48+
group.Value.Sort((a, b) => a.PreloadOrder.CompareTo(b.PreloadOrder));
5349
}
5450
}
5551

56-
private static (int order, string header) CreateHeader(StringBuilder headerBuilder, string url, IEnumerable<ResourceAssetProperty> properties)
52+
private static PreloadAsset CreateAsset(string url, IEnumerable<ResourceAssetProperty> properties)
5753
{
58-
headerBuilder.Clear();
59-
headerBuilder.Append('<');
60-
headerBuilder.Append(url);
61-
headerBuilder.Append('>');
62-
63-
int order = 0;
54+
var resourceAsset = new PreloadAsset(url);
6455
foreach (var property in properties)
6556
{
66-
if (property.Name.Equals("preloadrel", StringComparison.OrdinalIgnoreCase))
57+
if (property.Name.Equals("label", StringComparison.OrdinalIgnoreCase))
58+
{
59+
resourceAsset.Label = property.Value;
60+
}
61+
else if (property.Name.Equals("integrity", StringComparison.OrdinalIgnoreCase))
62+
{
63+
resourceAsset.Integrity = property.Value;
64+
}
65+
else if (property.Name.Equals("preloadgroup", StringComparison.OrdinalIgnoreCase))
66+
{
67+
resourceAsset.PreloadGroup = property.Value;
68+
}
69+
else if (property.Name.Equals("preloadrel", StringComparison.OrdinalIgnoreCase))
6770
{
68-
headerBuilder.Append("; rel=").Append(property.Value);
71+
resourceAsset.PreloadRel = property.Value;
6972
}
7073
else if (property.Name.Equals("preloadas", StringComparison.OrdinalIgnoreCase))
7174
{
72-
headerBuilder.Append("; as=").Append(property.Value);
75+
resourceAsset.PreloadAs = property.Value;
7376
}
7477
else if (property.Name.Equals("preloadpriority", StringComparison.OrdinalIgnoreCase))
7578
{
76-
headerBuilder.Append("; fetchpriority=").Append(property.Value);
79+
resourceAsset.PreloadPriority = property.Value;
7780
}
7881
else if (property.Name.Equals("preloadcrossorigin", StringComparison.OrdinalIgnoreCase))
7982
{
80-
headerBuilder.Append("; crossorigin=").Append(property.Value);
81-
}
82-
else if (property.Name.Equals("integrity", StringComparison.OrdinalIgnoreCase))
83-
{
84-
headerBuilder.Append("; integrity=\"").Append(property.Value).Append('"');
83+
resourceAsset.PreloadCrossorigin = property.Value;
8584
}
8685
else if (property.Name.Equals("preloadorder", StringComparison.OrdinalIgnoreCase))
8786
{
88-
if (!int.TryParse(property.Value, out order))
87+
if (!int.TryParse(property.Value, out int order))
8988
{
9089
order = 0;
9190
}
91+
92+
resourceAsset.PreloadOrder = order;
9293
}
9394
}
9495

95-
return (order, headerBuilder.ToString());
96+
return resourceAsset;
9697
}
9798

98-
public bool TryGetLinkHeaders(string group, out StringValues linkHeaders)
99-
=> _storage.TryGetValue(group, out linkHeaders);
99+
public bool TryGetAssets(string group, [MaybeNullWhen(false)] out List<PreloadAsset> assets)
100+
=> _storage.TryGetValue(group, out assets);
101+
}
102+
103+
internal sealed class PreloadAsset(string url)
104+
{
105+
public string Url { get; } = url;
106+
public string? Label { get; set; }
107+
public string? Integrity { get; set; }
108+
public string? PreloadGroup { get; set; }
109+
public string? PreloadRel { get; set; }
110+
public string? PreloadAs { get; set; }
111+
public string? PreloadPriority { get; set; }
112+
public string? PreloadCrossorigin { get; set; }
113+
public int PreloadOrder { get; set; }
100114
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Components.Endpoints;
5+
6+
internal class ResourcePreloadService
7+
{
8+
private Action<List<PreloadAsset>>? handler;
9+
10+
public void SetPreloadingHandler(Action<List<PreloadAsset>> handler)
11+
=> this.handler = handler;
12+
13+
public void Preload(List<PreloadAsset> assets)
14+
=> this.handler?.Invoke(assets);
15+
}

src/Components/Endpoints/src/DependencyInjection/RazorComponentsServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public static IRazorComponentsBuilder AddRazorComponents(this IServiceCollection
7373
services.AddSupplyValueFromPersistentComponentStateProvider();
7474
services.TryAddCascadingValue(sp => sp.GetRequiredService<EndpointHtmlRenderer>().HttpContext);
7575
services.TryAddScoped<WebAssemblySettingsEmitter>();
76+
services.TryAddScoped<ResourcePreloadService>();
7677

7778
services.TryAddScoped<ResourceCollectionProvider>();
7879
RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration<ResourceCollectionProvider>(services, RenderMode.InteractiveWebAssembly);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Components.LinkPreload
3+
Microsoft.AspNetCore.Components.LinkPreload.LinkPreload() -> void
24
Microsoft.Extensions.DependencyInjection.RazorComponentsRazorComponentBuilderExtensions
35
static Microsoft.Extensions.DependencyInjection.RazorComponentsRazorComponentBuilderExtensions.RegisterPersistentService<TPersistentService>(this Microsoft.Extensions.DependencyInjection.IRazorComponentsBuilder! builder, Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> Microsoft.Extensions.DependencyInjection.IRazorComponentsBuilder!

src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
using Microsoft.Extensions.DependencyInjection;
1313
using Microsoft.Extensions.Hosting;
1414
using Microsoft.Extensions.Options;
15-
using Microsoft.Extensions.Primitives;
1615

1716
namespace Microsoft.AspNetCore.Components.Endpoints;
1817

@@ -278,12 +277,6 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo
278277
{
279278
if (_httpContext.RequestServices.GetRequiredService<WebAssemblySettingsEmitter>().TryGetSettingsOnce(out var settings))
280279
{
281-
if (marker.Type is ComponentMarker.WebAssemblyMarkerType)
282-
{
283-
// Preload WebAssembly assets when using WebAssembly (not Auto) mode
284-
AppendWebAssemblyPreloadHeaders();
285-
}
286-
287280
var settingsJson = JsonSerializer.Serialize(settings, ServerComponentSerializationSettings.JsonSerializationOptions);
288281
output.Write($"<!--Blazor-WebAssembly:{settingsJson}-->");
289282
}
@@ -320,15 +313,6 @@ private void WriteComponentHtml(int componentId, TextWriter output, bool allowBo
320313
}
321314
}
322315

323-
private void AppendWebAssemblyPreloadHeaders()
324-
{
325-
var preloads = _httpContext.GetEndpoint()?.Metadata.GetMetadata<ResourcePreloadCollection>();
326-
if (preloads != null && preloads.TryGetLinkHeaders("webassembly", out var linkHeaders))
327-
{
328-
_httpContext.Response.Headers.Link = StringValues.Concat(_httpContext.Response.Headers.Link, linkHeaders);
329-
}
330-
}
331-
332316
private static bool IsProgressivelyEnhancedNavigation(HttpRequest request)
333317
{
334318
// For enhanced nav, the Blazor JS code controls the "accept" header precisely, so we can be very specific about the format

src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ internal class SSRRenderModeBoundary : IComponent
2828
private RenderHandle _renderHandle;
2929
private IReadOnlyDictionary<string, object?>? _latestParameters;
3030
private ComponentMarkerKey? _markerKey;
31+
private readonly HttpContext _httpContext;
3132

3233
public IComponentRenderMode RenderMode { get; }
3334

@@ -38,6 +39,7 @@ public SSRRenderModeBoundary(
3839
{
3940
AssertRenderModeIsConfigured(httpContext, componentType, renderMode);
4041

42+
_httpContext = httpContext;
4143
_componentType = componentType;
4244
RenderMode = renderMode;
4345
_prerender = renderMode switch
@@ -106,6 +108,12 @@ public Task SetParametersAsync(ParameterView parameters)
106108

107109
ValidateParameters(_latestParameters);
108110

111+
if (RenderMode is InteractiveWebAssemblyRenderMode)
112+
{
113+
// Preload WebAssembly assets when using WebAssembly (not Auto) mode
114+
PreloadWebAssemblyAssets();
115+
}
116+
109117
if (_prerender)
110118
{
111119
_renderHandle.Render(Prerender);
@@ -114,6 +122,16 @@ public Task SetParametersAsync(ParameterView parameters)
114122
return Task.CompletedTask;
115123
}
116124

125+
private void PreloadWebAssemblyAssets()
126+
{
127+
var preloads = _httpContext.GetEndpoint()?.Metadata.GetMetadata<ResourcePreloadCollection>();
128+
if (preloads != null && preloads.TryGetAssets("webassembly", out var preloadAssets))
129+
{
130+
var service = _httpContext.RequestServices.GetRequiredService<ResourcePreloadService>();
131+
service.Preload(preloadAssets);
132+
}
133+
}
134+
117135
private void ValidateParameters(IReadOnlyDictionary<string, object?> latestParameters)
118136
{
119137
foreach (var (name, value) in latestParameters)

src/Components/Endpoints/test/EndpointHtmlRendererTest.cs

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -142,25 +142,19 @@ public async Task CanPreload_WebAssembly_ResourceAssets()
142142
);
143143

144144
// Act
145-
var result = await renderer.PrerenderComponentAsync(httpContext, typeof(SimpleComponent), new InteractiveWebAssemblyRenderMode(prerender: false), ParameterView.Empty);
145+
var result = await renderer.PrerenderComponentAsync(httpContext, typeof(WebAssemblyPreloadWrapper), null, ParameterView.Empty);
146146
await renderer.Dispatcher.InvokeAsync(() => result.WriteTo(writer, HtmlEncoder.Default));
147147

148148
// Assert
149-
Assert.Equal(2, httpContext.Response.Headers.Link.Count);
150-
151-
var firstPreloadLink = httpContext.Response.Headers.Link[0];
152-
Assert.Contains("<first.js>", firstPreloadLink);
153-
Assert.Contains("rel=preload", firstPreloadLink);
154-
Assert.Contains("as=script", firstPreloadLink);
155-
Assert.Contains("fetchpriority=high", firstPreloadLink);
156-
Assert.Contains("integrity=\"abcd\"", firstPreloadLink);
149+
var output = writer.ToString();
157150

158-
var secondPreloadLink = httpContext.Response.Headers.Link[1];
159-
Assert.Contains("<second.js>", secondPreloadLink);
160-
Assert.Contains("rel=preload", secondPreloadLink);
161-
Assert.Contains("as=script", secondPreloadLink);
162-
Assert.Contains("fetchpriority=high", secondPreloadLink);
163-
Assert.Contains("integrity=\"abcd\"", secondPreloadLink);
151+
Assert.Contains("href=\"first.js\"", output);
152+
Assert.Contains("href=\"second.js\"", output);
153+
Assert.DoesNotContain("nopreload.js", output);
154+
Assert.Contains("rel=\"preload\"", output);
155+
Assert.Contains("as=\"script\"", output);
156+
Assert.Contains("fetchpriority=\"high\"", output);
157+
Assert.Contains("integrity=\"abcd\"", output);
164158
}
165159

166160
[Fact]
@@ -1835,6 +1829,7 @@ private static ServiceCollection CreateDefaultServiceCollection()
18351829
services.AddSingleton<AntiforgeryStateProvider, EndpointAntiforgeryStateProvider>();
18361830
services.AddSingleton<ICascadingValueSupplier>(_ => new SupplyParameterFromFormValueProvider(null, ""));
18371831
services.AddScoped<ResourceCollectionProvider>();
1832+
services.AddScoped<ResourcePreloadService>();
18381833
services.AddSingleton(new WebAssemblySettingsEmitter(new TestEnvironment(Environments.Development)));
18391834
return services;
18401835
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<h3>WebAssemblyPreloadComponent</h3>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@using Microsoft.AspNetCore.Components.Web
2+
<LinkPreload />
3+
4+
<WebAssemblyPreloadComponent @rendermode="@(new InteractiveWebAssemblyRenderMode(prerender: true))" />

src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWeb-CSharp/Components/App.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<meta charset="utf-8" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<base href="/" />
8+
<LinkPreload />
89
@*#if (SampleContent)
910
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
1011
##endif*@

0 commit comments

Comments
 (0)