Client reconnects when state's available on the server (#7395)

Reconnect if we have state on the server

Fixes #7537
This commit is contained in:
Pranav K 2019-03-04 17:27:51 -08:00 committed by GitHub
parent d09c6e8576
commit 33839dc66a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 7166 additions and 777 deletions

View File

@ -38,7 +38,7 @@
</Target>
<ItemGroup>
<ProjectReference Condition="'$(BuildNodeJS)' != 'false'" Include="$(RepositoryRoot)src\Components\Browser.JS\src\Microsoft.AspNetCore.Components.Browser.JS.npmproj" ReferenceOutputAssembly="false" />
<ProjectReference Condition="'$(BuildNodeJS)' != 'false'" Include="$(RepositoryRoot)src\Components\Browser.JS\Microsoft.AspNetCore.Components.Browser.JS.npmproj" ReferenceOutputAssembly="false" />
<Reference Include="Microsoft.AspNetCore.Components" />
<Reference Include="Microsoft.Extensions.CommandLineUtils.Sources" />
<Reference Include="Microsoft.Extensions.FileProviders.Composite" />

View File

@ -2,7 +2,7 @@
<Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), Directory.Build.props))\Directory.Build.props" />
<PropertyGroup>
<IsTestProject>false</IsTestProject>
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
</PropertyGroup>

View File

@ -0,0 +1,14 @@
// 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.
module.exports = {
globals: {
"ts-jest": {
"tsConfig": "./tests/tsconfig.json",
"babeConfig": true,
"diagnostics": true
}
},
preset: 'ts-jest',
testEnvironment: 'jsdom'
};

View File

@ -5,15 +5,19 @@
"description": "",
"main": "index.js",
"scripts": {
"build:debug": "webpack --mode development",
"build:production": "webpack --mode production",
"test": "echo \"Error: no test specified\" && exit 1"
"build:debug": "cd src && webpack --mode development --config ./webpack.config.js",
"build:production": "cd src && webpack --mode production --config ./webpack.config.js",
"test": "jest"
},
"devDependencies": {
"@aspnet/signalr": "^1.0.0",
"@aspnet/signalr-protocol-msgpack": "^1.0.0",
"@dotnet/jsinterop": "^0.1.1",
"@types/jsdom": "11.0.6",
"@types/jest": "^24.0.6",
"@types/emscripten": "0.0.31",
"jest": "^24.1.0",
"ts-jest": "^24.0.0",
"ts-loader": "^4.4.1",
"typescript": "^2.9.2",
"webpack": "^4.12.0",

View File

@ -6,16 +6,43 @@ import { OutOfProcessRenderBatch } from './Rendering/RenderBatch/OutOfProcessRen
import { internalFunctions as uriHelperFunctions } from './Services/UriHelper';
import { renderBatch } from './Rendering/Renderer';
import { fetchBootConfigAsync, loadEmbeddedResourcesAsync } from './BootCommon';
import { CircuitHandler } from './Platform/Circuits/CircuitHandler';
import { AutoReconnectCircuitHandler } from './Platform/Circuits/AutoReconnectCircuitHandler';
let connection : signalR.HubConnection;
async function boot() {
const circuitHandlers: CircuitHandler[] = [ new AutoReconnectCircuitHandler() ];
window['Blazor'].circuitHandlers = circuitHandlers;
function boot() {
// In the background, start loading the boot config and any embedded resources
const embeddedResourcesPromise = fetchBootConfigAsync().then(bootConfig => {
return loadEmbeddedResourcesAsync(bootConfig);
});
connection = new signalR.HubConnectionBuilder()
const initialConnection = await initializeConnection(circuitHandlers);
// Ensure any embedded resources have been loaded before starting the app
await embeddedResourcesPromise;
const circuitId = await initialConnection.invoke<string>(
'StartCircuit',
uriHelperFunctions.getLocationHref(),
uriHelperFunctions.getBaseURI()
);
window['Blazor'].reconnect = async () => {
const reconnection = await initializeConnection(circuitHandlers);
if (!(await reconnection.invoke<Boolean>('ConnectCircuit', circuitId))) {
return false;
}
circuitHandlers.forEach(h => h.onConnectionUp && h.onConnectionUp());
return true;
};
circuitHandlers.forEach(h => h.onConnectionUp && h.onConnectionUp());
}
async function initializeConnection(circuitHandlers: CircuitHandler[]): Promise<signalR.HubConnection> {
const connection = new signalR.HubConnectionBuilder()
.withUrl('_blazor')
.withHubProtocol(new MessagePackHubProtocol())
.configureLogging(signalR.LogLevel.Information)
@ -33,40 +60,31 @@ function boot() {
}
});
connection.on('JS.Error', unhandledError);
connection.onclose(error => circuitHandlers.forEach(h => h.onConnectionDown && h.onConnectionDown(error)));
connection.on('JS.Error', error => unhandledError(connection, error));
connection.start()
.then(async () => {
DotNet.attachDispatcher({
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
}
});
window['Blazor']._internal.forceCloseConnection = () => connection.stop();
// Ensure any embedded resources have been loaded before starting the app
await embeddedResourcesPromise;
try {
await connection.start();
} catch (ex) {
unhandledError(connection, ex);
}
connection.send(
'StartCircuit',
uriHelperFunctions.getLocationHref(),
uriHelperFunctions.getBaseURI()
);
})
.catch(unhandledError);
DotNet.attachDispatcher({
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
}
});
// Temporary undocumented API to help with https://github.com/aspnet/Blazor/issues/1339
// This will be replaced once we implement proper connection management (reconnects, etc.)
window['Blazor'].onServerConnectionClose = connection.onclose.bind(connection);
return connection;
}
function unhandledError(err) {
function unhandledError(connection: signalR.HubConnection, err: Error) {
console.error(err);
// Disconnect on errors.
//
// TODO: it would be nice to have some kind of experience for what happens when you're
// trying to interact with an app that's disconnected.
//
// Trying to call methods on the connection after its been closed will throw.
if (connection) {
connection.stop();

View File

@ -0,0 +1,47 @@
import { CircuitHandler } from './CircuitHandler';
import { UserSpecifiedDisplay } from './UserSpecifiedDisplay';
import { DefaultReconnectDisplay } from './DefaultReconnectDisplay';
import { ReconnectDisplay } from './ReconnectDisplay';
export class AutoReconnectCircuitHandler implements CircuitHandler {
static readonly MaxRetries = 5;
static readonly RetryInterval = 3000;
static readonly DialogId = 'components-reconnect-modal';
reconnectDisplay: ReconnectDisplay;
constructor() {
this.reconnectDisplay = new DefaultReconnectDisplay(document);
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById(AutoReconnectCircuitHandler.DialogId);
if (modal) {
this.reconnectDisplay = new UserSpecifiedDisplay(modal);
}
});
}
onConnectionUp() : void{
this.reconnectDisplay.hide();
}
delay() : Promise<void>{
return new Promise((resolve) => setTimeout(resolve, AutoReconnectCircuitHandler.RetryInterval));
}
async onConnectionDown() : Promise<void> {
this.reconnectDisplay.show();
for (let i = 0; i < AutoReconnectCircuitHandler.MaxRetries; i++) {
await this.delay();
try {
const result = await window['Blazor'].reconnect();
if (!result) {
// If the server responded and refused to reconnect, stop auto-retrying.
break;
}
return;
} catch (err) {
console.error(err);
}
}
this.reconnectDisplay.failed();
}
}

View File

@ -0,0 +1,10 @@
export interface CircuitHandler {
/** Invoked when a server connection is established or re-established after a connection failure.
*/
onConnectionUp?() : void;
/** Invoked when a server connection is dropped.
* @param {Error} error Optionally argument containing the error that caused the connection to close (if any).
*/
onConnectionDown?(error?: Error): void;
}

View File

@ -0,0 +1,50 @@
import { ReconnectDisplay } from "./ReconnectDisplay";
import { AutoReconnectCircuitHandler } from "./AutoReconnectCircuitHandler";
export class DefaultReconnectDisplay implements ReconnectDisplay {
modal: HTMLDivElement;
message: HTMLHeadingElement;
button: HTMLButtonElement;
addedToDom: boolean = false;
constructor(private document: Document) {
this.modal = this.document.createElement('div');
this.modal.id = AutoReconnectCircuitHandler.DialogId;
const modalStyles = [
"position: fixed",
"top: 0",
"right: 0",
"bottom: 0",
"left: 0",
"z-index: 1000",
"display: none",
"overflow: hidden",
"background-color: #fff",
"opacity: 0.8",
"text-align: center",
"font-weight: bold"
];
this.modal.style.cssText = modalStyles.join(';');
this.modal.innerHTML = '<h5 style="margin-top: 20px"></h5><button style="margin:5px auto 5px">Retry?</button>';
this.message = this.modal.querySelector('h5')!;
this.button = this.modal.querySelector('button')!;
this.button.addEventListener('click', () => window['Blazor'].reconnect());
}
show(): void {
if (!this.addedToDom) {
this.addedToDom = true;
this.document.body.appendChild(this.modal);
}
this.modal.style.display = 'block';
this.button.style.display = 'none';
this.message.textContent = 'Attempting to reconnect to the server...';
}
hide(): void {
this.modal.style.display = 'none';
}
failed(): void {
this.button.style.display = 'block';
this.message.textContent = 'Failed to reconnect to the server.';
}
}

View File

@ -0,0 +1,5 @@
export interface ReconnectDisplay {
show(): void;
hide(): void;
failed(): void;
}

View File

@ -0,0 +1,23 @@
import { ReconnectDisplay } from "./ReconnectDisplay";
export class UserSpecifiedDisplay implements ReconnectDisplay {
static readonly ShowClassName = 'components-reconnect-show';
static readonly HideClassName = 'components-reconnect-hide';
static readonly FailedClassName = 'components-reconnect-failed';
constructor(private dialog: HTMLElement) {
}
show(): void {
this.removeClasses();
this.dialog.classList.add(UserSpecifiedDisplay.ShowClassName);
}
hide(): void {
this.removeClasses();
this.dialog.classList.add(UserSpecifiedDisplay.HideClassName);
}
failed(): void {
this.removeClasses();
this.dialog.classList.add(UserSpecifiedDisplay.FailedClassName);
}
private removeClasses() {
this.dialog.classList.remove(UserSpecifiedDisplay.ShowClassName, UserSpecifiedDisplay.HideClassName, UserSpecifiedDisplay.FailedClassName);
}
}

View File

@ -1,3 +1,5 @@
import '@dotnet/jsinterop';
let hasRegisteredEventListeners = false;
// Will be initialized once someone registers

View File

@ -1,15 +1,3 @@
{
"compilerOptions": {
"noImplicitAny": false,
"noEmitOnError": true,
"removeComments": false,
"sourceMap": true,
"target": "es5",
"lib": ["es2015", "dom"],
"strict": true
},
"exclude": [
"node_modules",
"wwwroot"
]
"extends": "../tsconfig.base.json"
}

View File

@ -0,0 +1,77 @@
import { AutoReconnectCircuitHandler } from "../src/Platform/Circuits/AutoReconnectCircuitHandler";
import { UserSpecifiedDisplay } from "../src/Platform/Circuits/UserSpecifiedDisplay";
import { DefaultReconnectDisplay } from "../src/Platform/Circuits/DefaultReconnectDisplay";
import { ReconnectDisplay } from "../src/Platform/Circuits/ReconnectDisplay";
import '../src/GlobalExports';
describe('AutoReconnectCircuitHandler', () => {
it('creates default element', () => {
const handler = new AutoReconnectCircuitHandler();
document.dispatchEvent(new Event('DOMContentLoaded'));
expect(handler.reconnectDisplay).toBeInstanceOf(DefaultReconnectDisplay);
});
it('locates user-specified handler', () => {
const element = document.createElement('div');
element.id = 'components-reconnect-modal';
document.body.appendChild(element);
const handler = new AutoReconnectCircuitHandler();
document.dispatchEvent(new Event('DOMContentLoaded'));
expect(handler.reconnectDisplay).toBeInstanceOf(UserSpecifiedDisplay);
document.body.removeChild(element);
});
const TestDisplay = jest.fn<ReconnectDisplay, any[]>(() => ({
show: jest.fn(),
hide: jest.fn(),
failed: jest.fn()
}));
it('hides display on connection up', () => {
const handler = new AutoReconnectCircuitHandler();
const testDisplay = new TestDisplay();
handler.reconnectDisplay = testDisplay;
handler.onConnectionUp();
expect(testDisplay.hide).toHaveBeenCalled();
});
it('shows display on connection down', async () => {
const handler = new AutoReconnectCircuitHandler();
handler.delay = () => Promise.resolve();
const reconnect = jest.fn().mockResolvedValue(true);
window['Blazor'].reconnect = reconnect;
const testDisplay = new TestDisplay();
handler.reconnectDisplay = testDisplay;
await handler.onConnectionDown();
expect(testDisplay.show).toHaveBeenCalled();
expect(testDisplay.failed).not.toHaveBeenCalled();
expect(reconnect).toHaveBeenCalledTimes(1);
});
it('invokes failed if reconnect fails', async () => {
const handler = new AutoReconnectCircuitHandler();
handler.delay = () => Promise.resolve();
const reconnect = jest.fn().mockRejectedValue(new Error('some error'));
window.console.error = jest.fn();
window['Blazor'].reconnect = reconnect;
const testDisplay = new TestDisplay();
handler.reconnectDisplay = testDisplay;
await handler.onConnectionDown();
expect(testDisplay.show).toHaveBeenCalled();
expect(testDisplay.failed).toHaveBeenCalled();
expect(reconnect).toHaveBeenCalledTimes(AutoReconnectCircuitHandler.MaxRetries);
});
});

View File

@ -0,0 +1,53 @@
import { DefaultReconnectDisplay } from "../src/Platform/Circuits/DefaultReconnectDisplay";
import { AutoReconnectCircuitHandler } from "../src/Platform/Circuits/AutoReconnectCircuitHandler";
import {JSDOM} from 'jsdom';
describe('DefaultReconnectDisplay', () => {
it ('adds element to the body on show', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay(testDocument);
display.show();
const element = testDocument.body.querySelector('div');
expect(element).toBeDefined();
expect(element!.id).toBe(AutoReconnectCircuitHandler.DialogId);
expect(element!.style.display).toBe('block');
expect(display.message.textContent).toBe('Attempting to reconnect to the server...');
expect(display.button.style.display).toBe('none');
});
it ('does not add element to the body multiple times', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay(testDocument);
display.show();
display.show();
expect(testDocument.body.childElementCount).toBe(1);
});
it ('hides element', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay(testDocument);
display.hide();
expect(display.modal.style.display).toBe('none');
});
it ('updates message on fail', () => {
const testDocument = new JSDOM().window.document;
const display = new DefaultReconnectDisplay(testDocument);
display.show();
display.failed();
expect(display.modal.style.display).toBe('block');
expect(display.message.textContent).toBe('Failed to reconnect to the server.');
expect(display.button.style.display).toBe('block');
});
});

View File

@ -0,0 +1,3 @@
{
"extends": "../tsconfig.base.json"
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"noImplicitAny": false,
"noEmitOnError": true,
"removeComments": false,
"sourceMap": true,
"target": "es5",
"lib": ["es2015", "dom"],
"strict": true
}
}

File diff suppressed because it is too large Load Diff

View File

@ -823,7 +823,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
public Renderer(System.IServiceProvider serviceProvider, Microsoft.AspNetCore.Components.Rendering.IDispatcher dispatcher) { }
public event System.UnhandledExceptionEventHandler UnhandledSynchronizationException { add { } remove { } }
protected internal virtual void AddToRenderQueue(int componentId, Microsoft.AspNetCore.Components.RenderFragment renderFragment) { }
protected int AssignRootComponentId(Microsoft.AspNetCore.Components.IComponent component) { throw null; }
protected internal int AssignRootComponentId(Microsoft.AspNetCore.Components.IComponent component) { throw null; }
public static Microsoft.AspNetCore.Components.Rendering.IDispatcher CreateDefaultDispatcher() { throw null; }
public System.Threading.Tasks.Task DispatchEventAsync(int eventHandlerId, Microsoft.AspNetCore.Components.UIEventArgs eventArgs) { throw null; }
public void Dispose() { }

View File

@ -20,7 +20,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
private readonly Dictionary<int, ComponentState> _componentStateById = new Dictionary<int, ComponentState>();
private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder();
private readonly Dictionary<int, EventCallback> _eventBindings = new Dictionary<int, EventCallback>();
private IDispatcher _dispatcher;
private readonly IDispatcher _dispatcher;
private int _nextComponentId = 0; // TODO: change to 'long' when Mono .NET->JS interop supports it
private bool _isBatchInProgress;
@ -89,7 +89,8 @@ namespace Microsoft.AspNetCore.Components.Rendering
/// </summary>
/// <param name="component">The component.</param>
/// <returns>The component's assigned identifier.</returns>
protected int AssignRootComponentId(IComponent component)
// Internal for unit testing
protected internal int AssignRootComponentId(IComponent component)
=> AttachAndInitComponent(component, -1).ComponentId;
/// <summary>

View File

@ -0,0 +1,570 @@
// 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.Runtime.ExceptionServices;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Microsoft.AspNetCore.Components.Rendering
{
public abstract class HtmlRendererTestBase
{
protected readonly Func<string, string> _encoder = (string t) => HtmlEncoder.Default.Encode(t);
protected readonly IDispatcher Dispatcher = Renderer.CreateDefaultDispatcher();
protected abstract HtmlRenderer GetHtmlRenderer(IServiceProvider serviceProvider);
[Fact]
public void RenderComponentAsync_CanRenderEmptyElement()
{
// Arrange
var expectedHtml = new[] { "<", "p", ">", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderSimpleComponent()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] { "<", "p", ">", "Hello world!", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddContent(1, "Hello world!");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_HtmlEncodesContent()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] { "<", "p", ">", "&lt;Hello world!&gt;", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddContent(1, "<Hello world!>");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_DoesNotEncodeMarkup()
{
// Arrange
var expectedHtml = new[] { "<", "p", ">", "<span>Hello world!</span>", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddMarkupContent(1, "<span>Hello world!</span>");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderWithAttributes()
{
// Arrange
var expectedHtml = new[] { "<", "p", " ", "class", "=", "\"", "lead", "\"", ">", "Hello world!", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddAttribute(1, "class", "lead");
rtb.AddContent(2, "Hello world!");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_HtmlEncodesAttributeValues()
{
// Arrange
var expectedHtml = new[] { "<", "p", " ", "class", "=", "\"", "&lt;lead", "\"", ">", "Hello world!", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddAttribute(1, "class", "<lead");
rtb.AddContent(2, "Hello world!");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderBooleanAttributes()
{
// Arrange
var expectedHtml = new[] { "<", "input", " ", "disabled", " />" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "input");
rtb.AddAttribute(1, "disabled", true);
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_DoesNotRenderBooleanAttributesWhenValueIsFalse()
{
// Arrange
var expectedHtml = new[] { "<", "input", " />" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "input");
rtb.AddAttribute(1, "disabled", false);
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderWithChildren()
{
// Arrange
var expectedHtml = new[] { "<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.OpenElement(1, "span");
rtb.AddContent(2, "Hello world!");
rtb.CloseElement();
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderWithMultipleChildren()
{
// Arrange
var expectedHtml = new[] { "<", "p", ">",
"<", "span", ">", "Hello world!", "</", "span", ">",
"<", "span", ">", "Bye Bye world!", "</", "span", ">",
"</", "p", ">"
};
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.OpenElement(1, "span");
rtb.AddContent(2, "Hello world!");
rtb.CloseElement();
rtb.OpenElement(3, "span");
rtb.AddContent(4, "Bye Bye world!");
rtb.CloseElement();
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderComponentAsyncWithChildrenComponents()
{
// Arrange
var expectedHtml = new[] {
"<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">",
"<", "span", ">", "Child content!", "</", "span", ">"
};
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.OpenElement(1, "span");
rtb.AddContent(2, "Hello world!");
rtb.CloseElement();
rtb.CloseElement();
rtb.OpenComponent(3, typeof(ChildComponent));
rtb.AddAttribute(4, "Value", "Child content!");
rtb.CloseComponent();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_ComponentReferenceNoops()
{
// Arrange
var expectedHtml = new[] {
"<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">",
"<", "span", ">", "Child content!", "</", "span", ">"
};
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.OpenElement(1, "span");
rtb.AddContent(2, "Hello world!");
rtb.CloseElement();
rtb.CloseElement();
rtb.OpenComponent(3, typeof(ChildComponent));
rtb.AddAttribute(4, "Value", "Child content!");
rtb.AddComponentReferenceCapture(5, cr => { });
rtb.CloseComponent();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanPassParameters()
{
// Arrange
var expectedHtml = new[] {
"<", "p", ">", "<", "input", " ", "value", "=", "\"", "5", "\"", " />", "</", "p", ">" };
RenderFragment Content(ParameterCollection pc) => new RenderFragment((RenderTreeBuilder rtb) =>
{
rtb.OpenElement(0, "p");
rtb.OpenElement(1, "input");
rtb.AddAttribute(2, "change", pc.GetValueOrDefault<Action<UIChangeEventArgs>>("update"));
rtb.AddAttribute(3, "value", pc.GetValueOrDefault<int>("value"));
rtb.CloseElement();
rtb.CloseElement();
});
var serviceProvider = new ServiceCollection()
.AddSingleton(new Func<ParameterCollection, RenderFragment>(Content))
.BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
Action<UIChangeEventArgs> change = (UIChangeEventArgs changeArgs) => throw new InvalidOperationException();
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<ComponentWithParameters>(
new ParameterCollection(new[] {
RenderTreeFrame.Element(0,string.Empty),
RenderTreeFrame.Attribute(1,"update",change),
RenderTreeFrame.Attribute(2,"value",5)
}, 0))));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderComponentAsyncWithRenderFragmentContent()
{
// Arrange
var expectedHtml = new[] {
"<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.OpenElement(1, "span");
rtb.AddContent(2,
// This internally creates a region frame.
rf => rf.AddContent(0, "Hello world!"));
rtb.CloseElement();
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_ElementRefsNoops()
{
// Arrange
var expectedHtml = new[] {
"<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddElementReferenceCapture(1, er => { });
rtb.OpenElement(2, "span");
rtb.AddContent(3,
// This internally creates a region frame.
rf => rf.AddContent(0, "Hello world!"));
rtb.CloseElement();
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = GetResult(Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
private IEnumerable<string> GetResult(Task<IEnumerable<string>> task)
{
Assert.True(task.IsCompleted);
if (task.IsCompletedSuccessfully)
{
return task.Result;
}
else
{
ExceptionDispatchInfo.Capture(task.Exception).Throw();
throw new InvalidOperationException("We will never hit this line");
}
}
private class ComponentWithParameters : IComponent
{
public RenderHandle RenderHandle { get; private set; }
public void Configure(RenderHandle renderHandle)
{
RenderHandle = renderHandle;
}
[Inject]
Func<ParameterCollection, RenderFragment> CreateRenderFragment { get; set; }
public Task SetParametersAsync(ParameterCollection parameters)
{
RenderHandle.Render(CreateRenderFragment(parameters));
return Task.CompletedTask;
}
}
[Fact]
public async Task CanRender_AsyncComponent()
{
// Arrange
var expectedHtml = new[] {
"<", "p", ">", "20", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton<AsyncComponent>().BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = await Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<AsyncComponent>(ParameterCollection.FromDictionary(new Dictionary<string, object>
{
["Value"] = 10
})));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public async Task CanRender_NestedAsyncComponents()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] {
"<", "p", ">", "20", "</", "p", ">",
"<", "p", ">", "80", "</", "p", ">"
};
var serviceProvider = new ServiceCollection().AddSingleton<AsyncComponent>().BuildServiceProvider();
var htmlRenderer = GetHtmlRenderer(serviceProvider);
// Act
var result = await Dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<NestedAsyncComponent>(ParameterCollection.FromDictionary(new Dictionary<string, object>
{
["Nested"] = false,
["Value"] = 10
})));
// Assert
Assert.Equal(expectedHtml, result);
}
private class NestedAsyncComponent : ComponentBase
{
[Parameter] public bool Nested { get; set; }
[Parameter] public int Value { get; set; }
protected override async Task OnInitAsync()
{
Value = Value * 2;
await Task.Yield();
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
base.BuildRenderTree(builder);
builder.OpenElement(0, "p");
builder.AddContent(1, Value.ToString());
builder.CloseElement();
if (!Nested)
{
builder.OpenComponent<NestedAsyncComponent>(2);
builder.AddAttribute(3, "Nested", true);
builder.AddAttribute(4, "Value", Value * 2);
builder.CloseComponent();
}
}
}
private class AsyncComponent : ComponentBase
{
public AsyncComponent()
{
}
[Parameter]
public int Value { get; set; }
protected override async Task OnInitAsync()
{
Value = Value * 2;
await Task.Delay(Value * 100);
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
base.BuildRenderTree(builder);
builder.OpenElement(0, "p");
builder.AddContent(1, Value.ToString());
builder.CloseElement();
}
}
private class ChildComponent : IComponent
{
private RenderHandle _renderHandle;
public void Configure(RenderHandle renderHandle)
{
_renderHandle = renderHandle;
}
public Task SetParametersAsync(ParameterCollection parameters)
{
_renderHandle.Render(CreateRenderFragment(parameters));
return Task.CompletedTask;
}
private RenderFragment CreateRenderFragment(ParameterCollection parameters)
{
return RenderFragment;
void RenderFragment(RenderTreeBuilder rtb)
{
rtb.OpenElement(1, "span");
rtb.AddContent(2, parameters.GetValueOrDefault<string>("Value"));
rtb.CloseElement();
}
}
}
private class TestComponent : IComponent
{
private RenderHandle _renderHandle;
[Inject]
public RenderFragment Fragment { get; set; }
public void Configure(RenderHandle renderHandle)
{
_renderHandle = renderHandle;
}
public Task SetParametersAsync(ParameterCollection parameters)
{
_renderHandle.Render(Fragment);
return Task.CompletedTask;
}
}
}
}

View File

@ -2,579 +2,14 @@
// 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.Runtime.ExceptionServices;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Microsoft.AspNetCore.Components.Rendering
{
public class HtmlRendererTests
public class HtmlRendererTests : HtmlRendererTestBase
{
private static readonly Func<string, string> _encoder = (string t) => HtmlEncoder.Default.Encode(t);
[Fact]
public void RenderComponentAsync_CanRenderEmptyElement()
protected override HtmlRenderer GetHtmlRenderer(IServiceProvider serviceProvider)
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] { "<", "p", ">", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderSimpleComponent()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] { "<", "p", ">", "Hello world!", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddContent(1, "Hello world!");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_HtmlEncodesContent()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] { "<", "p", ">", "&lt;Hello world!&gt;", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddContent(1, "<Hello world!>");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_DoesNotEncodeMarkup()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] { "<", "p", ">", "<span>Hello world!</span>", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddMarkupContent(1, "<span>Hello world!</span>");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderWithAttributes()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] { "<", "p", " ", "class", "=", "\"", "lead", "\"", ">", "Hello world!", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddAttribute(1, "class", "lead");
rtb.AddContent(2, "Hello world!");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_HtmlEncodesAttributeValues()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] { "<", "p", " ", "class", "=", "\"", "&lt;lead", "\"", ">", "Hello world!", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddAttribute(1, "class", "<lead");
rtb.AddContent(2, "Hello world!");
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderBooleanAttributes()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] { "<", "input", " ", "disabled", " />" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "input");
rtb.AddAttribute(1, "disabled", true);
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_DoesNotRenderBooleanAttributesWhenValueIsFalse()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] { "<", "input", " />" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "input");
rtb.AddAttribute(1, "disabled", false);
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderWithChildren()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] { "<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.OpenElement(1, "span");
rtb.AddContent(2, "Hello world!");
rtb.CloseElement();
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderWithMultipleChildren()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] { "<", "p", ">",
"<", "span", ">", "Hello world!", "</", "span", ">",
"<", "span", ">", "Bye Bye world!", "</", "span", ">",
"</", "p", ">"
};
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.OpenElement(1, "span");
rtb.AddContent(2, "Hello world!");
rtb.CloseElement();
rtb.OpenElement(3, "span");
rtb.AddContent(4, "Bye Bye world!");
rtb.CloseElement();
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderComponentAsyncWithChildrenComponents()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] {
"<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">",
"<", "span", ">", "Child content!", "</", "span", ">"
};
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.OpenElement(1, "span");
rtb.AddContent(2, "Hello world!");
rtb.CloseElement();
rtb.CloseElement();
rtb.OpenComponent(3, typeof(ChildComponent));
rtb.AddAttribute(4, "Value", "Child content!");
rtb.CloseComponent();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_ComponentReferenceNoops()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] {
"<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">",
"<", "span", ">", "Child content!", "</", "span", ">"
};
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.OpenElement(1, "span");
rtb.AddContent(2, "Hello world!");
rtb.CloseElement();
rtb.CloseElement();
rtb.OpenComponent(3, typeof(ChildComponent));
rtb.AddAttribute(4, "Value", "Child content!");
rtb.AddComponentReferenceCapture(5, cr => { });
rtb.CloseComponent();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanPassParameters()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] {
"<", "p", ">", "<", "input", " ", "value", "=", "\"", "5", "\"", " />", "</", "p", ">" };
RenderFragment Content(ParameterCollection pc) => new RenderFragment((RenderTreeBuilder rtb) =>
{
rtb.OpenElement(0, "p");
rtb.OpenElement(1, "input");
rtb.AddAttribute(2, "change", pc.GetValueOrDefault<Action<UIChangeEventArgs>>("update"));
rtb.AddAttribute(3, "value", pc.GetValueOrDefault<int>("value"));
rtb.CloseElement();
rtb.CloseElement();
});
var serviceProvider = new ServiceCollection()
.AddSingleton(new Func<ParameterCollection, RenderFragment>(Content))
.BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
Action<UIChangeEventArgs> change = (UIChangeEventArgs changeArgs) => throw new InvalidOperationException();
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<ComponentWithParameters>(
new ParameterCollection(new[] {
RenderTreeFrame.Element(0,string.Empty),
RenderTreeFrame.Attribute(1,"update",change),
RenderTreeFrame.Attribute(2,"value",5)
}, 0))));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_CanRenderComponentAsyncWithRenderFragmentContent()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] {
"<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.OpenElement(1, "span");
rtb.AddContent(2,
// This internally creates a region frame.
rf => rf.AddContent(0, "Hello world!"));
rtb.CloseElement();
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public void RenderComponentAsync_ElementRefsNoops()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] {
"<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
{
rtb.OpenElement(0, "p");
rtb.AddElementReferenceCapture(1, er => { });
rtb.OpenElement(2, "span");
rtb.AddContent(3,
// This internally creates a region frame.
rf => rf.AddContent(0, "Hello world!"));
rtb.CloseElement();
rtb.CloseElement();
})).BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
// Assert
Assert.Equal(expectedHtml, result);
}
private IEnumerable<string> GetResult(Task<IEnumerable<string>> task)
{
Assert.True(task.IsCompleted);
if (task.IsCompletedSuccessfully)
{
return task.Result;
}
else
{
ExceptionDispatchInfo.Capture(task.Exception).Throw();
throw new InvalidOperationException("We will never hit this line");
}
}
private class ComponentWithParameters : IComponent
{
public RenderHandle RenderHandle { get; private set; }
public void Configure(RenderHandle renderHandle)
{
RenderHandle = renderHandle;
}
[Inject]
Func<ParameterCollection, RenderFragment> CreateRenderFragment { get; set; }
public Task SetParametersAsync(ParameterCollection parameters)
{
RenderHandle.Render(CreateRenderFragment(parameters));
return Task.CompletedTask;
}
}
[Fact]
public async Task CanRender_AsyncComponent()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] {
"<", "p", ">", "20", "</", "p", ">" };
var serviceProvider = new ServiceCollection().AddSingleton<AsyncComponent>().BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = await dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<AsyncComponent>(ParameterCollection.FromDictionary(new Dictionary<string, object>
{
["Value"] = 10
})));
// Assert
Assert.Equal(expectedHtml, result);
}
[Fact]
public async Task CanRender_NestedAsyncComponents()
{
// Arrange
var dispatcher = Renderer.CreateDefaultDispatcher();
var expectedHtml = new[] {
"<", "p", ">", "20", "</", "p", ">",
"<", "p", ">", "80", "</", "p", ">"
};
var serviceProvider = new ServiceCollection().AddSingleton<AsyncComponent>().BuildServiceProvider();
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
// Act
var result = await dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<NestedAsyncComponent>(ParameterCollection.FromDictionary(new Dictionary<string, object>
{
["Nested"] = false,
["Value"] = 10
})));
// Assert
Assert.Equal(expectedHtml, result);
}
private class NestedAsyncComponent : ComponentBase
{
[Parameter] public bool Nested { get; set; }
[Parameter] public int Value { get; set; }
protected override async Task OnInitAsync()
{
Value = Value * 2;
await Task.Yield();
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
base.BuildRenderTree(builder);
builder.OpenElement(0, "p");
builder.AddContent(1, Value.ToString());
builder.CloseElement();
if (!Nested)
{
builder.OpenComponent<NestedAsyncComponent>(2);
builder.AddAttribute(3, "Nested", true);
builder.AddAttribute(4, "Value", Value * 2);
builder.CloseComponent();
}
}
}
private class AsyncComponent : ComponentBase
{
public AsyncComponent()
{
}
[Parameter]
public int Value { get; set; }
protected override async Task OnInitAsync()
{
Value = Value * 2;
await Task.Delay(Value * 100);
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
base.BuildRenderTree(builder);
builder.OpenElement(0, "p");
builder.AddContent(1, Value.ToString());
builder.CloseElement();
}
}
private class ChildComponent : IComponent
{
private RenderHandle _renderHandle;
public void Configure(RenderHandle renderHandle)
{
_renderHandle = renderHandle;
}
public Task SetParametersAsync(ParameterCollection parameters)
{
_renderHandle.Render(CreateRenderFragment(parameters));
return Task.CompletedTask;
}
private RenderFragment CreateRenderFragment(ParameterCollection parameters)
{
return RenderFragment;
void RenderFragment(RenderTreeBuilder rtb)
{
rtb.OpenElement(1, "span");
rtb.AddContent(2, parameters.GetValueOrDefault<string>("Value"));
rtb.CloseElement();
}
}
}
private class TestComponent : IComponent
{
private RenderHandle _renderHandle;
[Inject]
public RenderFragment Fragment { get; set; }
public void Configure(RenderHandle renderHandle)
{
_renderHandle = renderHandle;
}
public Task SetParametersAsync(ParameterCollection parameters)
{
_renderHandle.Render(Fragment);
return Task.CompletedTask;
}
return new HtmlRenderer(serviceProvider, _encoder, Dispatcher);
}
}
}

View File

@ -0,0 +1,49 @@
// 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;
namespace Microsoft.AspNetCore.Components.Server
{
/// <summary>
/// Options to configure ASP.NET Core Components.
/// </summary>
public class CircuitOptions
{
/// <summary>
/// Gets or sets a value that determines the maximum number of disconnected circuit state details
/// are retained by the server.
/// <para>
/// When a client disconnects, ASP.NET Core Components attempts to retain state on the server for an
/// interval. This allows the client to re-establish a connection to the existing circuit on the server
/// without losing any state in the event of transient connection issues.
/// </para>
/// <para>
/// This value determines the maximium number of circuit states retained by the server.
/// <seealso cref="DisconnectedCircuitRetentionPeriod"/>
/// </para>
/// </summary>
/// <value>
/// Defaults to <c>100</c>.
/// </value>
public int MaxRetainedDisconnectedCircuits { get; set; } = 100;
/// <summary>
/// Gets or sets a value that determines the maximum duration state for a disconnected circuit is
/// retained on the server.
/// <para>
/// When a client disconnects, ASP.NET Core Components attempts to retain state on the server for an
/// interval. This allows the client to re-establish a connection to the existing circuit on the server
/// without losing any state in the event of transient connection issues.
/// </para>
/// <para>
/// This value determines the maximium duration circuit state is retained by the server before being evicted.
/// <seealso cref="MaxRetainedDisconnectedCircuits"/>
/// </para>
/// </summary>
/// <value>
/// Defaults to <c>3 minutes</c>.
/// </value>
public TimeSpan DisconnectedCircuitRetentionPeriod { get; set; } = TimeSpan.FromMinutes(3);
}
}

View File

@ -0,0 +1,53 @@
// 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.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
namespace Microsoft.AspNetCore.Components.Server.Circuits
{
internal class CircuitClientProxy : IClientProxy
{
public static readonly CircuitClientProxy OfflineClient = new CircuitClientProxy();
private CircuitClientProxy()
{
Connected = false;
}
public CircuitClientProxy(IClientProxy clientProxy, string connectionId)
{
Transfer(clientProxy, connectionId);
}
public bool Connected { get; private set; }
public string ConnectionId { get; private set; }
public IClientProxy Client { get; private set; }
public void Transfer(IClientProxy clientProxy, string connectionId)
{
Client = clientProxy ?? throw new ArgumentNullException(nameof(clientProxy));
ConnectionId = connectionId ?? throw new ArgumentNullException(nameof(connectionId));
Connected = true;
}
public void SetDisconnected()
{
Connected = false;
}
public Task SendCoreAsync(string method, object[] args, CancellationToken cancellationToken = default)
{
if (Client == null)
{
throw new InvalidOperationException($"{nameof(SendCoreAsync)} cannot be invoked with an offline client.");
}
return Client.SendCoreAsync(method, args, cancellationToken);
}
}
}

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 Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
namespace Microsoft.AspNetCore.Components.Server.Circuits
{
@ -10,7 +9,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
{
public abstract CircuitHost CreateCircuitHost(
HttpContext httpContext,
IClientProxy client,
CircuitClientProxy client,
string uriAbsolute,
string baseUriAbsolute);
}

View File

@ -69,7 +69,6 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
/// <returns><see cref="Task"/> that represents the asynchronous execution operation.</returns>
public virtual Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary>
/// Invoked when a new circuit is being discarded.
/// </summary>

View File

@ -8,8 +8,8 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Browser;
using Microsoft.AspNetCore.Components.Browser.Rendering;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
namespace Microsoft.AspNetCore.Components.Server.Circuits
@ -17,9 +17,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
internal class CircuitHost : IAsyncDisposable
{
private static readonly AsyncLocal<CircuitHost> _current = new AsyncLocal<CircuitHost>();
private readonly SemaphoreSlim HandlerLock = new SemaphoreSlim(1);
private readonly IServiceScope _scope;
private readonly IDispatcher _dispatcher;
private readonly CircuitHandler[] _circuitHandlers;
private readonly ILogger _logger;
private bool _initialized;
/// <summary>
@ -49,22 +50,24 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
public CircuitHost(
IServiceScope scope,
IClientProxy client,
CircuitClientProxy client,
RendererRegistry rendererRegistry,
RemoteRenderer renderer,
IList<ComponentDescriptor> descriptors,
IDispatcher dispatcher,
IJSRuntime jsRuntime,
CircuitHandler[] circuitHandlers)
RemoteJSRuntime jsRuntime,
CircuitHandler[] circuitHandlers,
ILogger logger)
{
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
_dispatcher = dispatcher;
Dispatcher = dispatcher;
Client = client;
RendererRegistry = rendererRegistry ?? throw new ArgumentNullException(nameof(rendererRegistry));
Descriptors = descriptors ?? throw new ArgumentNullException(nameof(descriptors));
Renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
JSRuntime = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime));
_logger = logger;
Services = scope.ServiceProvider;
Circuit = new Circuit(this);
@ -78,9 +81,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
public Circuit Circuit { get; }
public IClientProxy Client { get; set; }
public CircuitClientProxy Client { get; set; }
public IJSRuntime JSRuntime { get; }
public RemoteJSRuntime JSRuntime { get; }
public RemoteRenderer Renderer { get; }
@ -90,11 +93,12 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
public IServiceProvider Services { get; }
public IDispatcher Dispatcher { get; }
public Task<IEnumerable<string>> PrerenderComponentAsync(Type componentType, ParameterCollection parameters)
{
return _dispatcher.InvokeAsync(async () =>
return Dispatcher.InvokeAsync(async () =>
{
Renderer.StartPrerender();
var result = await Renderer.RenderComponentAsync(componentType, parameters);
return result;
});
@ -112,15 +116,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
await Renderer.AddComponentAsync(componentType, domElementSelector);
}
for (var i = 0; i < _circuitHandlers.Length; i++)
{
await _circuitHandlers[i].OnCircuitOpenedAsync(Circuit, cancellationToken);
}
await OnCircuitOpenedAsync(cancellationToken);
for (var i = 0; i < _circuitHandlers.Length; i++)
{
await _circuitHandlers[i].OnConnectionUpAsync(Circuit, cancellationToken);
}
await OnConnectionUpAsync(cancellationToken);
});
_initialized = true;
@ -144,20 +142,102 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
}
}
public async ValueTask DisposeAsync()
private async Task OnCircuitOpenedAsync(CancellationToken cancellationToken)
{
await Renderer.InvokeAsync(async () =>
for (var i = 0; i < _circuitHandlers.Length; i++)
{
for (var i = 0; i < _circuitHandlers.Length; i++)
var circuitHandler = _circuitHandlers[i];
try
{
await _circuitHandlers[i].OnConnectionDownAsync(Circuit, default);
await circuitHandler.OnCircuitOpenedAsync(Circuit, cancellationToken);
}
catch (Exception ex)
{
OnHandlerError(circuitHandler, nameof(CircuitHandler.OnCircuitOpenedAsync), ex);
}
}
}
public async Task OnConnectionUpAsync(CancellationToken cancellationToken)
{
try
{
await HandlerLock.WaitAsync(cancellationToken);
for (var i = 0; i < _circuitHandlers.Length; i++)
{
await _circuitHandlers[i].OnCircuitClosedAsync(Circuit, default);
var circuitHandler = _circuitHandlers[i];
try
{
await circuitHandler.OnConnectionUpAsync(Circuit, cancellationToken);
}
catch (Exception ex)
{
OnHandlerError(circuitHandler, nameof(CircuitHandler.OnConnectionUpAsync), ex);
}
}
});
}
finally
{
HandlerLock.Release();
}
}
public async Task OnConnectionDownAsync(CancellationToken cancellationToken)
{
try
{
await HandlerLock.WaitAsync(cancellationToken);
for (var i = 0; i < _circuitHandlers.Length; i++)
{
var circuitHandler = _circuitHandlers[i];
try
{
await circuitHandler.OnConnectionDownAsync(Circuit, cancellationToken);
}
catch (Exception ex)
{
OnHandlerError(circuitHandler, nameof(CircuitHandler.OnConnectionDownAsync), ex);
}
}
}
finally
{
HandlerLock.Release();
}
}
protected virtual void OnHandlerError(CircuitHandler circuitHandler, string handlerMethod, Exception ex)
{
Log.UnhandledExceptionInvokingCircuitHandler(_logger, circuitHandler, handlerMethod, ex);
}
private async Task OnCircuitDownAsync()
{
for (var i = 0; i < _circuitHandlers.Length; i++)
{
var circuitHandler = _circuitHandlers[i];
try
{
await circuitHandler.OnCircuitClosedAsync(Circuit, default);
}
catch (Exception ex)
{
OnHandlerError(circuitHandler, nameof(CircuitHandler.OnCircuitClosedAsync), ex);
}
}
}
public async ValueTask DisposeAsync()
{
Log.DisposingCircuit(_logger, CircuitId);
await Renderer.InvokeAsync((Func<Task>)(async () =>
{
await OnConnectionDownAsync(CancellationToken.None);
await OnCircuitDownAsync();
}));
_scope.Dispose();
Renderer.Dispose();
@ -167,7 +247,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
{
if (!_initialized)
{
throw new InvalidOperationException("Something is calling into the circuit before Initialize() completes");
throw new InvalidOperationException("Circuit is being invoked prior to initialization.");
}
}
@ -180,5 +260,42 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
{
UnhandledException?.Invoke(this, e);
}
private static class Log
{
private static readonly Action<ILogger, Type, string, string, Exception> _unhandledExceptionInvokingCircuitHandler;
private static readonly Action<ILogger, string, Exception> _disposingCircuit;
private static class EventIds
{
public static readonly EventId ExceptionInvokingCircuitHandlerMethod = new EventId(100, "ExceptionInvokingCircuitHandlerMethod");
public static readonly EventId DisposingCircuit = new EventId(101, "DisposingCircuitHost");
}
static Log()
{
_unhandledExceptionInvokingCircuitHandler = LoggerMessage.Define<Type, string, string>(
LogLevel.Error,
EventIds.ExceptionInvokingCircuitHandlerMethod,
"Unhandled error invoking circuit handler type {handlerType}.{handlerMethod}: {Message}");
_disposingCircuit = LoggerMessage.Define<string>(
LogLevel.Trace,
EventIds.DisposingCircuit,
"Disposing circuit with identifier {CircuitId}");
}
public static void UnhandledExceptionInvokingCircuitHandler(ILogger logger, CircuitHandler handler, string handlerMethod, Exception exception)
{
_unhandledExceptionInvokingCircuitHandler(
logger,
handler.GetType(),
handlerMethod,
exception.Message,
exception);
}
public static void DisposingCircuit(ILogger logger, string circuitId) => _disposingCircuit(logger, circuitId, null);
}
}
}

View File

@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
var context = prerenderingContext.Context;
var circuitHost = _circuitFactory.CreateCircuitHost(
context,
client: null,
client: CircuitClientProxy.OfflineClient,
GetFullUri(context.Request),
GetFullBaseUri(context.Request));

View File

@ -0,0 +1,262 @@
// 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.Concurrent;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Components.Server.Circuits
{
/// <summary>
/// <see cref="CircuitRegistry"/> manages the lifetime of a <see cref="CircuitHost"/>.
/// </summary>
/// <remarks>
/// Hosts start off by being registered using <see cref="CircuitHost"/>.
///
/// In the simplest of cases, the client disconnects e.g. the user is done with the application and closes the browser.
/// The server (eventually) learns of the disconnect. The host is transitioned from <see cref="ConnectedCircuits"/> to
/// <see cref="DisconnectedCircuits"/> where it sits with an expiration time. We'll mark the associated <see cref="CircuitClientProxy"/> as disconnected
/// so that consumers of the Circuit know of the current state.
/// Once the entry for the host in <see cref="DisconnectedCircuits"/> expires, we'll dispose off the host.
///
/// The alternate case is when the disconnect was transient, e.g. due to a network failure, and the client attempts to reconnect.
/// We'll attempt to connect it back to the host and the preserved server state, when available. In this event, we do the opposite of
/// what we did during disconnect - transition the host from <see cref="DisconnectedCircuits"/> to <see cref="ConnectedCircuits"/>, and transfer
/// the <see cref="CircuitClientProxy"/> to use the new client instance that attempted to reconnect to the server. Removing the entry from
/// <see cref="DisconnectedCircuits"/> should ensure we no longer have to concern ourselves with entry expiration.
///
/// Knowing when a client disconnected is not an exact science. There's a fair possiblity that a client may reconnect before the server realizes.
/// Consequently, we have to account for reconnects and disconnects occuring simultaneously as well as appearing out of order.
/// To manage this, we use a critical section to manage all state transitions.
/// </remarks>
internal class CircuitRegistry
{
private readonly object CircuitRegistryLock = new object();
private readonly CircuitOptions _options;
private readonly ILogger _logger;
private readonly PostEvictionCallbackRegistration _postEvictionCallback;
public CircuitRegistry(
IOptions<CircuitOptions> options,
ILogger<CircuitRegistry> logger)
{
_options = options.Value;
_logger = logger;
ConnectedCircuits = new ConcurrentDictionary<string, CircuitHost>(StringComparer.Ordinal);
DisconnectedCircuits = new MemoryCache(new MemoryCacheOptions
{
SizeLimit = _options.MaxRetainedDisconnectedCircuits,
});
_postEvictionCallback = new PostEvictionCallbackRegistration
{
EvictionCallback = OnEntryEvicted,
};
}
internal ConcurrentDictionary<string, CircuitHost> ConnectedCircuits { get; }
internal MemoryCache DisconnectedCircuits { get; }
/// <summary>
/// Registers an active <see cref="CircuitHost"/> with the register.
/// </summary>
public void Register(CircuitHost circuitHost)
{
if (!ConnectedCircuits.TryAdd(circuitHost.CircuitId, circuitHost))
{
// This will likely never happen, except perhaps in unit tests, since CircuitIds are unique.
throw new ArgumentException($"Circuit with identity {circuitHost.CircuitId} is already registered.");
}
}
public virtual Task DisconnectAsync(CircuitHost circuitHost, string connectionId)
{
Task circuitHandlerTask;
lock (CircuitRegistryLock)
{
if (DisconnectCore(circuitHost, connectionId))
{
circuitHandlerTask = circuitHost.Dispatcher.InvokeAsync(() => circuitHost.OnConnectionDownAsync(default));
}
else
{
// DisconnectCore may fail to disconnect the circuit if it was previously marked inactive or
// has been transfered to a new connection. Do not invoke the circuit handlers in this instance.
// We have to do in this instance.
return Task.CompletedTask;
}
}
return circuitHandlerTask;
}
protected virtual bool DisconnectCore(CircuitHost circuitHost, string connectionId)
{
if (!ConnectedCircuits.TryGetValue(circuitHost.CircuitId, out circuitHost))
{
// Guard: The circuit might already have been marked as inactive.
return false;
}
if (!string.Equals(circuitHost.Client.ConnectionId, connectionId, StringComparison.Ordinal))
{
// The circuit is associated with a different connection. One way this could happen is when
// the client reconnects with a new connection before the OnDisconnect for the older
// connection is executed. Do nothing
return false;
}
var result = ConnectedCircuits.TryRemove(circuitHost.CircuitId, out circuitHost);
Debug.Assert(result, "This operation operates inside of a lock. We expect the previously inspected value to be still here.");
circuitHost.Client.SetDisconnected();
var entryOptions = new MemoryCacheEntryOptions
{
AbsoluteExpiration = DateTimeOffset.UtcNow.Add(_options.DisconnectedCircuitRetentionPeriod),
Size = 1,
PostEvictionCallbacks = { _postEvictionCallback },
};
DisconnectedCircuits.Set(circuitHost.CircuitId, circuitHost, entryOptions);
return true;
}
public virtual async Task<CircuitHost> ConnectAsync(string circuitId, IClientProxy clientProxy, string connectionId, CancellationToken cancellationToken)
{
CircuitHost circuitHost;
bool previouslyConnected;
Task circuitHandlerTask;
lock (CircuitRegistryLock)
{
// Transition the host from disconnected to connected if it's available. In this critical section, we return
// an existing host if it's currently considered connected or transition a disconnected host to connected.
// Transfering also wires up the client to the new set.
(circuitHost, previouslyConnected) = ConnectCore(circuitId, clientProxy, connectionId);
if (circuitHost == null)
{
// Failed to connect. Nothing to do here.
return null;
}
// CircuitHandler events do not need to be executed inside the critical section, however we
// a) do not want concurrent execution of handler events i.e. a OnConnectionDownAsync occuring in tandem with a OnConnectionUpAsync for a single circuit.
// b) out of order connection-up \ connection-down events e.g. a client that disconnects as soon it finishes reconnecting.
// Dispatch the circuit handlers inside the sync context to ensure the order of execution. CircuitHost executes circuit handlers inside of
//
circuitHandlerTask = circuitHost.Dispatcher.InvokeAsync(async () =>
{
if (previouslyConnected)
{
// During reconnects, we may transition from Connect->Connect i.e.without ever having invoking OnConnectionDownAsync during
// a formal client disconnect. To allow authors of CircuitHandlers to have reasonable expectations will pair the connection up with a connection down.
await circuitHost.OnConnectionDownAsync(cancellationToken);
}
await circuitHost.OnConnectionUpAsync(cancellationToken);
});
}
await circuitHandlerTask;
return circuitHost;
}
protected virtual (CircuitHost circuitHost, bool previouslyConnected) ConnectCore(string circuitId, IClientProxy clientProxy, string connectionId)
{
if (ConnectedCircuits.TryGetValue(circuitId, out var circuitHost))
{
// The host is still active i.e. the server hasn't detected the client disconnect.
// However the client reconnected establishing a new connection.
circuitHost.Client.Transfer(clientProxy, connectionId);
return (circuitHost, true);
}
if (DisconnectedCircuits.TryGetValue(circuitId, out circuitHost))
{
// The host was in disconnected state. Transfer it to ConnectedCircuits so that it's no longer considered disconnected.
DisconnectedCircuits.Remove(circuitId);
ConnectedCircuits.TryAdd(circuitId, circuitHost);
circuitHost.Client.Transfer(clientProxy, connectionId);
return (circuitHost, false);
}
return default;
}
private void OnEntryEvicted(object key, object value, EvictionReason reason, object state)
{
switch (reason)
{
case EvictionReason.Expired:
case EvictionReason.Capacity:
// Kick off the dispose in the background.
_ = DisposeCircuitHost((CircuitHost)value);
break;
case EvictionReason.Removed:
// The entry was explicitly removed as part of TryGetInactiveCircuit. Nothing to do here.
return;
default:
Debug.Fail($"Unexpected {nameof(EvictionReason)} {reason}");
break;
}
}
private async Task DisposeCircuitHost(CircuitHost circuitHost)
{
try
{
await circuitHost.DisposeAsync();
}
catch (Exception ex)
{
Log.UnhandledExceptionDisposingCircuitHost(_logger, ex);
}
}
private static class Log
{
private static readonly Action<ILogger, string, Exception> _unhandledExceptionDisposingCircuitHost;
private static class EventIds
{
public static readonly EventId ExceptionDisposingCircuit = new EventId(100, "ExceptionDisposingCircuit");
}
static Log()
{
_unhandledExceptionDisposingCircuitHost = LoggerMessage.Define<string>(
LogLevel.Error,
EventIds.ExceptionDisposingCircuit,
"Unhandled exception disposing circuit host: {Message}");
}
public static void UnhandledExceptionDisposingCircuitHost(ILogger logger, Exception exception)
{
_unhandledExceptionDisposingCircuitHost(
logger,
exception.Message,
exception);
}
}
}
}

View File

@ -11,7 +11,6 @@ using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
@ -33,7 +32,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
public override CircuitHost CreateCircuitHost(
HttpContext httpContext,
IClientProxy client,
CircuitClientProxy client,
string uriAbsolute,
string baseUriAbsolute)
{
@ -42,13 +41,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
var scope = _scopeFactory.CreateScope();
var encoder = scope.ServiceProvider.GetRequiredService<HtmlEncoder>();
var jsRuntime = (RemoteJSRuntime)scope.ServiceProvider.GetRequiredService<IJSRuntime>();
if (client != null)
{
jsRuntime.Initialize(client);
}
jsRuntime.Initialize(client);
var uriHelper = (RemoteUriHelper)scope.ServiceProvider.GetRequiredService<IUriHelper>();
if (client != null)
if (client != CircuitClientProxy.OfflineClient)
{
uriHelper.Initialize(uriAbsolute, baseUriAbsolute, jsRuntime);
}
@ -80,7 +76,8 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
components,
dispatcher,
jsRuntime,
circuitHandlers);
circuitHandlers,
_loggerFactory.CreateLogger<CircuitHost>());
// Initialize per - circuit data that services need
(circuitHost.Services.GetRequiredService<ICircuitAccessor>() as DefaultCircuitAccessor).Circuit = circuitHost.Circuit;
@ -88,9 +85,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
return circuitHost;
}
private static IList<ComponentDescriptor> ResolveComponentMetadata(HttpContext httpContext, IClientProxy client)
private static IList<ComponentDescriptor> ResolveComponentMetadata(HttpContext httpContext, CircuitClientProxy client)
{
if (client == null)
if (client == CircuitClientProxy.OfflineClient)
{
// This is the prerendering case.
return Array.Empty<ComponentDescriptor>();

View File

@ -11,21 +11,20 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
{
private IClientProxy _clientProxy;
public RemoteJSRuntime()
{
}
internal void Initialize(IClientProxy clientProxy)
internal void Initialize(CircuitClientProxy clientProxy)
{
_clientProxy = clientProxy ?? throw new ArgumentNullException(nameof(clientProxy));
}
protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson)
{
if (_clientProxy == null)
if (_clientProxy == CircuitClientProxy.OfflineClient)
{
throw new InvalidOperationException("The JavaScript runtime is not available during prerendering.");
var errorMessage = "JavaScript interop calls cannot be issued while the client is not connected, because the server is not able to interop with the browser at this time. " +
"Components must wrap any JavaScript interop calls in conditional logic to ensure those interop calls are not attempted during periods where the client is not connected.";
throw new InvalidOperationException(errorMessage);
}
_clientProxy.SendAsync("JS.BeginInvokeJS", asyncHandle, identifier, argsJson);
}
}

View File

@ -3,12 +3,12 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using MessagePack;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
@ -23,14 +23,13 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
private const int TimeoutMilliseconds = 60 * 1000;
private readonly int _id;
private IClientProxy _client;
private readonly IJSRuntime _jsRuntime;
private readonly CircuitClientProxy _client;
private readonly RendererRegistry _rendererRegistry;
private readonly ConcurrentDictionary<long, AutoCancelTaskCompletionSource<object>> _pendingRenders
= new ConcurrentDictionary<long, AutoCancelTaskCompletionSource<object>>();
private readonly ILogger _logger;
private long _nextRenderId = 1;
private bool _prerenderMode;
/// <summary>
/// Notifies when a rendering exception occured.
@ -40,16 +39,11 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
/// <summary>
/// Creates a new <see cref="RemoteRenderer"/>.
/// </summary>
/// <param name="serviceProvider">The <see cref="IServiceProvider"/>.</param>
/// <param name="rendererRegistry">The <see cref="RendererRegistry"/>.</param>
/// <param name="jsRuntime">The <see cref="IJSRuntime"/>.</param>
/// <param name="client">The <see cref="IClientProxy"/>.</param>
/// <param name="syncContext">A <see cref="SynchronizationContext"/> that can be used to serialize renderer operations.</param>
public RemoteRenderer(
IServiceProvider serviceProvider,
RendererRegistry rendererRegistry,
IJSRuntime jsRuntime,
IClientProxy client,
CircuitClientProxy client,
IDispatcher dispatcher,
HtmlEncoder encoder,
ILogger logger)
@ -63,6 +57,8 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
_logger = logger;
}
internal ConcurrentQueue<byte[]> OfflineRenderBatches = new ConcurrentQueue<byte[]>();
/// <summary>
/// Associates the <see cref="IComponent"/> with the <see cref="RemoteRenderer"/>,
/// causing it to be displayed in the specified DOM element.
@ -91,20 +87,15 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
{
foreach (var innerException in aggregateException.Flatten().InnerExceptions)
{
_logger.UnhandledExceptionRenderingComponent(innerException);
Log.UnhandledExceptionRenderingComponent(_logger, innerException);
}
}
else
{
_logger.UnhandledExceptionRenderingComponent(exception);
Log.UnhandledExceptionRenderingComponent(_logger, exception);
}
}
internal void StartPrerender()
{
_prerenderMode = true;
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
@ -115,14 +106,6 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
/// <inheritdoc />
protected override Task UpdateDisplayAsync(in RenderBatch batch)
{
if (_prerenderMode)
{
// Nothing to do in prerender mode for right now.
// In the future we will capture all the serialized render batches and
// resend them to the client upon the initial reconnect.
return Task.CompletedTask;
}
// Note that we have to capture the data as a byte[] synchronously here, because
// SignalR's SendAsync can wait an arbitrary duration before serializing the params.
// The RenderBatch buffer will get reused by subsequent renders, so we need to
@ -131,8 +114,31 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
// buffer on every render.
var batchBytes = MessagePackSerializer.Serialize(batch, RenderBatchFormatterResolver.Instance);
// Prepare to track the render process with a timeout
if (!_client.Connected)
{
// Buffer the rendered batches while the client is disconnected. We'll send it down once the client reconnects.
OfflineRenderBatches.Enqueue(batchBytes);
return Task.CompletedTask;
}
Log.BeginUpdateDisplayAsync(_logger, _client.ConnectionId);
return WriteBatchBytes(batchBytes);
}
public async Task ProcessBufferedRenderBatches()
{
// The server may discover that the client disconnected while we're attempting to write empty rendered batches.
// Discontinue writing in this event.
while (_client.Connected && OfflineRenderBatches.TryDequeue(out var renderBatch))
{
await WriteBatchBytes(renderBatch);
}
}
private Task WriteBatchBytes(byte[] batchBytes)
{
var renderId = Interlocked.Increment(ref _nextRenderId);
var pendingRenderInfo = new AutoCancelTaskCompletionSource<object>(TimeoutMilliseconds);
_pendingRenders[renderId] = pendingRenderInfo;
@ -191,5 +197,61 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
}
});
}
private static class Log
{
private static readonly Action<ILogger, string, Exception> _unhandledExceptionRenderingComponent;
private static readonly Action<ILogger, string, Exception> _beginUpdateDisplayAsync;
private static readonly Action<ILogger, string, Exception> _bufferingRenderDisconnectedClient;
private static class EventIds
{
public static readonly EventId UnhandledExceptionRenderingComponent = new EventId(100, "ExceptionRenderingComponent");
public static readonly EventId BeginUpdateDisplayAsync = new EventId(101, "BeginUpdateDisplayAsync");
public static readonly EventId SkipUpdateDisplayAsync = new EventId(102, "SkipUpdateDisplayAsync");
}
static Log()
{
_unhandledExceptionRenderingComponent = LoggerMessage.Define<string>(
LogLevel.Warning,
EventIds.UnhandledExceptionRenderingComponent,
"Unhandled exception rendering component: {Message}");
_beginUpdateDisplayAsync = LoggerMessage.Define<string>(
LogLevel.Trace,
EventIds.BeginUpdateDisplayAsync,
"Begin remote rendering of components on client {ConnectionId}.");
_bufferingRenderDisconnectedClient = LoggerMessage.Define<string>(
LogLevel.Trace,
EventIds.SkipUpdateDisplayAsync,
"Buffering remote render because the client on connection {ConnectionId} is disconnected.");
}
public static void UnhandledExceptionRenderingComponent(ILogger logger, Exception exception)
{
_unhandledExceptionRenderingComponent(
logger,
exception.Message,
exception);
}
public static void BeginUpdateDisplayAsync(ILogger logger, string connectionId)
{
_beginUpdateDisplayAsync(
logger,
connectionId,
null);
}
public static void BufferingRenderDisconnectedClient(ILogger logger, string connectionId)
{
_bufferingRenderDisconnectedClient(
logger,
connectionId,
null);
}
}
}
}

View File

@ -16,10 +16,6 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
{
private IJSRuntime _jsRuntime;
public RemoteUriHelper()
{
}
/// <summary>
/// Initializes the <see cref="RemoteUriHelper"/>.
/// </summary>

View File

@ -4,7 +4,6 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.Components.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
@ -19,6 +18,7 @@ namespace Microsoft.AspNetCore.Components.Server
{
private static readonly object CircuitKey = new object();
private readonly CircuitFactory _circuitFactory;
private readonly CircuitRegistry _circuitRegistry;
private readonly ILogger _logger;
/// <summary>
@ -28,6 +28,7 @@ namespace Microsoft.AspNetCore.Components.Server
public ComponentHub(IServiceProvider services, ILogger<ComponentHub> logger)
{
_circuitFactory = services.GetRequiredService<CircuitFactory>();
_circuitRegistry = services.GetRequiredService<CircuitRegistry>();
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@ -48,19 +49,28 @@ namespace Microsoft.AspNetCore.Components.Server
/// <summary>
/// Intended for framework use only. Applications should not call this method directly.
/// </summary>
public override async Task OnDisconnectedAsync(Exception exception)
public override Task OnDisconnectedAsync(Exception exception)
{
await CircuitHost.DisposeAsync();
var circuitHost = CircuitHost;
if (circuitHost == null)
{
return Task.CompletedTask;
}
CircuitHost = null;
return _circuitRegistry.DisconnectAsync(circuitHost, Context.ConnectionId);
}
/// <summary>
/// Intended for framework use only. Applications should not call this method directly.
/// </summary>
public async Task StartCircuit(string uriAbsolute, string baseUriAbsolute)
public async Task<string> StartCircuit(string uriAbsolute, string baseUriAbsolute)
{
var circuitClient = new CircuitClientProxy(Clients.Caller, Context.ConnectionId);
var circuitHost = _circuitFactory.CreateCircuitHost(
Context.GetHttpContext(),
Clients.Caller,
circuitClient,
uriAbsolute,
baseUriAbsolute);
@ -69,7 +79,31 @@ namespace Microsoft.AspNetCore.Components.Server
// If initialization fails, this will throw. The caller will fail if they try to call into any interop API.
await circuitHost.InitializeAsync(Context.ConnectionAborted);
_circuitRegistry.Register(circuitHost);
CircuitHost = circuitHost;
return circuitHost.CircuitId;
}
/// <summary>
/// Intended for framework use only. Applications should not call this method directly.
/// </summary>
public async Task<bool> ConnectCircuit(string circuitId)
{
var circuitHost = await _circuitRegistry.ConnectAsync(circuitId, Clients.Caller, Context.ConnectionId, Context.ConnectionAborted);
if (circuitHost != null)
{
CircuitHost = circuitHost;
// Dispatch any buffered renders we accumulated during a disconnect.
// Note that while the rendering is async, we cannot await it here. The Task returned by ProcessBufferedRenderBatches relies on
// OnRenderCompleted to be invoked to complete, and SignalR does not allow concurrent hub method invocations.
_ = circuitHost.Renderer.ProcessBufferedRenderBatches();
return true;
}
return false;
}
/// <summary>

View File

@ -33,6 +33,8 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddScoped(s => s.GetRequiredService<ICircuitAccessor>().Circuit);
services.TryAddScoped<ICircuitAccessor, DefaultCircuitAccessor>();
services.TryAddSingleton<CircuitRegistry>();
// We explicitly take over the prerendering and components services here.
// We can't have two separate component implementations coexisting at the
// same time, so when you register components (Circuits) it takes over

View File

@ -1,29 +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 Microsoft.Extensions.Logging;
namespace Microsoft.AspNetCore.Components.Server
{
internal static class LoggerExtensions
{
private static readonly Action<ILogger, string, Exception> _unhandledExceptionRenderingComponent;
static LoggerExtensions()
{
_unhandledExceptionRenderingComponent = LoggerMessage.Define<string>(
LogLevel.Warning,
new EventId(1, "ExceptionRenderingComponent"),
"Unhandled exception rendering component: {Message}");
}
public static void UnhandledExceptionRenderingComponent(this ILogger logger, Exception exception)
{
_unhandledExceptionRenderingComponent(
logger,
exception.Message,
exception);
}
}
}

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
@ -23,6 +23,7 @@
<Reference Include="Microsoft.AspNetCore.SignalR" />
<Reference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" />
<Reference Include="Microsoft.AspNetCore.StaticFiles" />
<Reference Include="Microsoft.Extensions.Caching.Memory" />
<Reference Include="Microsoft.Extensions.FileProviders.Composite" />
<Reference Include="Microsoft.Extensions.FileProviders.Embedded" />
@ -34,7 +35,7 @@
<ItemGroup Condition="'$(BuildNodeJS)' != 'false'">
<!-- We need .Browser.JS to build first so we can embed its .js output -->
<ProjectReference Include="..\..\Browser.JS\src\Microsoft.AspNetCore.Components.Browser.JS.npmproj" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\..\Browser.JS\Microsoft.AspNetCore.Components.Browser.JS.npmproj" ReferenceOutputAssembly="false" />
<EmbeddedResource Include="..\..\Browser.JS\src\dist\components.server.js" LogicalName="_framework\%(Filename)%(Extension)" />
</ItemGroup>

View File

@ -0,0 +1,53 @@
// 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.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Components.Server.Circuits
{
public class CircuitClientProxyTest
{
[Fact]
public async Task SendCoreAsync_WithoutTransfer()
{
// Arrange
bool? isCancelled = null;
var clientProxy = new Mock<IClientProxy>();
clientProxy.Setup(c => c.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.Callback((string _, object[] __, CancellationToken token) =>
{
isCancelled = token.IsCancellationRequested;
})
.Returns(Task.CompletedTask);
var circuitClient = new CircuitClientProxy(clientProxy.Object, "connection0");
// Act
var sendTask = circuitClient.SendCoreAsync("test", Array.Empty<object>());
await sendTask;
// Assert
Assert.False(isCancelled);
}
[Fact]
public void Transfer_SetsConnected()
{
// Arrange
var clientProxy = Mock.Of<IClientProxy>(
c => c.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()) == Task.CompletedTask);
var circuitClient = new CircuitClientProxy(clientProxy, "connection0");
circuitClient.SetDisconnected();
// Act
circuitClient.Transfer(Mock.Of<IClientProxy>(), "connection1");
// Assert
Assert.True(circuitClient.Connected);
}
}
}

View File

@ -26,7 +26,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
// Arrange
var serviceScope = new Mock<IServiceScope>();
var remoteRenderer = GetRemoteRenderer(Renderer.CreateDefaultDispatcher());
var circuitHost = GetCircuitHost(
var circuitHost = TestCircuitHost.Create(
serviceScope.Object,
remoteRenderer);
@ -71,7 +71,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
.Returns(Task.CompletedTask)
.Verifiable();
var circuitHost = GetCircuitHost(handlers: new[] { handler1.Object, handler2.Object });
var circuitHost = TestCircuitHost.Create(handlers: new[] { handler1.Object, handler2.Object });
// Act
await circuitHost.InitializeAsync(cancellationToken);
@ -114,7 +114,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
.Returns(Task.CompletedTask)
.Verifiable();
var circuitHost = GetCircuitHost(handlers: new[] { handler1.Object, handler2.Object });
var circuitHost = TestCircuitHost.Create(handlers: new[] { handler1.Object, handler2.Object });
// Act
await circuitHost.DisposeAsync();
@ -124,30 +124,6 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
handler2.VerifyAll();
}
private static CircuitHost GetCircuitHost(
IServiceScope serviceScope = null,
RemoteRenderer remoteRenderer = null,
CircuitHandler[] handlers = null)
{
serviceScope = serviceScope ?? Mock.Of<IServiceScope>();
var clientProxy = Mock.Of<IClientProxy>();
var renderRegistry = new RendererRegistry();
var jsRuntime = Mock.Of<IJSRuntime>();
var dispatcher = Renderer.CreateDefaultDispatcher();
remoteRenderer = remoteRenderer ?? GetRemoteRenderer(dispatcher);
handlers = handlers ?? Array.Empty<CircuitHandler>();
return new CircuitHost(
serviceScope,
clientProxy,
renderRegistry,
remoteRenderer,
new List<ComponentDescriptor>(),
dispatcher,
jsRuntime: jsRuntime,
handlers);
}
private static TestRemoteRenderer GetRemoteRenderer(IDispatcher dispatcher)
{
return new TestRemoteRenderer(
@ -161,7 +137,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
private class TestRemoteRenderer : RemoteRenderer
{
public TestRemoteRenderer(IServiceProvider serviceProvider, RendererRegistry rendererRegistry, IDispatcher dispatcher, IJSRuntime jsRuntime, IClientProxy client)
: base(serviceProvider, rendererRegistry, jsRuntime, client, dispatcher, HtmlEncoder.Default, NullLogger.Instance)
: base(serviceProvider, rendererRegistry, jsRuntime, new CircuitClientProxy(client, "connection"), dispatcher, HtmlEncoder.Default, NullLogger.Instance)
{
}

View File

@ -0,0 +1,373 @@
// 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.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Components.Server.Circuits
{
public class CircuitRegistryTest
{
[Fact]
public void Register_AddsCircuit()
{
// Arrange
var registry = CreateRegistry();
var circuitHost = TestCircuitHost.Create();
// Act
registry.Register(circuitHost);
// Assert
var actual = Assert.Single(registry.ConnectedCircuits.Values);
Assert.Same(circuitHost, actual);
}
[Fact]
public async Task ConnectAsync_TransfersClientOnActiveCircuit()
{
// Arrange
var registry = CreateRegistry();
var circuitHost = TestCircuitHost.Create();
registry.Register(circuitHost);
var newClient = Mock.Of<IClientProxy>();
var newConnectionId = "new-id";
// Act
var result = await registry.ConnectAsync(circuitHost.CircuitId, newClient, newConnectionId, default);
// Assert
Assert.Same(circuitHost, result);
Assert.Same(newClient, circuitHost.Client.Client);
Assert.Same(newConnectionId, circuitHost.Client.ConnectionId);
var actual = Assert.Single(registry.ConnectedCircuits.Values);
Assert.Same(circuitHost, actual);
}
[Fact]
public async Task ConnectAsync_MakesInactiveCircuitActive()
{
// Arrange
var registry = CreateRegistry();
var circuitHost = TestCircuitHost.Create();
registry.DisconnectedCircuits.Set(circuitHost.CircuitId, circuitHost, new MemoryCacheEntryOptions { Size = 1 });
var newClient = Mock.Of<IClientProxy>();
var newConnectionId = "new-id";
// Act
var result = await registry.ConnectAsync(circuitHost.CircuitId, newClient, newConnectionId, default);
// Assert
Assert.Same(circuitHost, result);
Assert.Same(newClient, circuitHost.Client.Client);
Assert.Same(newConnectionId, circuitHost.Client.ConnectionId);
var actual = Assert.Single(registry.ConnectedCircuits.Values);
Assert.Same(circuitHost, actual);
Assert.False(registry.DisconnectedCircuits.TryGetValue(circuitHost.CircuitId, out _));
}
[Fact]
public async Task ConnectAsync_InvokesCircuitHandlers_WhenCircuitWasPreviouslyDisconnected()
{
// Arrange
var registry = CreateRegistry();
var handler = new Mock<CircuitHandler> { CallBase = true };
var circuitHost = TestCircuitHost.Create(handlers: new[] { handler.Object });
registry.DisconnectedCircuits.Set(circuitHost.CircuitId, circuitHost, new MemoryCacheEntryOptions { Size = 1 });
var newClient = Mock.Of<IClientProxy>();
var newConnectionId = "new-id";
// Act
var result = await registry.ConnectAsync(circuitHost.CircuitId, newClient, newConnectionId, default);
// Assert
Assert.NotNull(result);
handler.Verify(v => v.OnCircuitOpenedAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
handler.Verify(v => v.OnConnectionUpAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Once());
handler.Verify(v => v.OnConnectionDownAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
handler.Verify(v => v.OnCircuitClosedAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
}
[Fact]
public async Task ConnectAsync_InvokesCircuitHandlers_WhenCircuitWasConsideredConnected()
{
// Arrange
var registry = CreateRegistry();
var handler = new Mock<CircuitHandler> { CallBase = true };
var circuitHost = TestCircuitHost.Create(handlers: new[] { handler.Object });
registry.Register(circuitHost);
var newClient = Mock.Of<IClientProxy>();
var newConnectionId = "new-id";
// Act
var result = await registry.ConnectAsync(circuitHost.CircuitId, newClient, newConnectionId, default);
// Assert
Assert.NotNull(result);
handler.Verify(v => v.OnCircuitOpenedAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
handler.Verify(v => v.OnConnectionUpAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Once());
handler.Verify(v => v.OnConnectionDownAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Once());
handler.Verify(v => v.OnCircuitClosedAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
}
[Fact]
public async Task DisconnectAsync_DoesNothing_IfCircuitIsInactive()
{
// Arrange
var registry = CreateRegistry();
var circuitHost = TestCircuitHost.Create();
registry.DisconnectedCircuits.Set(circuitHost.CircuitId, circuitHost, new MemoryCacheEntryOptions { Size = 1 });
// Act
await registry.DisconnectAsync(circuitHost, circuitHost.Client.ConnectionId);
// Assert
Assert.Empty(registry.ConnectedCircuits.Values);
Assert.True(registry.DisconnectedCircuits.TryGetValue(circuitHost.CircuitId, out _));
}
[Fact]
public async Task DisconnectAsync_InvokesCircuitHandlers_WhenCircuitWasDisconnected()
{
// Arrange
var registry = CreateRegistry();
var handler = new Mock<CircuitHandler> { CallBase = true };
var circuitHost = TestCircuitHost.Create(handlers: new[] { handler.Object });
registry.Register(circuitHost);
// Act
await registry.DisconnectAsync(circuitHost, circuitHost.Client.ConnectionId);
// Assert
handler.Verify(v => v.OnCircuitOpenedAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
handler.Verify(v => v.OnConnectionUpAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
handler.Verify(v => v.OnConnectionDownAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Once());
handler.Verify(v => v.OnCircuitClosedAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
}
[Fact]
public async Task DisconnectAsync_DoesNotInvokeCircuitHandlers_WhenCircuitReconnected()
{
// Arrange
var registry = CreateRegistry();
var handler = new Mock<CircuitHandler> { CallBase = true };
var circuitHost = TestCircuitHost.Create(handlers: new[] { handler.Object });
registry.Register(circuitHost);
// Act
await registry.DisconnectAsync(circuitHost, "old-connection");
// Assert
handler.Verify(v => v.OnCircuitOpenedAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
handler.Verify(v => v.OnConnectionUpAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
handler.Verify(v => v.OnConnectionDownAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
handler.Verify(v => v.OnCircuitClosedAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
}
[Fact]
public async Task DisconnectAsync_DoesNotInvokeCircuitHandlers_WhenCircuitWasNotFound()
{
// Arrange
var registry = CreateRegistry();
var handler = new Mock<CircuitHandler> { CallBase = true };
var circuitHost = TestCircuitHost.Create(handlers: new[] { handler.Object });
// Act
await registry.DisconnectAsync(circuitHost, "old-connection");
// Assert
handler.Verify(v => v.OnCircuitOpenedAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
handler.Verify(v => v.OnConnectionUpAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
handler.Verify(v => v.OnConnectionDownAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
handler.Verify(v => v.OnCircuitClosedAsync(It.IsAny<Circuit>(), It.IsAny<CancellationToken>()), Times.Never());
}
[Fact]
public async Task Connect_WhileDisconnectIsInProgress()
{
// Arrange
var registry = new TestCircuitRegistry();
registry.BeforeDisconnect = new ManualResetEventSlim();
var tcs = new TaskCompletionSource<int>();
var circuitHost = TestCircuitHost.Create();
registry.Register(circuitHost);
var client = Mock.Of<IClientProxy>();
var newId = "new-connection";
// Act
var disconnect = Task.Run(() =>
{
var task = registry.DisconnectAsync(circuitHost, circuitHost.Client.ConnectionId);
tcs.SetResult(0);
return task;
});
var connect = Task.Run(async () =>
{
registry.BeforeDisconnect.Set();
await tcs.Task;
await registry.ConnectAsync(circuitHost.CircuitId, client, newId, default);
});
registry.BeforeDisconnect.Set();
await Task.WhenAll(disconnect, connect);
// Assert
// We expect the disconnect to finish followed by a reconnect
var actual = Assert.Single(registry.ConnectedCircuits.Values);
Assert.Same(circuitHost, actual);
Assert.Same(client, circuitHost.Client.Client);
Assert.Equal(newId, circuitHost.Client.ConnectionId);
Assert.False(registry.DisconnectedCircuits.TryGetValue(circuitHost.CircuitId, out _));
}
[Fact]
public async Task Connect_WhileDisconnectIsInProgress_SeriallyExecutesCircuitHandlers()
{
// Arrange
var registry = new TestCircuitRegistry();
registry.BeforeDisconnect = new ManualResetEventSlim();
// This verifies that connection up \ down events on a circuit handler are always invoked serially.
var circuitHandler = new SerialCircuitHandler();
var tcs = new TaskCompletionSource<int>();
var circuitHost = TestCircuitHost.Create(handlers: new[] { circuitHandler });
registry.Register(circuitHost);
var client = Mock.Of<IClientProxy>();
var newId = "new-connection";
// Act
var disconnect = Task.Run(() =>
{
var task = registry.DisconnectAsync(circuitHost, circuitHost.Client.ConnectionId);
tcs.SetResult(0);
return task;
});
var connect = Task.Run(async () =>
{
registry.BeforeDisconnect.Set();
await tcs.Task;
await registry.ConnectAsync(circuitHost.CircuitId, client, newId, default);
});
await Task.WhenAll(disconnect, connect);
// Assert
Assert.Single(registry.ConnectedCircuits.Values);
Assert.False(registry.DisconnectedCircuits.TryGetValue(circuitHost.CircuitId, out _));
Assert.True(circuitHandler.OnConnectionDownExecuted, "OnConnectionDownAsync should have been executed.");
Assert.True(circuitHandler.OnConnectionUpExecuted, "OnConnectionUpAsync should have been executed.");
}
[Fact]
public async Task DisconnectWhenAConnectIsInProgress()
{
// Arrange
var registry = new TestCircuitRegistry();
registry.BeforeConnect = new ManualResetEventSlim();
var circuitHost = TestCircuitHost.Create();
registry.Register(circuitHost);
var client = Mock.Of<IClientProxy>();
var oldId = circuitHost.Client.ConnectionId;
var newId = "new-connection";
// Act
var connect = Task.Run(() => registry.ConnectAsync(circuitHost.CircuitId, client, newId, default));
var disconnect = Task.Run(() => registry.DisconnectAsync(circuitHost, oldId));
registry.BeforeConnect.Set();
await Task.WhenAll(connect, disconnect);
// Assert
// We expect the disconnect to fail since the client identifier has changed.
var actual = Assert.Single(registry.ConnectedCircuits.Values);
Assert.Same(circuitHost, actual);
Assert.Same(client, circuitHost.Client.Client);
Assert.Equal(newId, circuitHost.Client.ConnectionId);
Assert.False(registry.DisconnectedCircuits.TryGetValue(circuitHost.CircuitId, out _));
}
private class TestCircuitRegistry : CircuitRegistry
{
public TestCircuitRegistry()
: base(Options.Create(new CircuitOptions()), NullLogger<CircuitRegistry>.Instance)
{
}
public ManualResetEventSlim BeforeConnect { get; set; }
public ManualResetEventSlim BeforeDisconnect { get; set; }
protected override (CircuitHost, bool) ConnectCore(string circuitId, IClientProxy clientProxy, string connectionId)
{
if (BeforeConnect != null)
{
Assert.True(BeforeConnect?.Wait(TimeSpan.FromSeconds(10)), "BeforeConnect failed to be set");
}
return base.ConnectCore(circuitId, clientProxy, connectionId);
}
protected override bool DisconnectCore(CircuitHost circuitHost, string connectionId)
{
if (BeforeDisconnect != null)
{
Assert.True(BeforeDisconnect?.Wait(TimeSpan.FromSeconds(10)), "BeforeDisconnect failed to be set");
}
return base.DisconnectCore(circuitHost, connectionId);
}
}
private static CircuitRegistry CreateRegistry()
{
return new CircuitRegistry(
Options.Create(new CircuitOptions()),
NullLogger<CircuitRegistry>.Instance);
}
private class SerialCircuitHandler : CircuitHandler
{
private readonly SemaphoreSlim _sempahore = new SemaphoreSlim(1);
public bool OnConnectionUpExecuted { get; private set; }
public bool OnConnectionDownExecuted { get; private set; }
public override async Task OnConnectionUpAsync(Circuit circuit, CancellationToken cancellationToken)
{
Assert.True(await _sempahore.WaitAsync(0), "This should be serialized and consequently without contention");
await Task.Delay(10);
Assert.False(OnConnectionUpExecuted);
Assert.True(OnConnectionDownExecuted);
OnConnectionUpExecuted = true;
_sempahore.Release();
}
public override async Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken)
{
Assert.True(await _sempahore.WaitAsync(0), "This should be serialized and consequently without contention");
await Task.Delay(10);
Assert.False(OnConnectionUpExecuted);
Assert.False(OnConnectionDownExecuted);
OnConnectionDownExecuted = true;
_sempahore.Release();
}
}
}
}

View File

@ -0,0 +1,141 @@
// 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.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.JSInterop;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Components.Browser.Rendering
{
public class RemoteRendererTest : HtmlRendererTestBase
{
protected override HtmlRenderer GetHtmlRenderer(IServiceProvider serviceProvider)
{
return GetRemoteRenderer(serviceProvider, CircuitClientProxy.OfflineClient);
}
[Fact]
public void WritesAreBufferedWhenTheClientIsOffline()
{
// Arrange
var serviceProvider = new ServiceCollection().BuildServiceProvider();
var renderer = (RemoteRenderer)GetHtmlRenderer(serviceProvider);
var component = new TestComponent(builder =>
{
builder.OpenElement(0, "my element");
builder.AddContent(1, "some text");
builder.CloseElement();
});
// Act
var componentId = renderer.AssignRootComponentId(component);
component.TriggerRender();
component.TriggerRender();
// Assert
Assert.Equal(2, renderer.OfflineRenderBatches.Count);
}
[Fact]
public async Task ProcessBufferedRenderBatches_WritesRenders()
{
// Arrange
var serviceProvider = new ServiceCollection().BuildServiceProvider();
var renderIds = new List<int>();
var initialClient = new Mock<IClientProxy>();
initialClient.Setup(c => c.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.Callback((string name, object[] value, CancellationToken token) =>
{
renderIds.Add((int)value[1]);
})
.Returns(Task.CompletedTask);
var circuitClient = new CircuitClientProxy(initialClient.Object, "connection0");
var renderer = GetRemoteRenderer(serviceProvider, circuitClient);
var component = new TestComponent(builder =>
{
builder.OpenElement(0, "my element");
builder.AddContent(1, "some text");
builder.CloseElement();
});
var client = new Mock<IClientProxy>();
client.Setup(c => c.SendCoreAsync(It.IsAny<string>(), It.IsAny<object[]>(), It.IsAny<CancellationToken>()))
.Callback((string name, object[] value, CancellationToken token) =>
{
renderIds.Add((int)value[1]);
})
.Returns(Task.CompletedTask);
var componentId = renderer.AssignRootComponentId(component);
component.TriggerRender();
renderer.OnRenderCompleted(1, null);
circuitClient.SetDisconnected();
component.TriggerRender();
component.TriggerRender();
// Act
circuitClient.Transfer(client.Object, "new-connection");
var task = renderer.ProcessBufferedRenderBatches();
foreach (var id in renderIds)
{
renderer.OnRenderCompleted(id, null);
}
await task;
// Assert
client.Verify(c => c.SendCoreAsync("JS.RenderBatch", It.IsAny<object[]>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
}
private RemoteRenderer GetRemoteRenderer(IServiceProvider serviceProvider, CircuitClientProxy circuitClientProxy)
{
return new RemoteRenderer(
serviceProvider,
new RendererRegistry(),
Mock.Of<IJSRuntime>(),
circuitClientProxy,
Dispatcher,
HtmlEncoder.Default,
NullLogger.Instance);
}
private class TestComponent : IComponent
{
private RenderHandle _renderHandle;
private RenderFragment _renderFragment;
public TestComponent(RenderFragment renderFragment)
{
_renderFragment = renderFragment;
}
public void Configure(RenderHandle renderHandle)
{
_renderHandle = renderHandle;
}
public Task SetParametersAsync(ParameterCollection parameters)
{
TriggerRender();
return Task.CompletedTask;
}
public void TriggerRender()
{
var task = _renderHandle.Invoke(() => _renderHandle.Render(_renderFragment));
Assert.True(task.IsCompletedSuccessfully);
}
}
}
}

View File

@ -0,0 +1,68 @@
// 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.Runtime.ExceptionServices;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Components.Browser;
using Microsoft.AspNetCore.Components.Browser.Rendering;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace Microsoft.AspNetCore.Components.Server.Circuits
{
internal class TestCircuitHost : CircuitHost
{
private TestCircuitHost(IServiceScope scope, CircuitClientProxy client, RendererRegistry rendererRegistry, RemoteRenderer renderer, IList<ComponentDescriptor> descriptors, IDispatcher dispatcher, RemoteJSRuntime jsRuntime, CircuitHandler[] circuitHandlers, ILogger logger)
: base(scope, client, rendererRegistry, renderer, descriptors, dispatcher, jsRuntime, circuitHandlers, logger)
{
}
protected override void OnHandlerError(CircuitHandler circuitHandler, string handlerMethod, Exception ex)
{
ExceptionDispatchInfo.Capture(ex).Throw();
}
public static CircuitHost Create(
IServiceScope serviceScope = null,
RemoteRenderer remoteRenderer = null,
CircuitHandler[] handlers = null,
CircuitClientProxy clientProxy = null)
{
serviceScope = serviceScope ?? Mock.Of<IServiceScope>();
clientProxy = clientProxy ?? new CircuitClientProxy(Mock.Of<IClientProxy>(), Guid.NewGuid().ToString());
var renderRegistry = new RendererRegistry();
var jsRuntime = new RemoteJSRuntime();
var dispatcher = Rendering.Renderer.CreateDefaultDispatcher();
if (remoteRenderer == null)
{
remoteRenderer = new RemoteRenderer(
Mock.Of<IServiceProvider>(),
new RendererRegistry(),
jsRuntime,
clientProxy,
dispatcher,
HtmlEncoder.Default,
NullLogger.Instance);
}
handlers = handlers ?? Array.Empty<CircuitHandler>();
return new TestCircuitHost(
serviceScope,
clientProxy,
renderRegistry,
remoteRenderer,
new List<ComponentDescriptor>(),
dispatcher,
jsRuntime,
handlers,
NullLogger<CircuitHost>.Instance);
}
}
}

View File

@ -11,4 +11,8 @@
<Reference Include="Microsoft.Extensions.Logging.Testing" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\Components\test\Rendering\HtmlRendererTestBase.cs" />
</ItemGroup>
</Project>

View File

@ -17,26 +17,11 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure
class SeleniumStandaloneServer
{
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
private static readonly object _instanceCreationLock = new object();
private static SeleniumStandaloneServer _instance;
private static Lazy<SeleniumStandaloneServer> _instance = new Lazy<SeleniumStandaloneServer>(() => new SeleniumStandaloneServer());
public Uri Uri { get; }
public static SeleniumStandaloneServer Instance
{
get
{
lock (_instanceCreationLock)
{
if (_instance == null)
{
_instance = new SeleniumStandaloneServer();
}
}
return _instance;
}
}
public static SeleniumStandaloneServer Instance => _instance.Value;
private SeleniumStandaloneServer()
{
@ -91,7 +76,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure
Timeout = TimeSpan.FromSeconds(1),
};
while (true)
var retries = 0;
while (retries++ < 30)
{
try
{
@ -109,6 +95,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure
}
await Task.Delay(1000);
}
throw new Exception("Failed to launch the server");
});
try

View File

@ -1,14 +1,14 @@
// 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 Microsoft.AspNetCore.Components.Browser.Rendering;
using ComponentsApp.App.Pages;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.Components.E2ETest.Tests;
using OpenQA.Selenium;
using System;
using System.Threading.Tasks;
using OpenQA.Selenium.Support.UI;
using Xunit;
using Xunit.Abstractions;

View File

@ -109,6 +109,55 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
}
}
[Fact]
public void ReconnectUI()
{
Browser.FindElement(By.LinkText("Counter")).Click();
var javascript = (IJavaScriptExecutor)Browser;
javascript.ExecuteScript(@"
window.modalDisplayState = [];
window.Blazor.circuitHandlers.push({
onConnectionUp: () => window.modalDisplayState.push(document.getElementById('components-reconnect-modal').style.display),
onConnectionDown: () => window.modalDisplayState.push(document.getElementById('components-reconnect-modal').style.display)
});
window.Blazor._internal.forceCloseConnection();");
new WebDriverWait(Browser, TimeSpan.FromSeconds(10)).Until(
driver => (long)javascript.ExecuteScript("console.log(window.modalDisplayState); return window.modalDisplayState.length") == 2);
var states = (string)javascript.ExecuteScript("return window.modalDisplayState.join(',')");
Assert.Equal("block,none", states);
}
[Fact]
public void RendersContinueAfterReconnect()
{
Browser.FindElement(By.LinkText("Ticker")).Click();
var selector = By.ClassName("tick-value");
var element = Browser.FindElement(selector);
var initialValue = element.Text;
var javascript = (IJavaScriptExecutor)Browser;
javascript.ExecuteScript(@"
window.connectionUp = false;
window.Blazor.circuitHandlers.push({
onConnectionUp: () => window.connectionUp = true
});
window.Blazor._internal.forceCloseConnection();");
new WebDriverWait(Browser, TimeSpan.FromSeconds(10)).Until(
driver => (bool)javascript.ExecuteScript("return window.connectionUp"));
var currentValue = element.Text;
Assert.NotEqual(initialValue, currentValue);
// Verify it continues to tick
new WebDriverWait(Browser, TimeSpan.FromSeconds(10)).Until(
_ => element.Text != currentValue);
}
private void WaitUntilLoaded()
{
new WebDriverWait(Browser, TimeSpan.FromSeconds(30)).Until(

View File

@ -0,0 +1,28 @@
@page "/ticker"
@implements IDisposable
@using System.Threading
<h1>Ticker</h1>
<p class="tick-value">@tick</p>
@functions {
int tick;
private bool _isDisposed;
protected override void OnInit()
{
Task.Run(() => InvokeAsync(async () =>
{
while (!_isDisposed)
{
await Task.Delay(TimeSpan.FromSeconds(1));
tick++;
StateHasChanged();
}
}));
}
public void Dispose() => _isDisposed = true;
}

View File

@ -22,6 +22,11 @@
<span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</NavLink>
</li>
<li class="nav-item px-3">
<NavLink class="nav-link" href="ticker">
<span class="oi oi-list-rich" aria-hidden="true"></span> Ticker
</NavLink>
</li>
</ul>
</div>