Skip to content

[release/8.0-rc2] [Blazor] Make auto components prefer the existing render mode #50851

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 5 commits into from
Sep 21, 2023
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.
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.web.js

Large diffs are not rendered by default.

52 changes: 35 additions & 17 deletions src/Components/Web.JS/src/Services/WebRootComponentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ type RootComponentInfo = {
assignedRendererId?: WebRendererId;
uniqueIdAtLastUpdate?: number;
interactiveComponentId?: number;
}
};

export class WebRootComponentManager implements DescriptorHandler, RootComponentManager<never> {
private readonly _rootComponents = new Set<RootComponentInfo>();
Expand Down Expand Up @@ -193,7 +193,7 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent
}

private circuitMayHaveNoRootComponents() {
const isCircuitInUse = this.hasAnyExistingOrPendingServerComponents();
const isCircuitInUse = this.rendererHasExistingOrPendingComponents(WebRendererId.Server, 'server', 'auto');
if (isCircuitInUse) {
// Clear the timeout because we know the circuit is in use.
clearTimeout(this._circuitInactivityTimeoutId);
Expand All @@ -208,31 +208,38 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent

// Start a new timeout to dispose the circuit unless it starts getting used.
this._circuitInactivityTimeoutId = setTimeout(() => {
if (!this.hasAnyExistingOrPendingServerComponents()) {
if (!this.rendererHasExistingOrPendingComponents(WebRendererId.Server, 'server', 'auto')) {
disposeCircuit();
this._circuitInactivityTimeoutId = undefined;
}
}, this._circuitInactivityTimeoutMs) as unknown as number;
}

private hasAnyExistingOrPendingServerComponents(): boolean {
// If there are active Blazor Server components on the page, we shouldn't dispose the circuit.
const renderer = getRendererer(WebRendererId.Server);
if (renderer && renderer.getRootComponentCount() > 0) {
private rendererHasComponents(rendererId: WebRendererId): boolean {
const renderer = getRendererer(rendererId);
return renderer !== undefined && renderer.getRootComponentCount() > 0;
Copy link
Member

Choose a reason for hiding this comment

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

when does getRootComponentCount gets populated? Can we be in a situation where we are starting both runtimes and end up having a similar situation? (Some components get assigned server, others webassembly, based on how quickly the runtimes start.

Also, what happens on situations where we start the server runtime, then navigate and kill it, and render new components? Do we switch to wasm at that point?

Copy link
Member

@SteveSandersonMS SteveSandersonMS Sep 21, 2023

Choose a reason for hiding this comment

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

Can we be in a situation where we are starting both runtimes and end up having a similar situation?

Good thing to check! It looks like the server sends 'JS.AttachComponent' with a descriptor, which triggers attachRootComponentToLogicalElement and thence this.rootComponentIds.add(componentId). So the server renderer will only have components after the circuit is fully established, and not while we're still starting it.

So it does seem possible to have this sequence:

  1. JS starts up and sees some auto component in the page
  2. Without having the wasm files loaded, it resolves auto as server, so it starts establishing a circuit, sending the descriptor for the auto component
  3. Before that process completes, the wasm files finish downloading and then the user does an enhanced nav to a new page that contains another auto component. That component would now resolve as wasm
  4. Then the circuit from step 2 fully starts, and we attach its auto component as server

That would be much more of an edge case than before (where any enhanced nav after starting the circuit could resolve auto as wasm), but seems still possible.

There's already a hasStartedServer function that is used to make sure we don't start a 2nd circuit while the 1st one is still in process of starting, so maybe if hasStartedServer is true but circuit.isDisposedOrDisposing is false, we should resolve auto as server, regardless of whether the server renderer has any components, i.e., the priority order would be:

  1. If wasm components already exist, resolve as wasm
  2. If hasStartedServer && !circuit.isDisposedOrDisposing, resolve as server
  3. If wasm resources are cached, resolve as wasm
  4. Otherwise, resolve as server

Is this correct, @MackinnonBuck? You know this area better and maybe counting server.rootComponentIds is already equivalent.

Copy link
Member Author

@MackinnonBuck MackinnonBuck Sep 21, 2023

Choose a reason for hiding this comment

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

Good catch, and your analysis seems correct to me. I think the right thing to do here would be to go with your suggestion and resolve Auto to use Blazor Server if the circuit is active, even if there aren't Server components on the page.

Copy link
Member Author

@MackinnonBuck MackinnonBuck Sep 21, 2023

Choose a reason for hiding this comment

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

I actually realized that this might lead to less desirable behavior if a page gets loaded with Auto, WebAssembly, and Server components all present. The logic above would resolve the Auto component to use Server because the circuit starts before the first WebAssembly component gets activated (considering the time it takes for the WebAssembly runtime to start). This actually broke an E2E test.

I'm proposing we use the following logic:

  1. If there are any WebAssembly components, or SSR components on the page that would get activated using WebAssembly, resolve to WebAssembly
  2. Same logic as above but for Server
  3. If WebAssembly resources are cached, resolve as WebAssembly
  4. Otherwise, resolve as Server

The "or SSR components on the page that would get activated using WebAssembly/Server" means:

  • An SSR component exists in the document with the "webassembly"/"server" descriptor type, or...
  • A component is in the middle of being activated for interactivity

^ This reuses part of the same logic we already use for determining whether the circuit can be closed.

How does this sound?

}

private rendererHasExistingOrPendingComponents(rendererId: WebRendererId, ...descriptorTypesToConsider: ComponentMarker['type'][]): boolean {
if (this.rendererHasComponents(rendererId)) {
return true;
}

// If we have SSR components that may become Blazor Server components in the future,
// we shouldn't dispose the circuit.
// We consider SSR'd components on the page that may get activated using the specified renderer.
for (const { descriptor: { type }, assignedRendererId } of this._rootComponents) {
if (assignedRendererId === WebRendererId.Server) {
// The component has been assigned to use Blazor Server.
if (assignedRendererId === rendererId) {
// The component has been assigned to use the specified renderer.
return true;
}

if (assignedRendererId === undefined && (type === 'auto' || type === 'server')) {
// The component has not been assigned a renderer yet, so it's possible it might
// use Blazor Server.
if (assignedRendererId !== undefined) {
// The component has been assigned to use another renderer.
continue;
}

if (descriptorTypesToConsider.indexOf(type) !== -1) {
// The component has not been assigned a renderer yet, but it might get activated with the specified renderer
// if it doesn't get removed from the page.
return true;
}
}
Expand Down Expand Up @@ -298,9 +305,20 @@ export class WebRootComponentManager implements DescriptorHandler, RootComponent
}

private getAutoRenderMode(): 'webassembly' | 'server' | null {
// If the WebAssembly runtime has loaded, we will always use WebAssembly
// for auto components. Otherwise, we'll wait to activate root components
// until we determine whether the WebAssembly runtime can be loaded quickly.
// If WebAssembly components exist or may exist soon, use WebAssembly.
if (this.rendererHasExistingOrPendingComponents(WebRendererId.WebAssembly, 'webassembly')) {
return 'webassembly';
}

// If Server components exist or may exist soon, use WebAssembly.
if (this.rendererHasExistingOrPendingComponents(WebRendererId.Server, 'server')) {
return 'server';
}

// If no interactive components are on the page, we use WebAssembly
// if the WebAssembly runtime has loaded. Otherwise, we'll wait to activate
// root components until we determine whether the WebAssembly runtime can be
// loaded quickly.
if (hasLoadedWebAssemblyPlatform()) {
return 'webassembly';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,84 @@ public void AutoRenderMode_CanUseBlazorWebAssembly_WhenMultipleAutoComponentsAre
Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-2")).Text);
}

[Fact]
public void AutoRenderMode_UsesBlazorWebAssembly_WhenBothServerAndWebAssemblyComponentsExist()
{
Navigate($"{ServerPathBase}/streaming-interactivity");
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.Click(By.Id(AddServerPrerenderedId));
Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-1")).Text);
Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-1")).Text);

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

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

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

// We start by adding a WebAssembly component to ensure the WebAssembly runtime
// will be cached after we refresh the page.
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.Click(By.Id($"remove-counter-link-0"));
Browser.DoesNotExist(By.Id("is-interactive-0"));

Browser.Navigate().Refresh();

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

// Verify that Auto mode will use Blazor Server, even though the WebAssembly runtime is cached
Browser.Click(By.Id(AddAutoPrerenderedId));
Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-2")).Text);
Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-2")).Text);
}

[Fact]
public void AutoRenderMode_UsesBlazorServer_AfterWebAssemblyComponentsNoLongerExist_ButServerComponentsDo()
{
Navigate($"{ServerPathBase}/streaming-interactivity");
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.Click(By.Id(AddServerPrerenderedId));
Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-1")).Text);
Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-1")).Text);

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

// Remove all WebAssembly components
Browser.Click(By.Id("remove-counter-link-0"));
Browser.Click(By.Id("remove-counter-link-2"));

// Verify that Blazor Server gets used
Browser.Click(By.Id(AddAutoPrerenderedId));
Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-3")).Text);
Browser.Equal("Server", () => Browser.FindElement(By.Id("render-mode-3")).Text);
}

[Fact]
public void Circuit_ShutsDown_WhenAllBlazorServerComponentsGetRemoved()
{
Expand Down