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> </Target>
<ItemGroup> <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.AspNetCore.Components" />
<Reference Include="Microsoft.Extensions.CommandLineUtils.Sources" /> <Reference Include="Microsoft.Extensions.CommandLineUtils.Sources" />
<Reference Include="Microsoft.Extensions.FileProviders.Composite" /> <Reference Include="Microsoft.Extensions.FileProviders.Composite" />

View File

@ -2,7 +2,7 @@
<Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), Directory.Build.props))\Directory.Build.props" /> <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), Directory.Build.props))\Directory.Build.props" />
<PropertyGroup> <PropertyGroup>
<IsTestProject>false</IsTestProject> <IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
</PropertyGroup> </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": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"build:debug": "webpack --mode development", "build:debug": "cd src && webpack --mode development --config ./webpack.config.js",
"build:production": "webpack --mode production", "build:production": "cd src && webpack --mode production --config ./webpack.config.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "jest"
}, },
"devDependencies": { "devDependencies": {
"@aspnet/signalr": "^1.0.0", "@aspnet/signalr": "^1.0.0",
"@aspnet/signalr-protocol-msgpack": "^1.0.0", "@aspnet/signalr-protocol-msgpack": "^1.0.0",
"@dotnet/jsinterop": "^0.1.1", "@dotnet/jsinterop": "^0.1.1",
"@types/jsdom": "11.0.6",
"@types/jest": "^24.0.6",
"@types/emscripten": "0.0.31", "@types/emscripten": "0.0.31",
"jest": "^24.1.0",
"ts-jest": "^24.0.0",
"ts-loader": "^4.4.1", "ts-loader": "^4.4.1",
"typescript": "^2.9.2", "typescript": "^2.9.2",
"webpack": "^4.12.0", "webpack": "^4.12.0",

View File

@ -6,16 +6,43 @@ import { OutOfProcessRenderBatch } from './Rendering/RenderBatch/OutOfProcessRen
import { internalFunctions as uriHelperFunctions } from './Services/UriHelper'; import { internalFunctions as uriHelperFunctions } from './Services/UriHelper';
import { renderBatch } from './Rendering/Renderer'; import { renderBatch } from './Rendering/Renderer';
import { fetchBootConfigAsync, loadEmbeddedResourcesAsync } from './BootCommon'; 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 // In the background, start loading the boot config and any embedded resources
const embeddedResourcesPromise = fetchBootConfigAsync().then(bootConfig => { const embeddedResourcesPromise = fetchBootConfigAsync().then(bootConfig => {
return loadEmbeddedResourcesAsync(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') .withUrl('_blazor')
.withHubProtocol(new MessagePackHubProtocol()) .withHubProtocol(new MessagePackHubProtocol())
.configureLogging(signalR.LogLevel.Information) .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() window['Blazor']._internal.forceCloseConnection = () => connection.stop();
.then(async () => {
DotNet.attachDispatcher({
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
}
});
// Ensure any embedded resources have been loaded before starting the app try {
await embeddedResourcesPromise; await connection.start();
} catch (ex) {
unhandledError(connection, ex);
}
connection.send( DotNet.attachDispatcher({
'StartCircuit', beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
uriHelperFunctions.getLocationHref(), connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
uriHelperFunctions.getBaseURI() }
); });
})
.catch(unhandledError);
// Temporary undocumented API to help with https://github.com/aspnet/Blazor/issues/1339 return connection;
// This will be replaced once we implement proper connection management (reconnects, etc.)
window['Blazor'].onServerConnectionClose = connection.onclose.bind(connection);
} }
function unhandledError(err) { function unhandledError(connection: signalR.HubConnection, err: Error) {
console.error(err); console.error(err);
// Disconnect on errors. // 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. // Trying to call methods on the connection after its been closed will throw.
if (connection) { if (connection) {
connection.stop(); 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; let hasRegisteredEventListeners = false;
// Will be initialized once someone registers // Will be initialized once someone registers

View File

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

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 Renderer(System.IServiceProvider serviceProvider, Microsoft.AspNetCore.Components.Rendering.IDispatcher dispatcher) { }
public event System.UnhandledExceptionEventHandler UnhandledSynchronizationException { add { } remove { } } public event System.UnhandledExceptionEventHandler UnhandledSynchronizationException { add { } remove { } }
protected internal virtual void AddToRenderQueue(int componentId, Microsoft.AspNetCore.Components.RenderFragment renderFragment) { } 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 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 System.Threading.Tasks.Task DispatchEventAsync(int eventHandlerId, Microsoft.AspNetCore.Components.UIEventArgs eventArgs) { throw null; }
public void Dispose() { } 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 Dictionary<int, ComponentState> _componentStateById = new Dictionary<int, ComponentState>();
private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder(); private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder();
private readonly Dictionary<int, EventCallback> _eventBindings = new Dictionary<int, EventCallback>(); 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 int _nextComponentId = 0; // TODO: change to 'long' when Mono .NET->JS interop supports it
private bool _isBatchInProgress; private bool _isBatchInProgress;
@ -89,7 +89,8 @@ namespace Microsoft.AspNetCore.Components.Rendering
/// </summary> /// </summary>
/// <param name="component">The component.</param> /// <param name="component">The component.</param>
/// <returns>The component's assigned identifier.</returns> /// <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; => AttachAndInitComponent(component, -1).ComponentId;
/// <summary> /// <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. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; 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 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); protected override HtmlRenderer GetHtmlRenderer(IServiceProvider serviceProvider)
[Fact]
public void RenderComponentAsync_CanRenderEmptyElement()
{ {
// Arrange return new HtmlRenderer(serviceProvider, _encoder, Dispatcher);
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;
}
} }
} }
} }

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. // 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.Http;
using Microsoft.AspNetCore.SignalR;
namespace Microsoft.AspNetCore.Components.Server.Circuits namespace Microsoft.AspNetCore.Components.Server.Circuits
{ {
@ -10,7 +9,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
{ {
public abstract CircuitHost CreateCircuitHost( public abstract CircuitHost CreateCircuitHost(
HttpContext httpContext, HttpContext httpContext,
IClientProxy client, CircuitClientProxy client,
string uriAbsolute, string uriAbsolute,
string baseUriAbsolute); 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> /// <returns><see cref="Task"/> that represents the asynchronous execution operation.</returns>
public virtual Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken) => Task.CompletedTask; public virtual Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken) => Task.CompletedTask;
/// <summary> /// <summary>
/// Invoked when a new circuit is being discarded. /// Invoked when a new circuit is being discarded.
/// </summary> /// </summary>

View File

@ -8,8 +8,8 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Browser; using Microsoft.AspNetCore.Components.Browser;
using Microsoft.AspNetCore.Components.Browser.Rendering; using Microsoft.AspNetCore.Components.Browser.Rendering;
using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop; using Microsoft.JSInterop;
namespace Microsoft.AspNetCore.Components.Server.Circuits namespace Microsoft.AspNetCore.Components.Server.Circuits
@ -17,9 +17,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
internal class CircuitHost : IAsyncDisposable internal class CircuitHost : IAsyncDisposable
{ {
private static readonly AsyncLocal<CircuitHost> _current = new AsyncLocal<CircuitHost>(); private static readonly AsyncLocal<CircuitHost> _current = new AsyncLocal<CircuitHost>();
private readonly SemaphoreSlim HandlerLock = new SemaphoreSlim(1);
private readonly IServiceScope _scope; private readonly IServiceScope _scope;
private readonly IDispatcher _dispatcher;
private readonly CircuitHandler[] _circuitHandlers; private readonly CircuitHandler[] _circuitHandlers;
private readonly ILogger _logger;
private bool _initialized; private bool _initialized;
/// <summary> /// <summary>
@ -49,22 +50,24 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
public CircuitHost( public CircuitHost(
IServiceScope scope, IServiceScope scope,
IClientProxy client, CircuitClientProxy client,
RendererRegistry rendererRegistry, RendererRegistry rendererRegistry,
RemoteRenderer renderer, RemoteRenderer renderer,
IList<ComponentDescriptor> descriptors, IList<ComponentDescriptor> descriptors,
IDispatcher dispatcher, IDispatcher dispatcher,
IJSRuntime jsRuntime, RemoteJSRuntime jsRuntime,
CircuitHandler[] circuitHandlers) CircuitHandler[] circuitHandlers,
ILogger logger)
{ {
_scope = scope ?? throw new ArgumentNullException(nameof(scope)); _scope = scope ?? throw new ArgumentNullException(nameof(scope));
_dispatcher = dispatcher; Dispatcher = dispatcher;
Client = client; Client = client;
RendererRegistry = rendererRegistry ?? throw new ArgumentNullException(nameof(rendererRegistry)); RendererRegistry = rendererRegistry ?? throw new ArgumentNullException(nameof(rendererRegistry));
Descriptors = descriptors ?? throw new ArgumentNullException(nameof(descriptors)); Descriptors = descriptors ?? throw new ArgumentNullException(nameof(descriptors));
Renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); Renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
JSRuntime = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime)); JSRuntime = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime));
_logger = logger;
Services = scope.ServiceProvider; Services = scope.ServiceProvider;
Circuit = new Circuit(this); Circuit = new Circuit(this);
@ -78,9 +81,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
public Circuit Circuit { get; } 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; } public RemoteRenderer Renderer { get; }
@ -90,11 +93,12 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
public IServiceProvider Services { get; } public IServiceProvider Services { get; }
public IDispatcher Dispatcher { get; }
public Task<IEnumerable<string>> PrerenderComponentAsync(Type componentType, ParameterCollection parameters) 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); var result = await Renderer.RenderComponentAsync(componentType, parameters);
return result; return result;
}); });
@ -112,15 +116,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
await Renderer.AddComponentAsync(componentType, domElementSelector); await Renderer.AddComponentAsync(componentType, domElementSelector);
} }
for (var i = 0; i < _circuitHandlers.Length; i++) await OnCircuitOpenedAsync(cancellationToken);
{
await _circuitHandlers[i].OnCircuitOpenedAsync(Circuit, cancellationToken);
}
for (var i = 0; i < _circuitHandlers.Length; i++) await OnConnectionUpAsync(cancellationToken);
{
await _circuitHandlers[i].OnConnectionUpAsync(Circuit, cancellationToken);
}
}); });
_initialized = true; _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++) 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(); _scope.Dispose();
Renderer.Dispose(); Renderer.Dispose();
@ -167,7 +247,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
{ {
if (!_initialized) 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); 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 context = prerenderingContext.Context;
var circuitHost = _circuitFactory.CreateCircuitHost( var circuitHost = _circuitFactory.CreateCircuitHost(
context, context,
client: null, client: CircuitClientProxy.OfflineClient,
GetFullUri(context.Request), GetFullUri(context.Request),
GetFullBaseUri(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.Components.Services;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.JSInterop; using Microsoft.JSInterop;
@ -33,7 +32,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
public override CircuitHost CreateCircuitHost( public override CircuitHost CreateCircuitHost(
HttpContext httpContext, HttpContext httpContext,
IClientProxy client, CircuitClientProxy client,
string uriAbsolute, string uriAbsolute,
string baseUriAbsolute) string baseUriAbsolute)
{ {
@ -42,13 +41,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
var scope = _scopeFactory.CreateScope(); var scope = _scopeFactory.CreateScope();
var encoder = scope.ServiceProvider.GetRequiredService<HtmlEncoder>(); var encoder = scope.ServiceProvider.GetRequiredService<HtmlEncoder>();
var jsRuntime = (RemoteJSRuntime)scope.ServiceProvider.GetRequiredService<IJSRuntime>(); var jsRuntime = (RemoteJSRuntime)scope.ServiceProvider.GetRequiredService<IJSRuntime>();
if (client != null) jsRuntime.Initialize(client);
{
jsRuntime.Initialize(client);
}
var uriHelper = (RemoteUriHelper)scope.ServiceProvider.GetRequiredService<IUriHelper>(); var uriHelper = (RemoteUriHelper)scope.ServiceProvider.GetRequiredService<IUriHelper>();
if (client != null) if (client != CircuitClientProxy.OfflineClient)
{ {
uriHelper.Initialize(uriAbsolute, baseUriAbsolute, jsRuntime); uriHelper.Initialize(uriAbsolute, baseUriAbsolute, jsRuntime);
} }
@ -80,7 +76,8 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
components, components,
dispatcher, dispatcher,
jsRuntime, jsRuntime,
circuitHandlers); circuitHandlers,
_loggerFactory.CreateLogger<CircuitHost>());
// Initialize per - circuit data that services need // Initialize per - circuit data that services need
(circuitHost.Services.GetRequiredService<ICircuitAccessor>() as DefaultCircuitAccessor).Circuit = circuitHost.Circuit; (circuitHost.Services.GetRequiredService<ICircuitAccessor>() as DefaultCircuitAccessor).Circuit = circuitHost.Circuit;
@ -88,9 +85,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
return circuitHost; 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. // This is the prerendering case.
return Array.Empty<ComponentDescriptor>(); return Array.Empty<ComponentDescriptor>();

View File

@ -11,21 +11,20 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
{ {
private IClientProxy _clientProxy; private IClientProxy _clientProxy;
public RemoteJSRuntime() internal void Initialize(CircuitClientProxy clientProxy)
{
}
internal void Initialize(IClientProxy clientProxy)
{ {
_clientProxy = clientProxy ?? throw new ArgumentNullException(nameof(clientProxy)); _clientProxy = clientProxy ?? throw new ArgumentNullException(nameof(clientProxy));
} }
protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson) 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); _clientProxy.SendAsync("JS.BeginInvokeJS", asyncHandle, identifier, argsJson);
} }
} }

View File

@ -3,12 +3,12 @@
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Text.Encodings.Web; using System.Text.Encodings.Web;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MessagePack; using MessagePack;
using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -23,14 +23,13 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
private const int TimeoutMilliseconds = 60 * 1000; private const int TimeoutMilliseconds = 60 * 1000;
private readonly int _id; private readonly int _id;
private IClientProxy _client;
private readonly IJSRuntime _jsRuntime; private readonly IJSRuntime _jsRuntime;
private readonly CircuitClientProxy _client;
private readonly RendererRegistry _rendererRegistry; private readonly RendererRegistry _rendererRegistry;
private readonly ConcurrentDictionary<long, AutoCancelTaskCompletionSource<object>> _pendingRenders private readonly ConcurrentDictionary<long, AutoCancelTaskCompletionSource<object>> _pendingRenders
= new ConcurrentDictionary<long, AutoCancelTaskCompletionSource<object>>(); = new ConcurrentDictionary<long, AutoCancelTaskCompletionSource<object>>();
private readonly ILogger _logger; private readonly ILogger _logger;
private long _nextRenderId = 1; private long _nextRenderId = 1;
private bool _prerenderMode;
/// <summary> /// <summary>
/// Notifies when a rendering exception occured. /// Notifies when a rendering exception occured.
@ -40,16 +39,11 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
/// <summary> /// <summary>
/// Creates a new <see cref="RemoteRenderer"/>. /// Creates a new <see cref="RemoteRenderer"/>.
/// </summary> /// </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( public RemoteRenderer(
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
RendererRegistry rendererRegistry, RendererRegistry rendererRegistry,
IJSRuntime jsRuntime, IJSRuntime jsRuntime,
IClientProxy client, CircuitClientProxy client,
IDispatcher dispatcher, IDispatcher dispatcher,
HtmlEncoder encoder, HtmlEncoder encoder,
ILogger logger) ILogger logger)
@ -63,6 +57,8 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
_logger = logger; _logger = logger;
} }
internal ConcurrentQueue<byte[]> OfflineRenderBatches = new ConcurrentQueue<byte[]>();
/// <summary> /// <summary>
/// Associates the <see cref="IComponent"/> with the <see cref="RemoteRenderer"/>, /// Associates the <see cref="IComponent"/> with the <see cref="RemoteRenderer"/>,
/// causing it to be displayed in the specified DOM element. /// 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) foreach (var innerException in aggregateException.Flatten().InnerExceptions)
{ {
_logger.UnhandledExceptionRenderingComponent(innerException); Log.UnhandledExceptionRenderingComponent(_logger, innerException);
} }
} }
else else
{ {
_logger.UnhandledExceptionRenderingComponent(exception); Log.UnhandledExceptionRenderingComponent(_logger, exception);
} }
} }
internal void StartPrerender()
{
_prerenderMode = true;
}
/// <inheritdoc /> /// <inheritdoc />
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
@ -115,14 +106,6 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
/// <inheritdoc /> /// <inheritdoc />
protected override Task UpdateDisplayAsync(in RenderBatch batch) 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 // 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. // 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 // 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. // buffer on every render.
var batchBytes = MessagePackSerializer.Serialize(batch, RenderBatchFormatterResolver.Instance); 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 renderId = Interlocked.Increment(ref _nextRenderId);
var pendingRenderInfo = new AutoCancelTaskCompletionSource<object>(TimeoutMilliseconds); var pendingRenderInfo = new AutoCancelTaskCompletionSource<object>(TimeoutMilliseconds);
_pendingRenders[renderId] = pendingRenderInfo; _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; private IJSRuntime _jsRuntime;
public RemoteUriHelper()
{
}
/// <summary> /// <summary>
/// Initializes the <see cref="RemoteUriHelper"/>. /// Initializes the <see cref="RemoteUriHelper"/>.
/// </summary> /// </summary>

View File

@ -4,7 +4,6 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.Components.Services;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -19,6 +18,7 @@ namespace Microsoft.AspNetCore.Components.Server
{ {
private static readonly object CircuitKey = new object(); private static readonly object CircuitKey = new object();
private readonly CircuitFactory _circuitFactory; private readonly CircuitFactory _circuitFactory;
private readonly CircuitRegistry _circuitRegistry;
private readonly ILogger _logger; private readonly ILogger _logger;
/// <summary> /// <summary>
@ -28,6 +28,7 @@ namespace Microsoft.AspNetCore.Components.Server
public ComponentHub(IServiceProvider services, ILogger<ComponentHub> logger) public ComponentHub(IServiceProvider services, ILogger<ComponentHub> logger)
{ {
_circuitFactory = services.GetRequiredService<CircuitFactory>(); _circuitFactory = services.GetRequiredService<CircuitFactory>();
_circuitRegistry = services.GetRequiredService<CircuitRegistry>();
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); _logger = logger ?? throw new ArgumentNullException(nameof(logger));
} }
@ -48,19 +49,28 @@ namespace Microsoft.AspNetCore.Components.Server
/// <summary> /// <summary>
/// Intended for framework use only. Applications should not call this method directly. /// Intended for framework use only. Applications should not call this method directly.
/// </summary> /// </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> /// <summary>
/// Intended for framework use only. Applications should not call this method directly. /// Intended for framework use only. Applications should not call this method directly.
/// </summary> /// </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( var circuitHost = _circuitFactory.CreateCircuitHost(
Context.GetHttpContext(), Context.GetHttpContext(),
Clients.Caller, circuitClient,
uriAbsolute, uriAbsolute,
baseUriAbsolute); 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. // If initialization fails, this will throw. The caller will fail if they try to call into any interop API.
await circuitHost.InitializeAsync(Context.ConnectionAborted); await circuitHost.InitializeAsync(Context.ConnectionAborted);
_circuitRegistry.Register(circuitHost);
CircuitHost = 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> /// <summary>

View File

@ -33,6 +33,8 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddScoped(s => s.GetRequiredService<ICircuitAccessor>().Circuit); services.TryAddScoped(s => s.GetRequiredService<ICircuitAccessor>().Circuit);
services.TryAddScoped<ICircuitAccessor, DefaultCircuitAccessor>(); services.TryAddScoped<ICircuitAccessor, DefaultCircuitAccessor>();
services.TryAddSingleton<CircuitRegistry>();
// We explicitly take over the prerendering and components services here. // We explicitly take over the prerendering and components services here.
// We can't have two separate component implementations coexisting at the // We can't have two separate component implementations coexisting at the
// same time, so when you register components (Circuits) it takes over // 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> <PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework> <TargetFramework>netcoreapp3.0</TargetFramework>
@ -23,6 +23,7 @@
<Reference Include="Microsoft.AspNetCore.SignalR" /> <Reference Include="Microsoft.AspNetCore.SignalR" />
<Reference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" /> <Reference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" />
<Reference Include="Microsoft.AspNetCore.StaticFiles" /> <Reference Include="Microsoft.AspNetCore.StaticFiles" />
<Reference Include="Microsoft.Extensions.Caching.Memory" />
<Reference Include="Microsoft.Extensions.FileProviders.Composite" /> <Reference Include="Microsoft.Extensions.FileProviders.Composite" />
<Reference Include="Microsoft.Extensions.FileProviders.Embedded" /> <Reference Include="Microsoft.Extensions.FileProviders.Embedded" />
@ -34,7 +35,7 @@
<ItemGroup Condition="'$(BuildNodeJS)' != 'false'"> <ItemGroup Condition="'$(BuildNodeJS)' != 'false'">
<!-- We need .Browser.JS to build first so we can embed its .js output --> <!-- 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)" /> <EmbeddedResource Include="..\..\Browser.JS\src\dist\components.server.js" LogicalName="_framework\%(Filename)%(Extension)" />
</ItemGroup> </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 // Arrange
var serviceScope = new Mock<IServiceScope>(); var serviceScope = new Mock<IServiceScope>();
var remoteRenderer = GetRemoteRenderer(Renderer.CreateDefaultDispatcher()); var remoteRenderer = GetRemoteRenderer(Renderer.CreateDefaultDispatcher());
var circuitHost = GetCircuitHost( var circuitHost = TestCircuitHost.Create(
serviceScope.Object, serviceScope.Object,
remoteRenderer); remoteRenderer);
@ -71,7 +71,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
.Returns(Task.CompletedTask) .Returns(Task.CompletedTask)
.Verifiable(); .Verifiable();
var circuitHost = GetCircuitHost(handlers: new[] { handler1.Object, handler2.Object }); var circuitHost = TestCircuitHost.Create(handlers: new[] { handler1.Object, handler2.Object });
// Act // Act
await circuitHost.InitializeAsync(cancellationToken); await circuitHost.InitializeAsync(cancellationToken);
@ -114,7 +114,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
.Returns(Task.CompletedTask) .Returns(Task.CompletedTask)
.Verifiable(); .Verifiable();
var circuitHost = GetCircuitHost(handlers: new[] { handler1.Object, handler2.Object }); var circuitHost = TestCircuitHost.Create(handlers: new[] { handler1.Object, handler2.Object });
// Act // Act
await circuitHost.DisposeAsync(); await circuitHost.DisposeAsync();
@ -124,30 +124,6 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
handler2.VerifyAll(); 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) private static TestRemoteRenderer GetRemoteRenderer(IDispatcher dispatcher)
{ {
return new TestRemoteRenderer( return new TestRemoteRenderer(
@ -161,7 +137,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
private class TestRemoteRenderer : RemoteRenderer private class TestRemoteRenderer : RemoteRenderer
{ {
public TestRemoteRenderer(IServiceProvider serviceProvider, RendererRegistry rendererRegistry, IDispatcher dispatcher, IJSRuntime jsRuntime, IClientProxy client) 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" /> <Reference Include="Microsoft.Extensions.Logging.Testing" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Compile Include="..\..\Components\test\Rendering\HtmlRendererTestBase.cs" />
</ItemGroup>
</Project> </Project>

View File

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

View File

@ -1,14 +1,14 @@
// Copyright (c) .NET Foundation. All rights reserved. // 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. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using BasicTestApp; using BasicTestApp;
using Microsoft.AspNetCore.Components.Browser.Rendering; using ComponentsApp.App.Pages;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.Components.E2ETest.Tests; using Microsoft.AspNetCore.Components.E2ETest.Tests;
using OpenQA.Selenium; using OpenQA.Selenium;
using System; using OpenQA.Selenium.Support.UI;
using System.Threading.Tasks;
using Xunit; using Xunit;
using Xunit.Abstractions; 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() private void WaitUntilLoaded()
{ {
new WebDriverWait(Browser, TimeSpan.FromSeconds(30)).Until( 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 <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</NavLink> </NavLink>
</li> </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> </ul>
</div> </div>