Skip to content
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

[Blazor] Auto render mode improvements #53159

Merged
merged 7 commits into from Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 0 additions & 1 deletion src/Components/Web.JS/src/Boot.Web.ts
Expand Up @@ -37,7 +37,6 @@ function boot(options?: Partial<WebStartOptions>) : Promise<void> {
started = true;
options = options || {};
options.logLevel ??= LogLevel.Error;
Blazor._internal.loadWebAssemblyQuicklyTimeout = 3000;

// Defined here to avoid inadvertently imported enhanced navigation
// related APIs in WebAssembly or Blazor Server contexts.
Expand Down
1 change: 0 additions & 1 deletion src/Components/Web.JS/src/GlobalExports.ts
Expand Up @@ -78,7 +78,6 @@ export interface IBlazor {
receiveWebAssemblyDotNetDataStream?: (streamId: number, data: any, bytesRead: number, errorMessage: string) => void;
receiveWebViewDotNetDataStream?: (streamId: number, data: any, bytesRead: number, errorMessage: string) => void;
attachWebRendererInterop?: typeof attachWebRendererInterop;
loadWebAssemblyQuicklyTimeout?: number;

// JSExport APIs
dotNetExports?: {
Expand Down
35 changes: 18 additions & 17 deletions src/Components/Web.JS/src/Services/WebRootComponentManager.ts
Expand Up @@ -9,7 +9,6 @@ import { disposeCircuit, hasStartedServer, isCircuitAvailable, startCircuit, sta
import { hasLoadedWebAssemblyPlatform, hasStartedLoadingWebAssemblyPlatform, hasStartedWebAssembly, isFirstUpdate, loadWebAssemblyPlatformIfNotStarted, resolveInitialUpdate, setWaitForRootComponents, startWebAssembly, updateWebAssemblyRootComponents, waitForBootConfigLoaded } from '../Boot.WebAssembly.Common';
import { MonoConfig } from 'dotnet-runtime';
import { RootComponentManager } from './RootComponentManager';
import { Blazor } from '../GlobalExports';
import { getRendererer } from '../Rendering/Renderer';
import { isPageLoading } from './NavigationEnhancement';
import { setShouldPreserveContentOnInteractiveComponentDisposal } from '../Rendering/BrowserRenderer';
Expand Down Expand Up @@ -100,12 +99,18 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent
return;
}

if (descriptor.type === 'auto' || descriptor.type === 'webassembly') {
// Eagerly start loading the WebAssembly runtime, even though we're not
// activating the component yet. This is becuase WebAssembly resources
// may take a long time to load, so starting to load them now potentially reduces
// the time to interactvity.
// When encountering a component with a WebAssembly or Auto render mode,
// start loading the WebAssembly runtime, even though we're not
// activating the component yet. This is becuase WebAssembly resources
// may take a long time to load, so starting to load them now potentially reduces
// the time to interactvity.
if (descriptor.type === 'webassembly') {
this.startLoadingWebAssemblyIfNotStarted();
} else if (descriptor.type === 'auto') {
// If the WebAssembly runtime starts downloading because an Auto component was added to
// the page, we limit the maximum number of parallel WebAssembly resource downloads to 1
// so that the performance of any Blazor Server circuit is minimally impacted.
this.startLoadingWebAssemblyIfNotStarted(/* maxParallelDownloadsOverride */ 1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean if there's a combination of WebAssembly and Auto components on the page, the maxParallelDownloadsOverride value will end up being determined by which of them appeared first in the DOM?

If so that's slightly quirky but most likely not a problem in real life, as people will almost never mix both of those modes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with Steve here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might be able to partially mitigate this by changing this method to accept the full set of discovered ComponentDescriptors all at once. Then if any descriptors have type 'webassembly', download WebAssembly resources with full (or user-specified) parallelism. Otherwise, if there are any 'auto' descriptors, download WebAssembly resources with no parallelism. But considering that mixing 'auto' and 'webassembly' is an uncommon use case, and that it's preferable to keep servicing changes minimal, I would propose that we make any further adjustments in .NET 9.

Long-term, it might be preferable to be able to dynamically change the throttling amount so that if a WebAssembly component appears on the page after an Auto component already initiated the downloading of WebAssembly resources, we would switch to downloading with full parallelism, so the WebAssembly component becomes interactive as quickly as possible.

I'll open an issue to track this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}

const ssrComponentId = this._nextSsrComponentId++;
Expand All @@ -120,26 +125,20 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent
this.circuitMayHaveNoRootComponents();
}

private async startLoadingWebAssemblyIfNotStarted() {
private async startLoadingWebAssemblyIfNotStarted(maxParallelDownloadsOverride?: number) {
if (hasStartedLoadingWebAssemblyPlatform()) {
return;
}

setWaitForRootComponents();

const loadWebAssemblyPromise = loadWebAssemblyPlatformIfNotStarted();

// If WebAssembly resources can't be loaded within some time limit,
// we take note of this fact so that "auto" components fall back
// to using Blazor Server.
setTimeout(() => {
if (!hasLoadedWebAssemblyPlatform()) {
this.onWebAssemblyFailedToLoadQuickly();
}
}, Blazor._internal.loadWebAssemblyQuicklyTimeout);

const bootConfig = await waitForBootConfigLoaded();

if (maxParallelDownloadsOverride !== undefined) {
bootConfig.maxParallelDownloads = maxParallelDownloadsOverride;
}

if (!areWebAssemblyResourcesLikelyCached(bootConfig)) {
// Since WebAssembly resources aren't likely cached,
// they will probably need to be fetched over the network.
Expand Down Expand Up @@ -299,6 +298,8 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent
this.updateWebAssemblyRootComponents(batchJson);
}
}

this.circuitMayHaveNoRootComponents();
}

private updateWebAssemblyRootComponents(operationsJson: string) {
Expand Down
Expand Up @@ -597,22 +597,6 @@ public void DynamicallyAddedSsrComponents_CanGetRemoved_BeforeStreamingRendering
AssertBrowserLogDoesNotContainErrors();
}

[Fact]
public void AutoRenderMode_UsesBlazorServer_IfWebAssemblyResourcesTakeTooLongToLoad()
{
Navigate(ServerPathBase);
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);
ForceWebAssemblyResourceCacheMiss();
BlockWebAssemblyResourceLoad();

Navigate($"{ServerPathBase}/streaming-interactivity");
Browser.Equal("Not streaming", () => Browser.FindElement(By.Id("status")).Text);

Browser.Click(By.Id(AddAutoPrerenderedId));
Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-0")).Text);
Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-0")).Text);
}

[Fact]
public void AutoRenderMode_UsesBlazorWebAssembly_AfterAddingWebAssemblyRootComponent()
{
Expand Down Expand Up @@ -659,8 +643,6 @@ public void AutoRenderMode_UsesBlazorServerOnFirstLoad_ThenWebAssemblyOnSuccessi
Navigate(ServerPathBase);
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);
BlockWebAssemblyResourceLoad();
UseLongWebAssemblyLoadTimeout();
ForceWebAssemblyResourceCacheMiss();

Navigate($"{ServerPathBase}/streaming-interactivity");
Browser.Equal("Not streaming", () => Browser.FindElement(By.Id("status")).Text);
Expand Down Expand Up @@ -697,8 +679,6 @@ public void AutoRenderMode_UsesBlazorServer_IfBootResourceHashChanges()
Navigate(ServerPathBase);
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);
BlockWebAssemblyResourceLoad();
UseLongWebAssemblyLoadTimeout();
ForceWebAssemblyResourceCacheMiss();

Navigate($"{ServerPathBase}/streaming-interactivity");
Browser.Equal("Not streaming", () => Browser.FindElement(By.Id("status")).Text);
Expand All @@ -715,14 +695,11 @@ public void AutoRenderMode_UsesBlazorServer_IfBootResourceHashChanges()
Browser.Click(By.Id($"remove-counter-link-1"));
Browser.DoesNotExist(By.Id("is-interactive-1"));

UseLongWebAssemblyLoadTimeout();
Browser.Navigate().Refresh();

Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-0")).Text);
Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-0")).Text);

BlockWebAssemblyResourceLoad();
UseLongWebAssemblyLoadTimeout();
ForceWebAssemblyResourceCacheMiss("dummy hash");
Browser.Navigate().Refresh();

Expand Down Expand Up @@ -766,8 +743,6 @@ public void AutoRenderMode_CanUseBlazorServer_WhenMultipleAutoComponentsAreAdded
Navigate(ServerPathBase);
Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);
BlockWebAssemblyResourceLoad();
UseLongWebAssemblyLoadTimeout();
ForceWebAssemblyResourceCacheMiss();

Navigate($"{ServerPathBase}/streaming-interactivity");
Browser.Equal("Not streaming", () => Browser.FindElement(By.Id("status")).Text);
Expand Down Expand Up @@ -911,6 +886,36 @@ public void AutoRenderMode_UsesBlazorServer_AfterWebAssemblyComponentsNoLongerEx
Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-3")).Text);
}

[Fact]
public void WebAssemblyRenderMode_DownloadsWebAssemblyResourcesInParallel()
{
Navigate($"{ServerPathBase}/streaming-interactivity?ClearSiteData=True");
Browser.Equal("Not streaming", () => Browser.FindElement(By.Id("status")).Text);

Browser.Click(By.Id(AddWebAssemblyPrerenderedId));
Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-0")).Text);
Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-0")).Text);

Browser.True(() => GetMaxParallelWebAssemblyResourceDownloadCount() > 1);
}

[Fact]
public void AutoRenderMode_DoesNotDownloadWebAssemblyResourcesInParallel()
{
Navigate($"{ServerPathBase}/streaming-interactivity?ClearSiteData=True");
Browser.Equal("Not streaming", () => Browser.FindElement(By.Id("status")).Text);

Browser.Click(By.Id(AddAutoPrerenderedId));
Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-0")).Text);
Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-0")).Text);

Browser.Click(By.Id(AddWebAssemblyPrerenderedId));
Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-1")).Text);
Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-1")).Text);

Browser.Equal(1, GetMaxParallelWebAssemblyResourceDownloadCount);
}

[Fact]
public void Circuit_ShutsDown_WhenAllBlazorServerComponentsGetRemoved()
{
Expand Down Expand Up @@ -1135,6 +1140,9 @@ public void NavigationManagerCanRefreshSSRPageWhenServerInteractivityEnabled()

private void BlockWebAssemblyResourceLoad()
{
// Force a WebAssembly resource cache miss so that we can fall back to using server interactivity
ForceWebAssemblyResourceCacheMiss();

((IJavaScriptExecutor)Browser).ExecuteScript("sessionStorage.setItem('block-load-boot-resource', 'true')");

// Clear caches so that we can block the resource load
Expand All @@ -1146,11 +1154,6 @@ private void UnblockWebAssemblyResourceLoad()
((IJavaScriptExecutor)Browser).ExecuteScript("window.unblockLoadBootResource()");
}

private void UseLongWebAssemblyLoadTimeout()
{
((IJavaScriptExecutor)Browser).ExecuteScript("sessionStorage.setItem('use-long-auto-timeout', 'true')");
}

private void ForceWebAssemblyResourceCacheMiss(string resourceHash = null)
{
if (resourceHash is not null)
Expand All @@ -1164,6 +1167,11 @@ private void ForceWebAssemblyResourceCacheMiss(string resourceHash = null)
}
}

private long GetMaxParallelWebAssemblyResourceDownloadCount()
{
return (long)((IJavaScriptExecutor)Browser).ExecuteScript("return window['__aspnetcore__testing__max__parallel__resource__download__count'] || 0;");
}

private string InteractiveCallsiteUrl(bool prerender, int? serverIncrement = default, int? webAssemblyIncrement = default)
{
var result = $"{ServerPathBase}/interactive-callsite?suppress-autostart&prerender={prerender}";
Expand Down
31 changes: 26 additions & 5 deletions src/Components/test/E2ETest/Tests/StatePersistenceTest.cs
Expand Up @@ -31,7 +31,7 @@ public class StatePersistenceTest : ServerTestBase<BasicTestAppServerSiteFixture
public override Task InitializeAsync()
=> InitializeAsync(BrowserFixture.StreamingContext + _nextStreamingIdContext++);

// Validates that we can use persisted state across server, webasembly, and auto modes, with and without
// Validates that we can use persisted state across server, webassembly, and auto modes, with and without
// streaming rendering.
// For streaming rendering, we validate that the state is captured and restored after streaming completes.
// For enhanced navigation we validate that the state is captured at the time components are rendered for
Expand Down Expand Up @@ -101,6 +101,12 @@ public void CanRenderComponentWithPersistedState(bool suppressEnhancedNavigation
RenderComponentsWithPersistentStateAndValidate(suppressEnhancedNavigation, mode, renderMode, streaming, interactiveRuntime: "server");

UnblockWebAssemblyResourceLoad();

if (suppressEnhancedNavigation)
{
RenderWebAssemblyComponentAndWaitForWebAssemblyRuntime(returnUrl: Browser.Url);
}

Browser.Navigate().Refresh();

RenderComponentsWithPersistentStateAndValidate(suppressEnhancedNavigation, mode, renderMode, streaming, interactiveRuntime: "wasm");
Expand All @@ -123,16 +129,19 @@ public async Task StateIsProvidedEveryTimeACircuitGetsCreated(string streaming)
}
Browser.Click(By.Id("page-with-components-link"));

RenderComponentsWithPersistentStateAndValidate(suppresEnhancedNavigation: false, mode, typeof(InteractiveServerRenderMode), streaming);
RenderComponentsWithPersistentStateAndValidate(suppressEnhancedNavigation: false, mode, typeof(InteractiveServerRenderMode), streaming);
Browser.Click(By.Id("page-no-components-link"));
// Ensure that the circuit is gone.
await Task.Delay(1000);
Browser.Click(By.Id("page-with-components-link-and-state"));
RenderComponentsWithPersistentStateAndValidate(suppresEnhancedNavigation: false, mode, typeof(InteractiveServerRenderMode), streaming, stateValue: "other");
RenderComponentsWithPersistentStateAndValidate(suppressEnhancedNavigation: false, mode, typeof(InteractiveServerRenderMode), streaming, stateValue: "other");
}

private void BlockWebAssemblyResourceLoad()
{
// Clear local storage so that the resource hash is not found
((IJavaScriptExecutor)Browser).ExecuteScript("localStorage.clear()");

((IJavaScriptExecutor)Browser).ExecuteScript("sessionStorage.setItem('block-load-boot-resource', 'true')");

// Clear caches so that we can block the resource load
Expand All @@ -145,7 +154,7 @@ private void UnblockWebAssemblyResourceLoad()
}

private void RenderComponentsWithPersistentStateAndValidate(
bool suppresEnhancedNavigation,
bool suppressEnhancedNavigation,
string mode,
Type renderMode,
string streaming,
Expand All @@ -154,7 +163,7 @@ private void UnblockWebAssemblyResourceLoad()
{
stateValue ??= "restored";
// No need to navigate if we are using enhanced navigation, the tests will have already navigated to the page via a link.
if (suppresEnhancedNavigation)
if (suppressEnhancedNavigation)
{
// In this case we suppress auto start to check some server side state before we boot Blazor.
if (streaming == null)
Expand Down Expand Up @@ -232,6 +241,18 @@ private void UnblockWebAssemblyResourceLoad()
}
}

private void RenderWebAssemblyComponentAndWaitForWebAssemblyRuntime(string returnUrl = null)
{
Navigate("subdir/persistent-state/page-with-webassembly-interactivity");

Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-counter")).Text);

if (returnUrl is not null)
{
Navigate(returnUrl);
}
}

private void SuppressEnhancedNavigation(bool shouldSuppress)
=> EnhancedNavigationTestUtil.SuppressEnhancedNavigation(this, shouldSuppress);
}
Expand Up @@ -21,10 +21,8 @@
const enableClassicInitializers = sessionStorage.getItem('enable-classic-initializers') === 'true';
const suppressEnhancedNavigation = sessionStorage.getItem('suppress-enhanced-navigation') === 'true';
const blockLoadBootResource = sessionStorage.getItem('block-load-boot-resource') === 'true';
const useLongWebAssemblyTimeout = sessionStorage.getItem('use-long-auto-timeout') === 'true';
sessionStorage.removeItem('suppress-enhanced-navigation');
sessionStorage.removeItem('block-load-boot-resource');
sessionStorage.removeItem('use-long-auto-timeout');
sessionStorage.removeItem('enable-classic-initializers');

let loadBootResourceUnblocked = null;
Expand All @@ -34,6 +32,9 @@
});
}

let maxParallelResourceDownloadCount = 0;
let currentParallelResourceDownloadCount = 0;

function callBlazorStart() {
Blazor.start({
ssr: {
Expand All @@ -55,19 +56,21 @@
// The following allows us to arbitrarily delay the loading of WebAssembly resources.
// This is useful for guaranteeing that auto mode components will fall back on
// using Blazor server.
currentParallelResourceDownloadCount++;
return fetch(`${document.baseURI}WasmMinimal/_framework/${name}?`, {
method: "GET",
}).then(async (response) => {
if (currentParallelResourceDownloadCount > maxParallelResourceDownloadCount) {
maxParallelResourceDownloadCount = currentParallelResourceDownloadCount;
window['__aspnetcore__testing__max__parallel__resource__download__count'] = maxParallelResourceDownloadCount;
}
currentParallelResourceDownloadCount--;
await loadBootResourceUnblocked;
return response;
});
}
},
},
}).then(() => {
if (useLongWebAssemblyTimeout) {
Blazor._internal.loadWebAssemblyQuicklyTimeout = 10000000;
}
}).then(() => {
const startedParagraph = document.createElement('p');
startedParagraph.id = 'blazor-started';
Expand Down
Expand Up @@ -100,6 +100,9 @@ else
ComponentState _state = new(ImmutableArray<CounterInfo>.Empty, NextCounterId: 0);
bool _isStreaming = false;

[CascadingParameter]
public HttpContext HttpContext { get; set; }

[SupplyParameterFromQuery]
public string? InitialState { get; set; }

Expand All @@ -109,13 +112,21 @@ else
[SupplyParameterFromQuery]
public bool DisableKeys { get; set; }

[SupplyParameterFromQuery]
public bool ClearSiteData { get; set; }

protected override async Task OnInitializedAsync()
{
if (InitialState is not null)
{
_state = ReadStateFromJson(InitialState);
}

if (ClearSiteData)
{
HttpContext.Response.Headers["Clear-Site-Data"] = "\"*\"";
}

if (ShouldStream)
{
_isStreaming = true;
Expand Down
@@ -0,0 +1,8 @@
@page "/persistent-state/page-with-webassembly-interactivity"

<p>
This page is used to ensure that the WebAssembly runtime is downloaded and available
so that WebAssembly interactivity will get used for components with the Auto render mode
</p>

<TestContentPackage.Counter @rendermode="RenderMode.InteractiveWebAssembly" IncrementAmount="1" IdSuffix="counter" />