Skip to content

[Blazor] Improve auto render mode selection strategy #49858

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

Merged
merged 6 commits into from
Aug 8, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.server.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.web.js

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions src/Components/Web.JS/src/Boot.Server.Common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { WebRendererId } from './Rendering/WebRendererId';
import { RootComponentManager } from './Services/RootComponentManager';

let renderingFailed = false;
let hasStarted = false;
let connection: HubConnection;
let circuit: CircuitDescriptor;
let dispatcher: DotNet.ICallDispatcher;
Expand All @@ -33,7 +34,17 @@ export function setCircuitOptions(circuitUserOptions?: Partial<CircuitStartOptio
userOptions = circuitUserOptions;
}

export function hasCircuitStarted() {
return hasStarted;
}

export async function startCircuit(components?: ServerComponentDescriptor[] | RootComponentManager): Promise<void> {
if (hasStarted) {
throw new Error('Blazor Server has already started.');
}

hasStarted = true;

// Establish options to be used
const options = resolveOptions(userOptions);
const jsInitializer = await fetchAndInvokeInitializers(options);
Expand Down
173 changes: 93 additions & 80 deletions src/Components/Web.JS/src/Boot.Web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,150 +9,163 @@
// of interactive components

import { DotNet } from '@microsoft/dotnet-js-interop';
import { setCircuitOptions, startCircuit } from './Boot.Server.Common';
import { loadWebAssemblyPlatform, setWebAssemblyOptions, startWebAssembly } from './Boot.WebAssembly.Common';
import { hasCircuitStarted, setCircuitOptions, startCircuit } from './Boot.Server.Common';
import { hasWebAssemblyStarted, hasWebAssemblyStartedLoading, loadWebAssemblyPlatform, setWebAssemblyOptions, startWebAssembly, waitForBootConfigLoaded } from './Boot.WebAssembly.Common';
import { shouldAutoStart } from './BootCommon';
import { Blazor } from './GlobalExports';
import { WebStartOptions } from './Platform/WebStartOptions';
import { attachStreamingRenderingListener } from './Rendering/StreamingRendering';
import { NavigationEnhancementCallbacks, attachProgressivelyEnhancedNavigationListener, isPerformingEnhancedPageLoad } from './Services/NavigationEnhancement';
import { attachProgressivelyEnhancedNavigationListener } from './Services/NavigationEnhancement';
import { ComponentDescriptor } from './Services/ComponentDescriptorDiscovery';
import { RootComponentManager, attachAutoModeResolver } from './Services/RootComponentManager';
import { RootComponentManager } from './Services/RootComponentManager';
import { DescriptorHandler, attachComponentDescriptorHandler, registerAllComponentDescriptors } from './Rendering/DomMerging/DomSync';
import { waitForRendererAttached } from './Rendering/WebRendererInteropMethods';
import { WebRendererId } from './Rendering/WebRendererId';

enum WebAssemblyLoadingState {
None = 0,
Loading = 1,
Loaded = 2,
Starting = 3,
Started = 4,
}
import { MonoConfig } from 'dotnet';

let started = false;
let hasCircuitStarted = false;
let webAssemblyLoadingState = WebAssemblyLoadingState.None;
let autoModeTimeoutState: undefined | 'waiting' | 'timed out';
const autoModeWebAssemblyTimeoutMilliseconds = 100;

let loadWebAssemblyQuicklyPromise: Promise<boolean> | null = null;
const loadWebAssemblyQuicklyTimeoutMs = 3000;
const rootComponentManager = new RootComponentManager();

function boot(options?: Partial<WebStartOptions>) : Promise<void> {
if (started) {
throw new Error('Blazor has already started.');
}

started = true;

setCircuitOptions(options?.circuit);
setWebAssemblyOptions(options?.webAssembly);

const navigationEnhancementCallbacks: NavigationEnhancementCallbacks = {
documentUpdated: handleUpdatedComponentDescriptors,
};

const descriptorHandler: DescriptorHandler = {
registerComponentDescriptor,
};

attachComponentDescriptorHandler(descriptorHandler);
attachStreamingRenderingListener(options?.ssr, navigationEnhancementCallbacks);
attachAutoModeResolver(resolveAutoMode);
attachStreamingRenderingListener(options?.ssr, rootComponentManager);

if (!options?.ssr?.disableDomPreservation) {
attachProgressivelyEnhancedNavigationListener(navigationEnhancementCallbacks);
attachProgressivelyEnhancedNavigationListener(rootComponentManager);
}

registerAllComponentDescriptors(document);
handleUpdatedComponentDescriptors();

return Promise.resolve();
}

function resolveAutoMode(): 'server' | 'webassembly' | null {
if (webAssemblyLoadingState >= WebAssemblyLoadingState.Loaded) {
// The WebAssembly runtime has loaded or is actively starting, so we'll use
// WebAssembly for the component in question. We'll also start
// the WebAssembly runtime if it hasn't already.
startWebAssemblyIfNotStarted();
return 'webassembly';
}

if (autoModeTimeoutState === 'timed out') {
// We've waited too long for WebAssembly to initialize, so we'll use the Server
// render mode for the component in question. At some point if the WebAssembly
// runtime finishes loading, we'll start using it again due to the earlier
// check in this function.
startCircuitIfNotStarted();
return 'server';
}

if (autoModeTimeoutState === undefined) {
// The WebAssembly runtime hasn't loaded yet, and this is the first
// time auto mode is being requested.
// We'll wait a bit for the WebAssembly runtime to load before making
// a render mode decision.
autoModeTimeoutState = 'waiting';
setTimeout(() => {
autoModeTimeoutState = 'timed out';

// We want to ensure that we activate any markers whose render mode didn't get resolved
// earlier.
handleUpdatedComponentDescriptors();
}, autoModeWebAssemblyTimeoutMilliseconds);
}

return null;
}

function registerComponentDescriptor(descriptor: ComponentDescriptor) {
rootComponentManager.registerComponentDescriptor(descriptor);

if (descriptor.type === 'auto') {
startLoadingWebAssemblyIfNotStarted();
startAutoModeRuntimeIfNotStarted();
} else if (descriptor.type === 'server') {
startCircuitIfNotStarted();
} else if (descriptor.type === 'webassembly') {
startWebAssemblyIfNotStarted();
}
}

function handleUpdatedComponentDescriptors() {
const shouldAddNewRootComponents = !isPerformingEnhancedPageLoad();
rootComponentManager.handleUpdatedRootComponents(shouldAddNewRootComponents);
async function startAutoModeRuntimeIfNotStarted() {
if (hasWebAssemblyStarted()) {
return;
}

const didLoadWebAssemblyQuickly = await tryLoadWebAssemblyQuicklyIfNotStarted();
if (didLoadWebAssemblyQuickly) {
// We'll start the WebAssembly runtime so it starts getting used for auto components.
await startWebAssemblyIfNotStarted();
} else if (!hasWebAssemblyStarted()) {
// WebAssembly could not load quickly. We notify the root component manager of this fact
// so it starts using Blazor Server to activate auto components rather than waiting
// for the WebAssembly runtime to start.
rootComponentManager.notifyWebAssemblyFailedToLoadQuickly();
await startCircuitIfNotStarted();
}
}

function tryLoadWebAssemblyQuicklyIfNotStarted(): Promise<boolean> {
if (loadWebAssemblyQuicklyPromise) {
return loadWebAssemblyQuicklyPromise;
}

const loadPromise = (async () => {
const loadWebAssemblyPromise = loadWebAssemblyIfNotStarted();
const bootConfig = await waitForBootConfigLoaded();
if (!areWebAssemblyResourcesLikelyCached(bootConfig)) {
// Since we don't think WebAssembly resources are cached,
// we can guess that we'll need to fetch resources over the network.
// Therefore, we'll fall back to Blazor Server for now.
return false;
}
await loadWebAssemblyPromise;
return true;
})();

const timeoutPromise = new Promise<boolean>(resolve => {
// If WebAssembly takes too long to load even though we think the resources
// are cached, we'll fall back to Blazor Server.
setTimeout(resolve, loadWebAssemblyQuicklyTimeoutMs, false);
});

loadWebAssemblyQuicklyPromise = Promise.race([loadPromise, timeoutPromise]);
return loadWebAssemblyQuicklyPromise;
}

async function startCircuitIfNotStarted() {
if (hasCircuitStarted) {
if (hasCircuitStarted()) {
return;
}

hasCircuitStarted = true;
await startCircuit(rootComponentManager);
await waitForRendererAttached(WebRendererId.Server);
handleUpdatedComponentDescriptors();
}

async function startLoadingWebAssemblyIfNotStarted() {
if (webAssemblyLoadingState >= WebAssemblyLoadingState.Loading) {
async function loadWebAssemblyIfNotStarted() {
if (hasWebAssemblyStartedLoading()) {
return;
}

webAssemblyLoadingState = WebAssemblyLoadingState.Loading;
await loadWebAssemblyPlatform();
webAssemblyLoadingState = WebAssemblyLoadingState.Loaded;

const config = await waitForBootConfigLoaded();
const hash = getWebAssemblyResourceHash(config);
if (hash) {
window.localStorage.setItem(hash.key, hash.value);
}
}

async function startWebAssemblyIfNotStarted() {
if (webAssemblyLoadingState >= WebAssemblyLoadingState.Starting) {
if (hasWebAssemblyStarted()) {
return;
}

webAssemblyLoadingState = WebAssemblyLoadingState.Starting;
loadWebAssemblyIfNotStarted();
await startWebAssembly(rootComponentManager);
await waitForRendererAttached(WebRendererId.WebAssembly);
webAssemblyLoadingState = WebAssemblyLoadingState.Started;
handleUpdatedComponentDescriptors();
}

function areWebAssemblyResourcesLikelyCached(loadedConfig: MonoConfig): boolean {
if (!loadedConfig.cacheBootResources) {
return false;
}

const hash = getWebAssemblyResourceHash(loadedConfig);
if (!hash) {
return false;
}

const existingHash = window.localStorage.getItem(hash.key);
return hash.value === existingHash;
}

function getWebAssemblyResourceHash(config: MonoConfig): { key: string, value: string } | null {
const hash = config.resources?.hash;
const mainAssemblyName = config.mainAssemblyName;
if (!hash || !mainAssemblyName) {
return null;
}

return {
key: `blazor-resource-hash:${mainAssemblyName}`,
value: hash,
};
}

Blazor.start = boot;
Expand Down
27 changes: 26 additions & 1 deletion src/Components/Web.JS/src/Boot.WebAssembly.Common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,16 @@ import { WebAssemblyComponentDescriptor, discoverPersistedState } from './Servic
import { receiveDotNetDataStream } from './StreamingInterop';
import { WebAssemblyComponentAttacher } from './Platform/WebAssemblyComponentAttacher';
import { RootComponentManager } from './Services/RootComponentManager';
import { MonoConfig } from 'dotnet';

let options: Partial<WebAssemblyStartOptions> | undefined;
let platformLoadPromise: Promise<void> | undefined;
let hasStarted = false;

let resolveBootConfigPromise: (value: MonoConfig) => void;
const bootConfigPromise = new Promise<MonoConfig>(resolve => {
resolveBootConfigPromise = resolve;
});

export function setWebAssemblyOptions(webAssemblyOptions?: Partial<WebAssemblyStartOptions>) {
if (options) {
Expand All @@ -28,6 +35,12 @@ export function setWebAssemblyOptions(webAssemblyOptions?: Partial<WebAssemblySt
}

export async function startWebAssembly(components?: WebAssemblyComponentDescriptor[] | RootComponentManager): Promise<void> {
if (hasStarted) {
throw new Error('Blazor WebAssembly has already started.');
}

hasStarted = true;

if (inAuthRedirectIframe()) {
// eslint-disable-next-line @typescript-eslint/no-empty-function
await new Promise(() => { }); // See inAuthRedirectIframe for explanation
Expand Down Expand Up @@ -134,8 +147,20 @@ export async function startWebAssembly(components?: WebAssemblyComponentDescript
api.invokeLibraryInitializers('afterStarted', [Blazor]);
}

export function hasWebAssemblyStarted(): boolean {
return hasStarted;
}

export function hasWebAssemblyStartedLoading(): boolean {
return platformLoadPromise !== undefined;
}

export function waitForBootConfigLoaded(): Promise<MonoConfig> {
return bootConfigPromise;
}

export function loadWebAssemblyPlatform(): Promise<void> {
platformLoadPromise ??= monoPlatform.load(options ?? {});
platformLoadPromise ??= monoPlatform.load(options ?? {}, resolveBootConfigPromise);
return platformLoadPromise;
}

Expand Down
12 changes: 7 additions & 5 deletions src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ function getValueU64(ptr: number) {
}

export const monoPlatform: Platform = {
load: function load(options: Partial<WebAssemblyStartOptions>) {
return createRuntimeInstance(options);
load: function load(options: Partial<WebAssemblyStartOptions>, onConfigLoaded?: (loadedConfig: MonoConfig) => void) {
return createRuntimeInstance(options, onConfigLoaded);
},

start: function start() {
Expand Down Expand Up @@ -174,7 +174,7 @@ async function importDotnetJs(startOptions: Partial<WebAssemblyStartOptions>): P
return await import(/* webpackIgnore: true */ absoluteSrc);
}

function prepareRuntimeConfig(options: Partial<WebAssemblyStartOptions>): DotnetModuleConfig {
function prepareRuntimeConfig(options: Partial<WebAssemblyStartOptions>, onConfigLoadedCallback?: (loadedConfig: MonoConfig) => void): DotnetModuleConfig {
const config: MonoConfig = {
maxParallelDownloads: 1000000, // disable throttling parallel downloads
enableDownloadRetry: false, // disable retry downloads
Expand All @@ -192,6 +192,8 @@ function prepareRuntimeConfig(options: Partial<WebAssemblyStartOptions>): Dotnet

Blazor._internal.getApplicationEnvironment = () => loadedConfig.applicationEnvironment!;

onConfigLoadedCallback?.(loadedConfig);

const initializerArguments = [options, loadedConfig.resources?.extensions ?? {}];
await invokeLibraryInitializers('beforeStart', initializerArguments);
};
Expand All @@ -210,9 +212,9 @@ function prepareRuntimeConfig(options: Partial<WebAssemblyStartOptions>): Dotnet
return dotnetModuleConfig;
}

async function createRuntimeInstance(options: Partial<WebAssemblyStartOptions>): Promise<void> {
async function createRuntimeInstance(options: Partial<WebAssemblyStartOptions>, onConfigLoaded?: (loadedConfig: MonoConfig) => void): Promise<void> {
const { dotnet } = await importDotnetJs(options);
const moduleConfig = prepareRuntimeConfig(options);
const moduleConfig = prepareRuntimeConfig(options, onConfigLoaded);

if (options.applicationCulture) {
dotnet.withApplicationCulture(options.applicationCulture);
Expand Down
3 changes: 2 additions & 1 deletion src/Components/Web.JS/src/Platform/Platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@

import { MonoObject, MonoString, MonoArray } from 'dotnet/dotnet-legacy';
import { WebAssemblyStartOptions } from './WebAssemblyStartOptions';
import { MonoConfig } from 'dotnet';

export interface Platform {
load(options: Partial<WebAssemblyStartOptions>): Promise<void>;
load(options: Partial<WebAssemblyStartOptions>, onConfigLoaded?: (loadedConfig: MonoConfig) => void): Promise<void>;
start(): Promise<PlatformApi>;

callEntryPoint(): Promise<unknown>;
Expand Down
Loading