Add framework support for lazy-loading assemblies on route change (#23290)

* Add framework support for lazy-loading assemblies on route change
* Configure lazy-loaded assemblies in WebAssemblyLazyLoadDefinition
* Move tests to WebAssembly-only scenarios
* Refactor RouteTableFactory and add WebAssemblyDynamicResourceLoader
* Address feedback from peer review
* Rename 'dynamicAssembly' to 'lazyAssembly' and address peer review
* Add sample with loading state
* Update Router API and assembly loading tests
* Support and test cancellation and pre-rendering
* Apply suggestions from code review
Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com>
* Spurce up API and add tests for pre-rendering scenario
* Use CT instead of CTS in NavigationContext
* Address feedback from peer review
* Remove extra test file and update Router
Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com>
This commit is contained in:
Safia Abdalla 2020-07-09 01:16:47 +00:00 committed by GitHub
parent 37c20036b3
commit bbc116254a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 652 additions and 206 deletions

View File

@ -556,6 +556,12 @@ namespace Microsoft.AspNetCore.Components.Routing
public bool IsNavigationIntercepted { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } }
public string Location { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } }
}
public sealed partial class NavigationContext
{
internal NavigationContext() { }
public System.Threading.CancellationToken CancellationToken { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } }
public string Path { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } }
}
public partial class Router : Microsoft.AspNetCore.Components.IComponent, Microsoft.AspNetCore.Components.IHandleAfterRender, System.IDisposable
{
public Router() { }
@ -566,10 +572,15 @@ namespace Microsoft.AspNetCore.Components.Routing
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.RouteData> Found { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment Navigating { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.RenderFragment NotFound { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
[Microsoft.AspNetCore.Components.ParameterAttribute]
public Microsoft.AspNetCore.Components.EventCallback<Microsoft.AspNetCore.Components.Routing.NavigationContext> OnNavigateAsync { [System.Runtime.CompilerServices.CompilerGeneratedAttribute] get { throw null; } [System.Runtime.CompilerServices.CompilerGeneratedAttribute] set { } }
public void Attach(Microsoft.AspNetCore.Components.RenderHandle renderHandle) { }
public void Dispose() { }
System.Threading.Tasks.Task Microsoft.AspNetCore.Components.IHandleAfterRender.OnAfterRenderAsync() { throw null; }
[System.Diagnostics.DebuggerStepThroughAttribute]
public System.Threading.Tasks.Task SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) { throw null; }
}
}

View File

@ -0,0 +1,24 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Threading;
namespace Microsoft.AspNetCore.Components.Routing
{
/// <summary>
/// Provides information about the current asynchronous navigation event
/// including the target path and the cancellation token.
/// </summary>
public sealed class NavigationContext
{
internal NavigationContext(string path, CancellationToken cancellationToken)
{
Path = path;
CancellationToken = cancellationToken;
}
public string Path { get; }
public CancellationToken CancellationToken { get; }
}
}

View File

@ -8,8 +8,8 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Components.Routing
@ -29,6 +29,12 @@ namespace Microsoft.AspNetCore.Components.Routing
bool _navigationInterceptionEnabled;
ILogger<Router> _logger;
private CancellationTokenSource _onNavigateCts;
private readonly HashSet<Assembly> _assemblies = new HashSet<Assembly>();
private bool _onNavigateCalled = false;
[Inject] private NavigationManager NavigationManager { get; set; }
[Inject] private INavigationInterception NavigationInterception { get; set; }
@ -56,6 +62,16 @@ namespace Microsoft.AspNetCore.Components.Routing
/// </summary>
[Parameter] public RenderFragment<RouteData> Found { get; set; }
/// <summary>
/// Get or sets the content to display when asynchronous navigation is in progress.
/// </summary>
[Parameter] public RenderFragment Navigating { get; set; }
/// <summary>
/// Gets or sets a handler that should be called before navigating to a new page.
/// </summary>
[Parameter] public EventCallback<NavigationContext> OnNavigateAsync { get; set; }
private RouteTable Routes { get; set; }
/// <inheritdoc />
@ -69,7 +85,7 @@ namespace Microsoft.AspNetCore.Components.Routing
}
/// <inheritdoc />
public Task SetParametersAsync(ParameterView parameters)
public async Task SetParametersAsync(ParameterView parameters)
{
parameters.SetParameterProperties(this);
@ -93,17 +109,20 @@ namespace Microsoft.AspNetCore.Components.Routing
throw new InvalidOperationException($"The {nameof(Router)} component requires a value for the parameter {nameof(NotFound)}.");
}
if (!_onNavigateCalled)
{
_onNavigateCalled = true;
await RunOnNavigateAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute));
}
var assemblies = AdditionalAssemblies == null ? new[] { AppAssembly } : new[] { AppAssembly }.Concat(AdditionalAssemblies);
Routes = RouteTableFactory.Create(assemblies);
Refresh(isNavigationIntercepted: false);
return Task.CompletedTask;
}
/// <inheritdoc />
public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
_onNavigateCts?.Dispose();
}
private static string StringUntilAny(string str, char[] chars)
@ -114,8 +133,24 @@ namespace Microsoft.AspNetCore.Components.Routing
: str.Substring(0, firstIndex);
}
private void RefreshRouteTable()
{
var assemblies = AdditionalAssemblies == null ? new[] { AppAssembly } : new[] { AppAssembly }.Concat(AdditionalAssemblies);
var assembliesSet = new HashSet<Assembly>(assemblies);
if (!_assemblies.SetEquals(assembliesSet))
{
Routes = RouteTableFactory.Create(assemblies);
_assemblies.Clear();
_assemblies.UnionWith(assembliesSet);
}
}
private void Refresh(bool isNavigationIntercepted)
{
RefreshRouteTable();
var locationPath = NavigationManager.ToBaseRelativePath(_locationAbsolute);
locationPath = StringUntilAny(locationPath, _queryOrHashStartChar);
var context = new RouteContext(locationPath);
@ -155,12 +190,52 @@ namespace Microsoft.AspNetCore.Components.Routing
}
}
private async Task RunOnNavigateAsync(string path)
{
// If this router instance does not provide an OnNavigateAsync parameter
// then we render the component associated with the route as per usual.
if (!OnNavigateAsync.HasDelegate)
{
return;
}
// If we've already invoked a task and stored its CTS, then
// cancel the existing task.
_onNavigateCts?.Dispose();
// Create a new cancellation token source for this instance
_onNavigateCts = new CancellationTokenSource();
var navigateContext = new NavigationContext(path, _onNavigateCts.Token);
// Create a cancellation task based on the cancellation token
// associated with the current running task.
var cancellationTaskSource = new TaskCompletionSource();
navigateContext.CancellationToken.Register(state =>
((TaskCompletionSource)state).SetResult(), cancellationTaskSource);
var task = OnNavigateAsync.InvokeAsync(navigateContext);
// If the user provided a Navigating render fragment, then show it.
if (Navigating != null && task.Status != TaskStatus.RanToCompletion)
{
_renderHandle.Render(Navigating);
}
await Task.WhenAny(task, cancellationTaskSource.Task);
}
private async Task RunOnNavigateWithRefreshAsync(string path, bool isNavigationIntercepted)
{
await RunOnNavigateAsync(path);
Refresh(isNavigationIntercepted);
}
private void OnLocationChanged(object sender, LocationChangedEventArgs args)
{
_locationAbsolute = args.Location;
if (_renderHandle.IsInitialized && Routes != null)
{
Refresh(args.IsNavigationIntercepted);
_ = RunOnNavigateWithRefreshAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), args.IsNavigationIntercepted);
}
}

File diff suppressed because one or more lines are too long

View File

@ -30,6 +30,7 @@ export interface BootJsonData {
export interface ResourceGroups {
readonly assembly: ResourceList;
readonly lazyAssembly: ResourceList;
readonly pdb?: ResourceList;
readonly runtime: ResourceList;
readonly satelliteResources?: { [cultureName: string] : ResourceList };

View File

@ -293,6 +293,34 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
}
return BINDING.js_to_mono_obj(Promise.resolve(0));
}
window['Blazor']._internal.getLazyAssemblies = (assembliesToLoadDotNetArray: System_Array<System_String>) : System_Object => {
const assembliesToLoad = BINDING.mono_array_to_js_array<System_String, string>(assembliesToLoadDotNetArray);
const lazyAssemblies = resourceLoader.bootConfig.resources.lazyAssembly;
if (lazyAssemblies) {
const resourcePromises = Promise.all(assembliesToLoad
.filter(assembly => lazyAssemblies.hasOwnProperty(assembly))
.map(assembly => resourceLoader.loadResource(assembly, `_framework/${assembly}`, lazyAssemblies[assembly], 'assembly'))
.map(async resource => (await resource.response).arrayBuffer()));
return BINDING.js_to_mono_obj(
resourcePromises.then(resourcesToLoad => {
if (resourcesToLoad.length) {
window['Blazor']._internal.readLazyAssemblies = () => {
const array = BINDING.mono_obj_array_new(resourcesToLoad.length);
for (var i = 0; i < resourcesToLoad.length; i++) {
BINDING.mono_obj_array_set(array, i, BINDING.js_typed_array_to_array(new Uint8Array(resourcesToLoad[i])));
}
return array;
};
}
return resourcesToLoad.length;
}));
}
return BINDING.js_to_mono_obj(Promise.resolve(0));
}
});
module.postRun.push(() => {

View File

@ -1,176 +0,0 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Diagnostics;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Testing;
using Xunit;
using static Microsoft.AspNetCore.Components.WebAssembly.Build.WebAssemblyRuntimePackage;
namespace Microsoft.AspNetCore.Components.WebAssembly.Build
{
public class BuildLazyLoadTest
{
[Fact]
public async Task Build_LazyLoadExplicitAssembly_Debug_Works()
{
// Arrange
using var project = ProjectDirectory.Create("standalone", additionalProjects: new[] { "razorclasslibrary" });
project.Configuration = "Debug";
project.AddProjectFileContent(
@"
<ItemGroup>
<BlazorWebAssemblyLazyLoad Include='RazorClassLibrary.dll' />
</ItemGroup>
");
var result = await MSBuildProcessManager.DotnetMSBuild(project);
var buildOutputDirectory = project.BuildOutputDirectory;
// Verify that a blazor.boot.json file has been created
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json");
// And that the assembly is in the output
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "_bin", "RazorClassLibrary.dll");
var bootJson = ReadBootJsonData(result, Path.Combine(buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json"));
// And that it has been labelled as a dynamic assembly in the boot.json
var dynamicAssemblies = bootJson.resources.dynamicAssembly;
var assemblies = bootJson.resources.assembly;
Assert.NotNull(dynamicAssemblies);
Assert.Contains("RazorClassLibrary.dll", dynamicAssemblies.Keys);
Assert.DoesNotContain("RazorClassLibrary.dll", assemblies.Keys);
// App assembly should not be lazy loaded
Assert.DoesNotContain("standalone.dll", dynamicAssemblies.Keys);
Assert.Contains("standalone.dll", assemblies.Keys);
}
[Fact]
public async Task Build_LazyLoadExplicitAssembly_Release_Works()
{
// Arrange
using var project = ProjectDirectory.Create("standalone", additionalProjects: new[] { "razorclasslibrary" });
project.Configuration = "Release";
project.AddProjectFileContent(
@"
<ItemGroup>
<BlazorWebAssemblyLazyLoad Include='RazorClassLibrary.dll' />
</ItemGroup>
");
var result = await MSBuildProcessManager.DotnetMSBuild(project);
var buildOutputDirectory = project.BuildOutputDirectory;
// Verify that a blazor.boot.json file has been created
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json");
// And that the assembly is in the output
Assert.FileExists(result, buildOutputDirectory, "wwwroot", "_framework", "_bin", "RazorClassLibrary.dll");
var bootJson = ReadBootJsonData(result, Path.Combine(buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json"));
// And that it has been labelled as a dynamic assembly in the boot.json
var dynamicAssemblies = bootJson.resources.dynamicAssembly;
var assemblies = bootJson.resources.assembly;
Assert.NotNull(dynamicAssemblies);
Assert.Contains("RazorClassLibrary.dll", dynamicAssemblies.Keys);
Assert.DoesNotContain("RazorClassLibrary.dll", assemblies.Keys);
// App assembly should not be lazy loaded
Assert.DoesNotContain("standalone.dll", dynamicAssemblies.Keys);
Assert.Contains("standalone.dll", assemblies.Keys);
}
[Fact]
public async Task Publish_LazyLoadExplicitAssembly_Debug_Works()
{
// Arrange
using var project = ProjectDirectory.Create("standalone", additionalProjects: new[] { "razorclasslibrary" });
project.Configuration = "Debug";
project.AddProjectFileContent(
@"
<ItemGroup>
<BlazorWebAssemblyLazyLoad Include='RazorClassLibrary.dll' />
</ItemGroup>
");
var result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish");
var publishDirectory = project.PublishOutputDirectory;
// Verify that a blazor.boot.json file has been created
Assert.FileExists(result, publishDirectory, "wwwroot", "_framework", "blazor.boot.json");
// And that the assembly is in the output
Assert.FileExists(result, publishDirectory, "wwwroot", "_framework", "_bin", "RazorClassLibrary.dll");
var bootJson = ReadBootJsonData(result, Path.Combine(publishDirectory, "wwwroot", "_framework", "blazor.boot.json"));
// And that it has been labelled as a dynamic assembly in the boot.json
var dynamicAssemblies = bootJson.resources.dynamicAssembly;
var assemblies = bootJson.resources.assembly;
Assert.NotNull(dynamicAssemblies);
Assert.Contains("RazorClassLibrary.dll", dynamicAssemblies.Keys);
Assert.DoesNotContain("RazorClassLibrary.dll", assemblies.Keys);
// App assembly should not be lazy loaded
Assert.DoesNotContain("standalone.dll", dynamicAssemblies.Keys);
Assert.Contains("standalone.dll", assemblies.Keys);
}
[Fact]
public async Task Publish_LazyLoadExplicitAssembly_Release_Works()
{
// Arrange
using var project = ProjectDirectory.Create("standalone", additionalProjects: new[] { "razorclasslibrary" });
project.Configuration = "Release";
project.AddProjectFileContent(
@"
<ItemGroup>
<BlazorWebAssemblyLazyLoad Include='RazorClassLibrary.dll' />
</ItemGroup>
");
var result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish");
var publishDirectory = project.PublishOutputDirectory;
// Verify that a blazor.boot.json file has been created
Assert.FileExists(result, publishDirectory, "wwwroot", "_framework", "blazor.boot.json");
// And that the assembly is in the output
Assert.FileExists(result, publishDirectory, "wwwroot", "_framework", "_bin", "RazorClassLibrary.dll");
var bootJson = ReadBootJsonData(result, Path.Combine(publishDirectory, "wwwroot", "_framework", "blazor.boot.json"));
// And that it has been labelled as a dynamic assembly in the boot.json
var dynamicAssemblies = bootJson.resources.dynamicAssembly;
var assemblies = bootJson.resources.assembly;
Assert.NotNull(dynamicAssemblies);
Assert.Contains("RazorClassLibrary.dll", dynamicAssemblies.Keys);
Assert.DoesNotContain("RazorClassLibrary.dll", assemblies.Keys);
// App assembly should not be lazy loaded
Assert.DoesNotContain("standalone.dll", dynamicAssemblies.Keys);
Assert.Contains("standalone.dll", assemblies.Keys);
}
private static GenerateBlazorBootJson.BootJsonData ReadBootJsonData(MSBuildResult result, string path)
{
return JsonSerializer.Deserialize<GenerateBlazorBootJson.BootJsonData>(
File.ReadAllText(Path.Combine(result.Project.DirectoryPath, path)),
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
}
}

View File

@ -2,7 +2,6 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.AspNetCore.Components.Routing;
@ -11,7 +10,6 @@ using Microsoft.AspNetCore.Components.Web;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
@ -191,6 +189,7 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
Services.AddSingleton<IJSRuntime>(DefaultWebAssemblyJSRuntime.Instance);
Services.AddSingleton<NavigationManager>(WebAssemblyNavigationManager.Instance);
Services.AddSingleton<INavigationInterception>(WebAssemblyNavigationInterception.Instance);
Services.AddSingleton(provider => new LazyAssemblyLoader(provider));
Services.AddLogging(builder => {
builder.AddProvider(new WebAssemblyConsoleLoggerProvider(DefaultWebAssemblyJSRuntime.Instance));
});

View File

@ -0,0 +1,108 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Runtime.Loader;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using Microsoft.JSInterop.WebAssembly;
namespace Microsoft.AspNetCore.Components.WebAssembly.Services
{
/// <summary>
/// Provides a service for loading assemblies at runtime in a browser context.
///
/// Supports finding pre-loaded assemblies in a server or pre-rendering context.
/// </summary>
public class LazyAssemblyLoader
{
internal const string GetDynamicAssemblies = "window.Blazor._internal.getLazyAssemblies";
internal const string ReadDynamicAssemblies = "window.Blazor._internal.readLazyAssemblies";
private List<Assembly> _loadedAssemblyCache = new List<Assembly>();
private readonly IServiceProvider _provider;
public LazyAssemblyLoader(IServiceProvider provider)
{
_provider = provider;
_loadedAssemblyCache = AppDomain.CurrentDomain.GetAssemblies().ToList();
}
/// <summary>
/// In a browser context, calling this method will fetch the assemblies requested
/// via a network call and load them into the runtime. In a server or pre-rendered
/// context, this method will look for the assemblies already loaded in the runtime
/// and return them.
/// </summary>
/// <param name="assembliesToLoad">The names of the assemblies to load (e.g. "MyAssembly.dll")</param>
/// <returns>A list of the loaded <see cref="Assembly"/></returns>
public async Task<IEnumerable<Assembly>> LoadAssembliesAsync(IEnumerable<string> assembliesToLoad)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Browser))
{
return await LoadAssembliesInClientAsync(assembliesToLoad);
}
return await LoadAssembliesInServerAsync(assembliesToLoad);
}
private Task<IEnumerable<Assembly>> LoadAssembliesInServerAsync(IEnumerable<string> assembliesToLoad)
{
var loadedAssemblies = _loadedAssemblyCache.Where(assembly =>
assembliesToLoad.Contains(assembly.GetName().Name + ".dll"));
if (loadedAssemblies.Count() != assembliesToLoad.Count())
{
var unloadedAssemblies = assembliesToLoad.Except(loadedAssemblies.Select(a => a.GetName().Name + ".dll"));
throw new InvalidOperationException($"Unable to find the following assemblies: {string.Join(",", unloadedAssemblies)}. Make sure that the appplication is referencing the assemblies and that they are present in the output folder.");
}
return Task.FromResult(loadedAssemblies);
}
private async Task<IEnumerable<Assembly>> LoadAssembliesInClientAsync(IEnumerable<string> assembliesToLoad)
{
var jsRuntime = _provider.GetRequiredService<IJSRuntime>();
// Only load assemblies that haven't already been lazily-loaded
var newAssembliesToLoad = assembliesToLoad.Except(_loadedAssemblyCache.Select(a => a.GetName().Name + ".dll"));
var loadedAssemblies = new List<Assembly>();
var count = (int)await ((WebAssemblyJSRuntime)jsRuntime).InvokeUnmarshalled<string[], object, object, Task<object>>(
GetDynamicAssemblies,
assembliesToLoad.ToArray(),
null,
null);
if (count == 0)
{
return loadedAssemblies;
}
var assemblies = ((WebAssemblyJSRuntime)jsRuntime).InvokeUnmarshalled<object, object, object, object[]>(
ReadDynamicAssemblies,
null,
null,
null);
foreach (byte[] assembly in assemblies)
{
// The runtime loads assemblies into an isolated context by default. As a result,
// assemblies that are loaded via Assembly.Load aren't available in the app's context
// AKA the default context. To work around this, we explicitly load the assemblies
// into the default app context.
var loadedAssembly = AssemblyLoadContext.Default.LoadFromStream(new MemoryStream(assembly));
loadedAssemblies.Add(loadedAssembly);
_loadedAssemblyCache.Add(loadedAssembly);
}
return loadedAssemblies;
}
}
}

View File

@ -60,6 +60,18 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
Browser.Equal("Hello from interop call", () => Browser.FindElement(By.Id("val-set-by-interop")).GetAttribute("value"));
}
[Fact]
public void IsCompatibleWithLazyLoadWebAssembly()
{
Navigate("/prerendered/WithLazyAssembly");
var button = Browser.FindElement(By.Id("use-package-button"));
button.Click();
AssertLogDoesNotContainCriticalMessages("Could not load file or assembly 'Newtonsoft.Json");
}
[Fact]
public void CanReadUrlHashOnlyOnceConnected()
{
@ -121,6 +133,19 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
Browser.FindElement(By.Id("load-boot-script")).Click();
}
private void AssertLogDoesNotContainCriticalMessages(params string[] messages)
{
var log = Browser.Manage().Logs.GetLog(LogType.Browser);
foreach (var message in messages)
{
Assert.DoesNotContain(log, entry =>
{
return entry.Level == LogLevel.Severe
&& entry.Message.Contains(message);
});
}
}
private void SignInAs(string userName, string roles, bool useSeparateTab = false) =>
Browser.SignInAs(new Uri(_serverFixture.RootUri, "/prerendered/"), userName, roles, useSeparateTab);
}

View File

@ -12,6 +12,7 @@ using Microsoft.AspNetCore.E2ETesting;
using Microsoft.AspNetCore.Testing;
using OpenQA.Selenium;
using OpenQA.Selenium.Interactions;
using OpenQA.Selenium.Support.UI;
using Xunit;
using Xunit.Abstractions;
@ -527,6 +528,32 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
}
}
[Fact]
public void OnNavigate_CanRenderLoadingFragment()
{
var app = Browser.MountTestComponent<TestRouterWithOnNavigate>();
SetUrlViaPushState("/LongPage1");
new WebDriverWait(Browser, TimeSpan.FromSeconds(2)).Until(
driver => driver.FindElement(By.Id("loading-banner")) != null);
Assert.True(app.FindElement(By.Id("loading-banner")) != null);
}
[Fact]
public void OnNavigate_CanCancelCallback()
{
var app = Browser.MountTestComponent<TestRouterWithOnNavigate>();
// Navigating from one page to another should
// cancel the previous OnNavigate Task
SetUrlViaPushState("/LongPage2");
SetUrlViaPushState("/LongPage1");
AssertDidNotLog("I'm not happening...");
}
private long BrowserScrollY
{
get => (long)((IJavaScriptExecutor)Browser).ExecuteScript("return window.scrollY");
@ -543,6 +570,15 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
return absoluteUri.AbsoluteUri;
}
private void AssertDidNotLog(params string[] messages)
{
var log = Browser.Manage().Logs.GetLog(LogType.Browser);
foreach (var message in messages)
{
Assert.DoesNotContain(log, entry => entry.Message.Contains(message));
}
}
private void AssertHighlightedLinks(params string[] linkTexts)
{
Browser.Equal(linkTexts, () => Browser

View File

@ -0,0 +1,146 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using BasicTestApp;
using BasicTestApp.RouterTest;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using OpenQA.Selenium.Support.UI;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Components.E2ETest.Tests
{
public class WebAssemblyLazyLoadTest : ServerTestBase<ToggleExecutionModeServerFixture<Program>>
{
public WebAssemblyLazyLoadTest(
BrowserFixture browserFixture,
ToggleExecutionModeServerFixture<Program> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
}
protected override void InitializeAsyncCore()
{
Navigate(ServerPathBase, noReload: false);
Browser.MountTestComponent<TestRouterWithLazyAssembly>();
Browser.Exists(By.Id("blazor-error-ui"));
var errorUi = Browser.FindElement(By.Id("blazor-error-ui"));
Assert.Equal("none", errorUi.GetCssValue("display"));
}
[Fact]
public void CanLazyLoadOnRouteChange()
{
// Navigate to a page without any lazy-loaded dependencies
SetUrlViaPushState("/");
var app = Browser.MountTestComponent<TestRouterWithLazyAssembly>();
// Ensure that we haven't requested the lazy loaded assembly
Assert.False(HasLoadedAssembly("Newtonsoft.Json.dll"));
// Visit the route for the lazy-loaded assembly
SetUrlViaPushState("/WithLazyAssembly");
var button = app.FindElement(By.Id("use-package-button"));
// Now we should have requested the DLL
Assert.True(HasLoadedAssembly("Newtonsoft.Json.dll"));
button.Click();
// We shouldn't get any errors about assemblies not being available
AssertLogDoesNotContainCriticalMessages("Could not load file or assembly 'Newtonsoft.Json");
}
[Fact]
public void CanLazyLoadOnFirstVisit()
{
// Navigate to a page with lazy loaded assemblies for the first time
SetUrlViaPushState("/WithLazyAssembly");
var app = Browser.MountTestComponent<TestRouterWithLazyAssembly>();
// Wait for the page to finish loading
new WebDriverWait(Browser, TimeSpan.FromSeconds(2)).Until(
driver => driver.FindElement(By.Id("use-package-button")) != null);
var button = app.FindElement(By.Id("use-package-button"));
// We should have requested the DLL
Assert.True(HasLoadedAssembly("Newtonsoft.Json.dll"));
button.Click();
// We shouldn't get any errors about assemblies not being available
AssertLogDoesNotContainCriticalMessages("Could not load file or assembly 'Newtonsoft.Json");
}
[Fact]
public void CanLazyLoadAssemblyWithRoutes()
{
// Navigate to a page without any lazy-loaded dependencies
SetUrlViaPushState("/");
var app = Browser.MountTestComponent<TestRouterWithLazyAssembly>();
// Ensure that we haven't requested the lazy loaded assembly
Assert.False(HasLoadedAssembly("LazyTestContentPackage.dll"));
// Navigate to the designated route
SetUrlViaPushState("/WithLazyLoadedRoutes");
// Wait for the page to finish loading
new WebDriverWait(Browser, TimeSpan.FromSeconds(2)).Until(
driver => driver.FindElement(By.Id("lazy-load-msg")) != null);
// Now the assembly has been loaded
Assert.True(HasLoadedAssembly("LazyTestContentPackage.dll"));
var button = app.FindElement(By.Id("go-to-lazy-route"));
button.Click();
// Navigating the lazy-loaded route should show its content
var renderedElement = app.FindElement(By.Id("lazy-page"));
Assert.True(renderedElement.Displayed);
}
private string SetUrlViaPushState(string relativeUri)
{
var pathBaseWithoutHash = ServerPathBase.Split('#')[0];
var jsExecutor = (IJavaScriptExecutor)Browser;
var absoluteUri = new Uri(_serverFixture.RootUri, $"{pathBaseWithoutHash}{relativeUri}");
jsExecutor.ExecuteScript($"Blazor.navigateTo('{absoluteUri.ToString().Replace("'", "\\'")}')");
return absoluteUri.AbsoluteUri;
}
private bool HasLoadedAssembly(string name)
{
var checkScript = $"return window.performance.getEntriesByType('resource').some(r => r.name.endsWith('{name}'));";
var jsExecutor = (IJavaScriptExecutor)Browser;
var nameRequested = jsExecutor.ExecuteScript(checkScript);
if (nameRequested != null)
{
return (bool)nameRequested;
}
return false;
}
private void AssertLogDoesNotContainCriticalMessages(params string[] messages)
{
var log = Browser.Manage().Logs.GetLog(LogType.Browser);
foreach (var message in messages)
{
Assert.DoesNotContain(log, entry =>
{
return entry.Level == LogLevel.Severe
&& entry.Message.Contains(message);
});
}
}
}
}

View File

@ -18,14 +18,21 @@
<Reference Include="Microsoft.AspNetCore.Components.WebAssembly" />
<Reference Include="Microsoft.AspNetCore.Components.Authorization" />
<Reference Include="Microsoft.Extensions.Logging.Configuration" />
<Reference Include="Newtonsoft.Json" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TestContentPackage\TestContentPackage.csproj" />
<ProjectReference Include="..\LazyTestContentPackage\LazyTestContentPackage.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Resources.resx" GenerateSource="true" />
</ItemGroup>
<ItemGroup>
<BlazorWebAssemblyLazyLoad Include="Newtonsoft.Json" />
<BlazorWebAssemblyLazyLoad Include="LazyTestContentPackage" />
</ItemGroup>
</Project>

View File

@ -70,6 +70,8 @@
<option value="BasicTestApp.ReorderingFocusComponent">Reordering focus retention</option>
<option value="BasicTestApp.RouterTest.NavigationManagerComponent">NavigationManager Test</option>
<option value="BasicTestApp.RouterTest.TestRouter">Router</option>
<option value="BasicTestApp.RouterTest.TestRouterWithOnNavigate">Router with OnNavigate</option>
<option value="BasicTestApp.RouterTest.TestRouterWithLazyAssembly">Router with dynamic assembly</option>
<option value="BasicTestApp.RouterTest.TestRouterWithAdditionalAssembly">Router with additional assembly</option>
<option value="BasicTestApp.StringComparisonComponent">StringComparison</option>
<option value="BasicTestApp.SvgComponent">SVG</option>

View File

@ -20,6 +20,7 @@
<li><NavLink href="/subdir/WithParameters/Name/Abc/LastName/McDef">With more parameters</NavLink></li>
<li><NavLink href="/subdir/LongPage1">Long page 1</NavLink></li>
<li><NavLink href="/subdir/LongPage2">Long page 2</NavLink></li>
<li><NavLink href="/subdir/WithLazyAssembly">With lazy assembly</NavLink></li>
<li><NavLink href="PreventDefaultCases">preventDefault cases</NavLink></li>
<li><NavLink>Null href never matches</NavLink></li>
</ul>

View File

@ -0,0 +1,57 @@
@using Microsoft.AspNetCore.Components.Routing
@using System.Reflection
@using Microsoft.AspNetCore.Components.WebAssembly.Services
@inject LazyAssemblyLoader lazyLoader
<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" AdditionalAssemblies="@lazyLoadedAssemblies" OnNavigateAsync="@OnNavigateAsync">
<Navigating>
<div style="padding: 20px;background-color:blue;color:white;" id="loading-banner">
<p>Loading the requested page...</p>
</div>
</Navigating>
<Found Context="routeData">
<RouteView RouteData="@routeData" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(RouterTestLayout)">
<div id="test-info">Oops, that component wasn't found!</div>
</LayoutView>
</NotFound>
</Router>
@code {
private List<Assembly> lazyLoadedAssemblies = new List<Assembly>();
private async Task OnNavigateAsync(NavigationContext args)
{
Console.WriteLine($"Running OnNavigate for {args.Path}...");
await LoadAssemblies(args.Path);
}
private async Task LoadAssemblies(string uri)
{
try
{
if (uri.EndsWith("WithLazyAssembly"))
{
Console.WriteLine($"Loading assemblies for WithLazyAssembly...");
var assemblies = await lazyLoader.LoadAssembliesAsync(new List<string>() { "Newtonsoft.Json.dll" });
lazyLoadedAssemblies.AddRange(assemblies);
}
if (uri.EndsWith("WithLazyLoadedRoutes"))
{
Console.WriteLine($"Loading assemblies for WithLazyLoadedRoutes...");
var assemblies = await lazyLoader.LoadAssembliesAsync(new List<string>() { "LazyTestContentPackage.dll" });
lazyLoadedAssemblies.AddRange(assemblies);
}
}
catch (Exception e)
{
Console.WriteLine($"Error when loading assemblies: {e}");
}
}
}

View File

@ -0,0 +1,46 @@
@using Microsoft.AspNetCore.Components.Routing
<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" OnNavigateAsync="@OnNavigateAsync">
<Navigating>
<div style="padding: 20px;background-color:blue;color:white;" id="loading-banner">
<p>Loading the requested page...</p>
</div>
</Navigating>
<Found Context="routeData">
<RouteView RouteData="@routeData" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(RouterTestLayout)">
<div id="test-info">Oops, that component wasn't found!</div>
</LayoutView>
</NotFound>
</Router>
@code {
private Dictionary<string, Func<NavigationContext, Task>> preNavigateTasks = new Dictionary<string, Func<NavigationContext, Task>>()
{
{ "LongPage1", new Func<NavigationContext, Task>(TestLoadingPageShows) },
{ "LongPage2", new Func<NavigationContext, Task>(TestOnNavCancel) }
};
private async Task OnNavigateAsync(NavigationContext args)
{
Console.WriteLine($"Running OnNavigate for {args.Path}...");
Func<NavigationContext, Task> task;
if (preNavigateTasks.TryGetValue(args.Path, out task))
{
await task.Invoke(args);
}
}
public static async Task TestLoadingPageShows(NavigationContext args)
{
await Task.Delay(2000);
}
public static async Task TestOnNavCancel(NavigationContext args)
{
await Task.Delay(2000, args.CancellationToken);
Console.WriteLine("I'm not happening...");
}
}

View File

@ -0,0 +1,16 @@
@page "/WithLazyAssembly"
@using System.Linq
@using System.Reflection
@using Newtonsoft.Json
<p>Just a webpage that uses a lazy-loaded dependency.</p>
<button @onclick="UsePackage" id="use-package-button">Click Me</button>
@code
{
private void UsePackage() {
JsonConvert.DeserializeObject("{ 'type': 'Test' }");
}
}

View File

@ -0,0 +1,5 @@
@page "/WithLazyLoadedRoutes"
<p id="lazy-load-msg">Click the button below to navigate to a route defined in a lazy-loaded component.</p>
<a href="LazyRouteInsidePackage" id="go-to-lazy-route">Click Me</a>

View File

@ -0,0 +1,3 @@
<div id="lazy-component">
This component will be lazily loaded.
</div>

View File

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
<OutputType>library</OutputType>
<StaticWebAssetBasePath>_content/TestContentPackage</StaticWebAssetBasePath>
</PropertyGroup>
<PropertyGroup>
<EnableTypeScriptNuGetTarget>true</EnableTypeScriptNuGetTarget>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Components" />
<Reference Include="Microsoft.AspNetCore.Components.Web" />
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
@page "/LazyRouteInsidePackage"
<div id="lazy-page">
This page will be lazy-loaded.
</div>

View File

@ -0,0 +1 @@
@using Microsoft.AspNetCore.Components.Web

View File

@ -8,7 +8,7 @@
<base href="~/" />
</head>
<body>
<app><component type="typeof(TestRouter)" render-mode="ServerPrerendered" /></app>
<app><component type="typeof(TestRouterWithLazyAssembly)" render-mode="ServerPrerendered" /></app>
@*
So that E2E tests can make assertions about both the prerendered and

View File

@ -4,6 +4,8 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Components.WebAssembly.Services;
using Microsoft.JSInterop;
namespace TestServer
{
@ -22,6 +24,7 @@ namespace TestServer
services.AddMvc();
services.AddServerSideBlazor();
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
services.AddSingleton<LazyAssemblyLoader>();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.

View File

@ -37,15 +37,15 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
var bootJson = ReadBootJsonData(result, Path.Combine(buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json"));
// And that it has been labelled as a dynamic assembly in the boot.json
var dynamicAssemblies = bootJson.resources.dynamicAssembly;
var lazyAssemblies = bootJson.resources.lazyAssembly;
var assemblies = bootJson.resources.assembly;
Assert.NotNull(dynamicAssemblies);
Assert.Contains("RazorClassLibrary.dll", dynamicAssemblies.Keys);
Assert.NotNull(lazyAssemblies);
Assert.Contains("RazorClassLibrary.dll", lazyAssemblies.Keys);
Assert.DoesNotContain("RazorClassLibrary.dll", assemblies.Keys);
// App assembly should not be lazy loaded
Assert.DoesNotContain("blazorwasm.dll", dynamicAssemblies.Keys);
Assert.DoesNotContain("blazorwasm.dll", lazyAssemblies.Keys);
Assert.Contains("blazorwasm.dll", assemblies.Keys);
}
@ -75,15 +75,15 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
var bootJson = ReadBootJsonData(result, Path.Combine(buildOutputDirectory, "wwwroot", "_framework", "blazor.boot.json"));
// And that it has been labelled as a dynamic assembly in the boot.json
var dynamicAssemblies = bootJson.resources.dynamicAssembly;
var lazyAssemblies = bootJson.resources.lazyAssembly;
var assemblies = bootJson.resources.assembly;
Assert.NotNull(dynamicAssemblies);
Assert.Contains("RazorClassLibrary.dll", dynamicAssemblies.Keys);
Assert.NotNull(lazyAssemblies);
Assert.Contains("RazorClassLibrary.dll", lazyAssemblies.Keys);
Assert.DoesNotContain("RazorClassLibrary.dll", assemblies.Keys);
// App assembly should not be lazy loaded
Assert.DoesNotContain("blazorwasm.dll", dynamicAssemblies.Keys);
Assert.DoesNotContain("blazorwasm.dll", lazyAssemblies.Keys);
Assert.Contains("blazorwasm.dll", assemblies.Keys);
}
@ -113,15 +113,15 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
var bootJson = ReadBootJsonData(result, Path.Combine(publishDirectory, "wwwroot", "_framework", "blazor.boot.json"));
// And that it has been labelled as a dynamic assembly in the boot.json
var dynamicAssemblies = bootJson.resources.dynamicAssembly;
var lazyAssemblies = bootJson.resources.lazyAssembly;
var assemblies = bootJson.resources.assembly;
Assert.NotNull(dynamicAssemblies);
Assert.Contains("RazorClassLibrary.dll", dynamicAssemblies.Keys);
Assert.NotNull(lazyAssemblies);
Assert.Contains("RazorClassLibrary.dll", lazyAssemblies.Keys);
Assert.DoesNotContain("RazorClassLibrary.dll", assemblies.Keys);
// App assembly should not be lazy loaded
Assert.DoesNotContain("blazorwasm.dll", dynamicAssemblies.Keys);
Assert.DoesNotContain("blazorwasm.dll", lazyAssemblies.Keys);
Assert.Contains("blazorwasm.dll", assemblies.Keys);
}
@ -151,15 +151,15 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
var bootJson = ReadBootJsonData(result, Path.Combine(publishDirectory, "wwwroot", "_framework", "blazor.boot.json"));
// And that it has been labelled as a dynamic assembly in the boot.json
var dynamicAssemblies = bootJson.resources.dynamicAssembly;
var lazyAssemblies = bootJson.resources.lazyAssembly;
var assemblies = bootJson.resources.assembly;
Assert.NotNull(dynamicAssemblies);
Assert.Contains("RazorClassLibrary.dll", dynamicAssemblies.Keys);
Assert.NotNull(lazyAssemblies);
Assert.Contains("RazorClassLibrary.dll", lazyAssemblies.Keys);
Assert.DoesNotContain("RazorClassLibrary.dll", assemblies.Keys);
// App assembly should not be lazy loaded
Assert.DoesNotContain("blazorwasm.dll", dynamicAssemblies.Keys);
Assert.DoesNotContain("blazorwasm.dll", lazyAssemblies.Keys);
Assert.Contains("blazorwasm.dll", assemblies.Keys);
}

View File

@ -76,10 +76,10 @@ namespace Microsoft.AspNetCore.Razor.Tasks
public Dictionary<string, ResourceHashesByNameDictionary> satelliteResources { get; set; }
/// <summary>
/// Assembly (.dll) resources that are loaded dynamically during runtime
/// Assembly (.dll) resources that are loaded lazily during runtime
/// </summary>
[DataMember(EmitDefaultValue = false)]
public ResourceHashesByNameDictionary dynamicAssembly { get; set; }
public ResourceHashesByNameDictionary lazyAssembly { get; set; }
}
#pragma warning restore IDE1006 // Naming Styles
}

View File

@ -90,8 +90,8 @@ namespace Microsoft.AspNetCore.Razor.Tasks
if (IsLazyLoadedAssembly(fileName))
{
resourceData.dynamicAssembly ??= new ResourceHashesByNameDictionary();
resourceList = resourceData.dynamicAssembly;
resourceData.lazyAssembly ??= new ResourceHashesByNameDictionary();
resourceList = resourceData.lazyAssembly;
}
else if (!string.IsNullOrEmpty(resourceCulture))
{