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:
parent
d09c6e8576
commit
33839dc66a
|
|
@ -38,7 +38,7 @@
|
|||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Condition="'$(BuildNodeJS)' != 'false'" Include="$(RepositoryRoot)src\Components\Browser.JS\src\Microsoft.AspNetCore.Components.Browser.JS.npmproj" ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Condition="'$(BuildNodeJS)' != 'false'" Include="$(RepositoryRoot)src\Components\Browser.JS\Microsoft.AspNetCore.Components.Browser.JS.npmproj" ReferenceOutputAssembly="false" />
|
||||
<Reference Include="Microsoft.AspNetCore.Components" />
|
||||
<Reference Include="Microsoft.Extensions.CommandLineUtils.Sources" />
|
||||
<Reference Include="Microsoft.Extensions.FileProviders.Composite" />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
<Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), Directory.Build.props))\Directory.Build.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsTestProject>false</IsTestProject>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
|
|
@ -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'
|
||||
};
|
||||
|
|
@ -5,15 +5,19 @@
|
|||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build:debug": "webpack --mode development",
|
||||
"build:production": "webpack --mode production",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"build:debug": "cd src && webpack --mode development --config ./webpack.config.js",
|
||||
"build:production": "cd src && webpack --mode production --config ./webpack.config.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aspnet/signalr": "^1.0.0",
|
||||
"@aspnet/signalr-protocol-msgpack": "^1.0.0",
|
||||
"@dotnet/jsinterop": "^0.1.1",
|
||||
"@types/jsdom": "11.0.6",
|
||||
"@types/jest": "^24.0.6",
|
||||
"@types/emscripten": "0.0.31",
|
||||
"jest": "^24.1.0",
|
||||
"ts-jest": "^24.0.0",
|
||||
"ts-loader": "^4.4.1",
|
||||
"typescript": "^2.9.2",
|
||||
"webpack": "^4.12.0",
|
||||
|
|
@ -6,16 +6,43 @@ import { OutOfProcessRenderBatch } from './Rendering/RenderBatch/OutOfProcessRen
|
|||
import { internalFunctions as uriHelperFunctions } from './Services/UriHelper';
|
||||
import { renderBatch } from './Rendering/Renderer';
|
||||
import { fetchBootConfigAsync, loadEmbeddedResourcesAsync } from './BootCommon';
|
||||
import { CircuitHandler } from './Platform/Circuits/CircuitHandler';
|
||||
import { AutoReconnectCircuitHandler } from './Platform/Circuits/AutoReconnectCircuitHandler';
|
||||
|
||||
let connection : signalR.HubConnection;
|
||||
async function boot() {
|
||||
const circuitHandlers: CircuitHandler[] = [ new AutoReconnectCircuitHandler() ];
|
||||
window['Blazor'].circuitHandlers = circuitHandlers;
|
||||
|
||||
function boot() {
|
||||
// In the background, start loading the boot config and any embedded resources
|
||||
const embeddedResourcesPromise = fetchBootConfigAsync().then(bootConfig => {
|
||||
return loadEmbeddedResourcesAsync(bootConfig);
|
||||
});
|
||||
|
||||
connection = new signalR.HubConnectionBuilder()
|
||||
const initialConnection = await initializeConnection(circuitHandlers);
|
||||
|
||||
// Ensure any embedded resources have been loaded before starting the app
|
||||
await embeddedResourcesPromise;
|
||||
const circuitId = await initialConnection.invoke<string>(
|
||||
'StartCircuit',
|
||||
uriHelperFunctions.getLocationHref(),
|
||||
uriHelperFunctions.getBaseURI()
|
||||
);
|
||||
|
||||
window['Blazor'].reconnect = async () => {
|
||||
const reconnection = await initializeConnection(circuitHandlers);
|
||||
if (!(await reconnection.invoke<Boolean>('ConnectCircuit', circuitId))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
circuitHandlers.forEach(h => h.onConnectionUp && h.onConnectionUp());
|
||||
return true;
|
||||
};
|
||||
|
||||
circuitHandlers.forEach(h => h.onConnectionUp && h.onConnectionUp());
|
||||
}
|
||||
|
||||
async function initializeConnection(circuitHandlers: CircuitHandler[]): Promise<signalR.HubConnection> {
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl('_blazor')
|
||||
.withHubProtocol(new MessagePackHubProtocol())
|
||||
.configureLogging(signalR.LogLevel.Information)
|
||||
|
|
@ -33,40 +60,31 @@ function boot() {
|
|||
}
|
||||
});
|
||||
|
||||
connection.on('JS.Error', unhandledError);
|
||||
connection.onclose(error => circuitHandlers.forEach(h => h.onConnectionDown && h.onConnectionDown(error)));
|
||||
connection.on('JS.Error', error => unhandledError(connection, error));
|
||||
|
||||
connection.start()
|
||||
.then(async () => {
|
||||
DotNet.attachDispatcher({
|
||||
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
|
||||
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
|
||||
}
|
||||
});
|
||||
window['Blazor']._internal.forceCloseConnection = () => connection.stop();
|
||||
|
||||
// Ensure any embedded resources have been loaded before starting the app
|
||||
await embeddedResourcesPromise;
|
||||
try {
|
||||
await connection.start();
|
||||
} catch (ex) {
|
||||
unhandledError(connection, ex);
|
||||
}
|
||||
|
||||
connection.send(
|
||||
'StartCircuit',
|
||||
uriHelperFunctions.getLocationHref(),
|
||||
uriHelperFunctions.getBaseURI()
|
||||
);
|
||||
})
|
||||
.catch(unhandledError);
|
||||
DotNet.attachDispatcher({
|
||||
beginInvokeDotNetFromJS: (callId, assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
|
||||
connection.send('BeginInvokeDotNetFromJS', callId ? callId.toString() : null, assemblyName, methodIdentifier, dotNetObjectId || 0, argsJson);
|
||||
}
|
||||
});
|
||||
|
||||
// Temporary undocumented API to help with https://github.com/aspnet/Blazor/issues/1339
|
||||
// This will be replaced once we implement proper connection management (reconnects, etc.)
|
||||
window['Blazor'].onServerConnectionClose = connection.onclose.bind(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
function unhandledError(err) {
|
||||
function unhandledError(connection: signalR.HubConnection, err: Error) {
|
||||
console.error(err);
|
||||
|
||||
// Disconnect on errors.
|
||||
//
|
||||
// TODO: it would be nice to have some kind of experience for what happens when you're
|
||||
// trying to interact with an app that's disconnected.
|
||||
//
|
||||
// Trying to call methods on the connection after its been closed will throw.
|
||||
if (connection) {
|
||||
connection.stop();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export interface ReconnectDisplay {
|
||||
show(): void;
|
||||
hide(): void;
|
||||
failed(): void;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import '@dotnet/jsinterop';
|
||||
|
||||
let hasRegisteredEventListeners = false;
|
||||
|
||||
// Will be initialized once someone registers
|
||||
|
|
|
|||
|
|
@ -1,15 +1,3 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"noImplicitAny": false,
|
||||
"noEmitOnError": true,
|
||||
"removeComments": false,
|
||||
"sourceMap": true,
|
||||
"target": "es5",
|
||||
"lib": ["es2015", "dom"],
|
||||
"strict": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"wwwroot"
|
||||
]
|
||||
"extends": "../tsconfig.base.json"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "../tsconfig.base.json"
|
||||
}
|
||||
|
|
@ -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
|
|
@ -823,7 +823,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
|||
public Renderer(System.IServiceProvider serviceProvider, Microsoft.AspNetCore.Components.Rendering.IDispatcher dispatcher) { }
|
||||
public event System.UnhandledExceptionEventHandler UnhandledSynchronizationException { add { } remove { } }
|
||||
protected internal virtual void AddToRenderQueue(int componentId, Microsoft.AspNetCore.Components.RenderFragment renderFragment) { }
|
||||
protected int AssignRootComponentId(Microsoft.AspNetCore.Components.IComponent component) { throw null; }
|
||||
protected internal int AssignRootComponentId(Microsoft.AspNetCore.Components.IComponent component) { throw null; }
|
||||
public static Microsoft.AspNetCore.Components.Rendering.IDispatcher CreateDefaultDispatcher() { throw null; }
|
||||
public System.Threading.Tasks.Task DispatchEventAsync(int eventHandlerId, Microsoft.AspNetCore.Components.UIEventArgs eventArgs) { throw null; }
|
||||
public void Dispose() { }
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
|||
private readonly Dictionary<int, ComponentState> _componentStateById = new Dictionary<int, ComponentState>();
|
||||
private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder();
|
||||
private readonly Dictionary<int, EventCallback> _eventBindings = new Dictionary<int, EventCallback>();
|
||||
private IDispatcher _dispatcher;
|
||||
private readonly IDispatcher _dispatcher;
|
||||
|
||||
private int _nextComponentId = 0; // TODO: change to 'long' when Mono .NET->JS interop supports it
|
||||
private bool _isBatchInProgress;
|
||||
|
|
@ -89,7 +89,8 @@ namespace Microsoft.AspNetCore.Components.Rendering
|
|||
/// </summary>
|
||||
/// <param name="component">The component.</param>
|
||||
/// <returns>The component's assigned identifier.</returns>
|
||||
protected int AssignRootComponentId(IComponent component)
|
||||
// Internal for unit testing
|
||||
protected internal int AssignRootComponentId(IComponent component)
|
||||
=> AttachAndInitComponent(component, -1).ComponentId;
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -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", ">", "<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_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", "=", "\"", "<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,579 +2,14 @@
|
|||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Components.RenderTree;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Rendering
|
||||
{
|
||||
public class HtmlRendererTests
|
||||
public class HtmlRendererTests : HtmlRendererTestBase
|
||||
{
|
||||
private static readonly Func<string, string> _encoder = (string t) => HtmlEncoder.Default.Encode(t);
|
||||
|
||||
[Fact]
|
||||
public void RenderComponentAsync_CanRenderEmptyElement()
|
||||
protected override HtmlRenderer GetHtmlRenderer(IServiceProvider serviceProvider)
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] { "<", "p", ">", "</", "p", ">" };
|
||||
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
|
||||
{
|
||||
rtb.OpenElement(0, "p");
|
||||
rtb.CloseElement();
|
||||
})).BuildServiceProvider();
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderComponentAsync_CanRenderSimpleComponent()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] { "<", "p", ">", "Hello world!", "</", "p", ">" };
|
||||
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
|
||||
{
|
||||
rtb.OpenElement(0, "p");
|
||||
rtb.AddContent(1, "Hello world!");
|
||||
rtb.CloseElement();
|
||||
})).BuildServiceProvider();
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderComponentAsync_HtmlEncodesContent()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] { "<", "p", ">", "<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_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", "=", "\"", "<lead", "\"", ">", "Hello world!", "</", "p", ">" };
|
||||
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
|
||||
{
|
||||
rtb.OpenElement(0, "p");
|
||||
rtb.AddAttribute(1, "class", "<lead");
|
||||
rtb.AddContent(2, "Hello world!");
|
||||
rtb.CloseElement();
|
||||
})).BuildServiceProvider();
|
||||
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderComponentAsync_CanRenderBooleanAttributes()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] { "<", "input", " ", "disabled", " />" };
|
||||
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
|
||||
{
|
||||
rtb.OpenElement(0, "input");
|
||||
rtb.AddAttribute(1, "disabled", true);
|
||||
rtb.CloseElement();
|
||||
})).BuildServiceProvider();
|
||||
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderComponentAsync_DoesNotRenderBooleanAttributesWhenValueIsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] { "<", "input", " />" };
|
||||
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
|
||||
{
|
||||
rtb.OpenElement(0, "input");
|
||||
rtb.AddAttribute(1, "disabled", false);
|
||||
rtb.CloseElement();
|
||||
})).BuildServiceProvider();
|
||||
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderComponentAsync_CanRenderWithChildren()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] { "<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">" };
|
||||
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
|
||||
{
|
||||
rtb.OpenElement(0, "p");
|
||||
rtb.OpenElement(1, "span");
|
||||
rtb.AddContent(2, "Hello world!");
|
||||
rtb.CloseElement();
|
||||
rtb.CloseElement();
|
||||
})).BuildServiceProvider();
|
||||
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderComponentAsync_CanRenderWithMultipleChildren()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] { "<", "p", ">",
|
||||
"<", "span", ">", "Hello world!", "</", "span", ">",
|
||||
"<", "span", ">", "Bye Bye world!", "</", "span", ">",
|
||||
"</", "p", ">"
|
||||
};
|
||||
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
|
||||
{
|
||||
rtb.OpenElement(0, "p");
|
||||
rtb.OpenElement(1, "span");
|
||||
rtb.AddContent(2, "Hello world!");
|
||||
rtb.CloseElement();
|
||||
rtb.OpenElement(3, "span");
|
||||
rtb.AddContent(4, "Bye Bye world!");
|
||||
rtb.CloseElement();
|
||||
rtb.CloseElement();
|
||||
})).BuildServiceProvider();
|
||||
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderComponentAsync_CanRenderComponentAsyncWithChildrenComponents()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] {
|
||||
"<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">",
|
||||
"<", "span", ">", "Child content!", "</", "span", ">"
|
||||
};
|
||||
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
|
||||
{
|
||||
rtb.OpenElement(0, "p");
|
||||
rtb.OpenElement(1, "span");
|
||||
rtb.AddContent(2, "Hello world!");
|
||||
rtb.CloseElement();
|
||||
rtb.CloseElement();
|
||||
rtb.OpenComponent(3, typeof(ChildComponent));
|
||||
rtb.AddAttribute(4, "Value", "Child content!");
|
||||
rtb.CloseComponent();
|
||||
})).BuildServiceProvider();
|
||||
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderComponentAsync_ComponentReferenceNoops()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] {
|
||||
"<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">",
|
||||
"<", "span", ">", "Child content!", "</", "span", ">"
|
||||
};
|
||||
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
|
||||
{
|
||||
rtb.OpenElement(0, "p");
|
||||
rtb.OpenElement(1, "span");
|
||||
rtb.AddContent(2, "Hello world!");
|
||||
rtb.CloseElement();
|
||||
rtb.CloseElement();
|
||||
rtb.OpenComponent(3, typeof(ChildComponent));
|
||||
rtb.AddAttribute(4, "Value", "Child content!");
|
||||
rtb.AddComponentReferenceCapture(5, cr => { });
|
||||
rtb.CloseComponent();
|
||||
})).BuildServiceProvider();
|
||||
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderComponentAsync_CanPassParameters()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] {
|
||||
"<", "p", ">", "<", "input", " ", "value", "=", "\"", "5", "\"", " />", "</", "p", ">" };
|
||||
|
||||
RenderFragment Content(ParameterCollection pc) => new RenderFragment((RenderTreeBuilder rtb) =>
|
||||
{
|
||||
rtb.OpenElement(0, "p");
|
||||
rtb.OpenElement(1, "input");
|
||||
rtb.AddAttribute(2, "change", pc.GetValueOrDefault<Action<UIChangeEventArgs>>("update"));
|
||||
rtb.AddAttribute(3, "value", pc.GetValueOrDefault<int>("value"));
|
||||
rtb.CloseElement();
|
||||
rtb.CloseElement();
|
||||
});
|
||||
|
||||
var serviceProvider = new ServiceCollection()
|
||||
.AddSingleton(new Func<ParameterCollection, RenderFragment>(Content))
|
||||
.BuildServiceProvider();
|
||||
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
Action<UIChangeEventArgs> change = (UIChangeEventArgs changeArgs) => throw new InvalidOperationException();
|
||||
|
||||
// Act
|
||||
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<ComponentWithParameters>(
|
||||
new ParameterCollection(new[] {
|
||||
RenderTreeFrame.Element(0,string.Empty),
|
||||
RenderTreeFrame.Attribute(1,"update",change),
|
||||
RenderTreeFrame.Attribute(2,"value",5)
|
||||
}, 0))));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderComponentAsync_CanRenderComponentAsyncWithRenderFragmentContent()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] {
|
||||
"<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">" };
|
||||
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
|
||||
{
|
||||
rtb.OpenElement(0, "p");
|
||||
rtb.OpenElement(1, "span");
|
||||
rtb.AddContent(2,
|
||||
// This internally creates a region frame.
|
||||
rf => rf.AddContent(0, "Hello world!"));
|
||||
rtb.CloseElement();
|
||||
rtb.CloseElement();
|
||||
})).BuildServiceProvider();
|
||||
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderComponentAsync_ElementRefsNoops()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] {
|
||||
"<", "p", ">", "<", "span", ">", "Hello world!", "</", "span", ">", "</", "p", ">" };
|
||||
var serviceProvider = new ServiceCollection().AddSingleton(new RenderFragment(rtb =>
|
||||
{
|
||||
rtb.OpenElement(0, "p");
|
||||
rtb.AddElementReferenceCapture(1, er => { });
|
||||
rtb.OpenElement(2, "span");
|
||||
rtb.AddContent(3,
|
||||
// This internally creates a region frame.
|
||||
rf => rf.AddContent(0, "Hello world!"));
|
||||
rtb.CloseElement();
|
||||
rtb.CloseElement();
|
||||
})).BuildServiceProvider();
|
||||
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<TestComponent>(ParameterCollection.Empty)));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
private IEnumerable<string> GetResult(Task<IEnumerable<string>> task)
|
||||
{
|
||||
Assert.True(task.IsCompleted);
|
||||
if (task.IsCompletedSuccessfully)
|
||||
{
|
||||
return task.Result;
|
||||
}
|
||||
else
|
||||
{
|
||||
ExceptionDispatchInfo.Capture(task.Exception).Throw();
|
||||
throw new InvalidOperationException("We will never hit this line");
|
||||
}
|
||||
}
|
||||
|
||||
private class ComponentWithParameters : IComponent
|
||||
{
|
||||
public RenderHandle RenderHandle { get; private set; }
|
||||
|
||||
public void Configure(RenderHandle renderHandle)
|
||||
{
|
||||
RenderHandle = renderHandle;
|
||||
}
|
||||
|
||||
[Inject]
|
||||
Func<ParameterCollection, RenderFragment> CreateRenderFragment { get; set; }
|
||||
|
||||
public Task SetParametersAsync(ParameterCollection parameters)
|
||||
{
|
||||
RenderHandle.Render(CreateRenderFragment(parameters));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanRender_AsyncComponent()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] {
|
||||
"<", "p", ">", "20", "</", "p", ">" };
|
||||
var serviceProvider = new ServiceCollection().AddSingleton<AsyncComponent>().BuildServiceProvider();
|
||||
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = await dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<AsyncComponent>(ParameterCollection.FromDictionary(new Dictionary<string, object>
|
||||
{
|
||||
["Value"] = 10
|
||||
})));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CanRender_NestedAsyncComponents()
|
||||
{
|
||||
// Arrange
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
var expectedHtml = new[] {
|
||||
"<", "p", ">", "20", "</", "p", ">",
|
||||
"<", "p", ">", "80", "</", "p", ">"
|
||||
};
|
||||
|
||||
var serviceProvider = new ServiceCollection().AddSingleton<AsyncComponent>().BuildServiceProvider();
|
||||
|
||||
var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher);
|
||||
|
||||
// Act
|
||||
var result = await dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync<NestedAsyncComponent>(ParameterCollection.FromDictionary(new Dictionary<string, object>
|
||||
{
|
||||
["Nested"] = false,
|
||||
["Value"] = 10
|
||||
})));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(expectedHtml, result);
|
||||
}
|
||||
|
||||
|
||||
private class NestedAsyncComponent : ComponentBase
|
||||
{
|
||||
[Parameter] public bool Nested { get; set; }
|
||||
[Parameter] public int Value { get; set; }
|
||||
|
||||
protected override async Task OnInitAsync()
|
||||
{
|
||||
Value = Value * 2;
|
||||
await Task.Yield();
|
||||
}
|
||||
|
||||
protected override void BuildRenderTree(RenderTreeBuilder builder)
|
||||
{
|
||||
base.BuildRenderTree(builder);
|
||||
builder.OpenElement(0, "p");
|
||||
builder.AddContent(1, Value.ToString());
|
||||
builder.CloseElement();
|
||||
if (!Nested)
|
||||
{
|
||||
builder.OpenComponent<NestedAsyncComponent>(2);
|
||||
builder.AddAttribute(3, "Nested", true);
|
||||
builder.AddAttribute(4, "Value", Value * 2);
|
||||
builder.CloseComponent();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AsyncComponent : ComponentBase
|
||||
{
|
||||
public AsyncComponent()
|
||||
{
|
||||
}
|
||||
|
||||
[Parameter]
|
||||
public int Value { get; set; }
|
||||
|
||||
protected override async Task OnInitAsync()
|
||||
{
|
||||
Value = Value * 2;
|
||||
await Task.Delay(Value * 100);
|
||||
}
|
||||
|
||||
protected override void BuildRenderTree(RenderTreeBuilder builder)
|
||||
{
|
||||
base.BuildRenderTree(builder);
|
||||
builder.OpenElement(0, "p");
|
||||
builder.AddContent(1, Value.ToString());
|
||||
builder.CloseElement();
|
||||
}
|
||||
}
|
||||
|
||||
private class ChildComponent : IComponent
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
|
||||
public void Configure(RenderHandle renderHandle)
|
||||
{
|
||||
_renderHandle = renderHandle;
|
||||
}
|
||||
|
||||
public Task SetParametersAsync(ParameterCollection parameters)
|
||||
{
|
||||
_renderHandle.Render(CreateRenderFragment(parameters));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private RenderFragment CreateRenderFragment(ParameterCollection parameters)
|
||||
{
|
||||
return RenderFragment;
|
||||
|
||||
void RenderFragment(RenderTreeBuilder rtb)
|
||||
{
|
||||
rtb.OpenElement(1, "span");
|
||||
rtb.AddContent(2, parameters.GetValueOrDefault<string>("Value"));
|
||||
rtb.CloseElement();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class TestComponent : IComponent
|
||||
{
|
||||
private RenderHandle _renderHandle;
|
||||
|
||||
[Inject]
|
||||
public RenderFragment Fragment { get; set; }
|
||||
|
||||
public void Configure(RenderHandle renderHandle)
|
||||
{
|
||||
_renderHandle = renderHandle;
|
||||
}
|
||||
|
||||
public Task SetParametersAsync(ParameterCollection parameters)
|
||||
{
|
||||
_renderHandle.Render(Fragment);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
return new HtmlRenderer(serviceProvider, _encoder, Dispatcher);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Server.Circuits
|
||||
{
|
||||
|
|
@ -10,7 +9,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
{
|
||||
public abstract CircuitHost CreateCircuitHost(
|
||||
HttpContext httpContext,
|
||||
IClientProxy client,
|
||||
CircuitClientProxy client,
|
||||
string uriAbsolute,
|
||||
string baseUriAbsolute);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
/// <returns><see cref="Task"/> that represents the asynchronous execution operation.</returns>
|
||||
public virtual Task OnConnectionDownAsync(Circuit circuit, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when a new circuit is being discarded.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ using System.Threading.Tasks;
|
|||
using Microsoft.AspNetCore.Components.Browser;
|
||||
using Microsoft.AspNetCore.Components.Browser.Rendering;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Microsoft.AspNetCore.Components.Server.Circuits
|
||||
|
|
@ -17,9 +17,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
internal class CircuitHost : IAsyncDisposable
|
||||
{
|
||||
private static readonly AsyncLocal<CircuitHost> _current = new AsyncLocal<CircuitHost>();
|
||||
private readonly SemaphoreSlim HandlerLock = new SemaphoreSlim(1);
|
||||
private readonly IServiceScope _scope;
|
||||
private readonly IDispatcher _dispatcher;
|
||||
private readonly CircuitHandler[] _circuitHandlers;
|
||||
private readonly ILogger _logger;
|
||||
private bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -49,22 +50,24 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
|
||||
public CircuitHost(
|
||||
IServiceScope scope,
|
||||
IClientProxy client,
|
||||
CircuitClientProxy client,
|
||||
RendererRegistry rendererRegistry,
|
||||
RemoteRenderer renderer,
|
||||
IList<ComponentDescriptor> descriptors,
|
||||
IDispatcher dispatcher,
|
||||
IJSRuntime jsRuntime,
|
||||
CircuitHandler[] circuitHandlers)
|
||||
RemoteJSRuntime jsRuntime,
|
||||
CircuitHandler[] circuitHandlers,
|
||||
ILogger logger)
|
||||
{
|
||||
_scope = scope ?? throw new ArgumentNullException(nameof(scope));
|
||||
_dispatcher = dispatcher;
|
||||
Dispatcher = dispatcher;
|
||||
Client = client;
|
||||
RendererRegistry = rendererRegistry ?? throw new ArgumentNullException(nameof(rendererRegistry));
|
||||
Descriptors = descriptors ?? throw new ArgumentNullException(nameof(descriptors));
|
||||
Renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
|
||||
JSRuntime = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime));
|
||||
|
||||
_logger = logger;
|
||||
|
||||
Services = scope.ServiceProvider;
|
||||
|
||||
Circuit = new Circuit(this);
|
||||
|
|
@ -78,9 +81,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
|
||||
public Circuit Circuit { get; }
|
||||
|
||||
public IClientProxy Client { get; set; }
|
||||
public CircuitClientProxy Client { get; set; }
|
||||
|
||||
public IJSRuntime JSRuntime { get; }
|
||||
public RemoteJSRuntime JSRuntime { get; }
|
||||
|
||||
public RemoteRenderer Renderer { get; }
|
||||
|
||||
|
|
@ -90,11 +93,12 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
|
||||
public IServiceProvider Services { get; }
|
||||
|
||||
public IDispatcher Dispatcher { get; }
|
||||
|
||||
public Task<IEnumerable<string>> PrerenderComponentAsync(Type componentType, ParameterCollection parameters)
|
||||
{
|
||||
return _dispatcher.InvokeAsync(async () =>
|
||||
return Dispatcher.InvokeAsync(async () =>
|
||||
{
|
||||
Renderer.StartPrerender();
|
||||
var result = await Renderer.RenderComponentAsync(componentType, parameters);
|
||||
return result;
|
||||
});
|
||||
|
|
@ -112,15 +116,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
await Renderer.AddComponentAsync(componentType, domElementSelector);
|
||||
}
|
||||
|
||||
for (var i = 0; i < _circuitHandlers.Length; i++)
|
||||
{
|
||||
await _circuitHandlers[i].OnCircuitOpenedAsync(Circuit, cancellationToken);
|
||||
}
|
||||
await OnCircuitOpenedAsync(cancellationToken);
|
||||
|
||||
for (var i = 0; i < _circuitHandlers.Length; i++)
|
||||
{
|
||||
await _circuitHandlers[i].OnConnectionUpAsync(Circuit, cancellationToken);
|
||||
}
|
||||
await OnConnectionUpAsync(cancellationToken);
|
||||
});
|
||||
|
||||
_initialized = true;
|
||||
|
|
@ -144,20 +142,102 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
private async Task OnCircuitOpenedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await Renderer.InvokeAsync(async () =>
|
||||
for (var i = 0; i < _circuitHandlers.Length; i++)
|
||||
{
|
||||
for (var i = 0; i < _circuitHandlers.Length; i++)
|
||||
var circuitHandler = _circuitHandlers[i];
|
||||
try
|
||||
{
|
||||
await _circuitHandlers[i].OnConnectionDownAsync(Circuit, default);
|
||||
await circuitHandler.OnCircuitOpenedAsync(Circuit, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnHandlerError(circuitHandler, nameof(CircuitHandler.OnCircuitOpenedAsync), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnConnectionUpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await HandlerLock.WaitAsync(cancellationToken);
|
||||
|
||||
for (var i = 0; i < _circuitHandlers.Length; i++)
|
||||
{
|
||||
await _circuitHandlers[i].OnCircuitClosedAsync(Circuit, default);
|
||||
var circuitHandler = _circuitHandlers[i];
|
||||
try
|
||||
{
|
||||
await circuitHandler.OnConnectionUpAsync(Circuit, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnHandlerError(circuitHandler, nameof(CircuitHandler.OnConnectionUpAsync), ex);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
HandlerLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task OnConnectionDownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await HandlerLock.WaitAsync(cancellationToken);
|
||||
|
||||
for (var i = 0; i < _circuitHandlers.Length; i++)
|
||||
{
|
||||
var circuitHandler = _circuitHandlers[i];
|
||||
try
|
||||
{
|
||||
await circuitHandler.OnConnectionDownAsync(Circuit, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnHandlerError(circuitHandler, nameof(CircuitHandler.OnConnectionDownAsync), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
HandlerLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual void OnHandlerError(CircuitHandler circuitHandler, string handlerMethod, Exception ex)
|
||||
{
|
||||
Log.UnhandledExceptionInvokingCircuitHandler(_logger, circuitHandler, handlerMethod, ex);
|
||||
}
|
||||
|
||||
private async Task OnCircuitDownAsync()
|
||||
{
|
||||
for (var i = 0; i < _circuitHandlers.Length; i++)
|
||||
{
|
||||
var circuitHandler = _circuitHandlers[i];
|
||||
try
|
||||
{
|
||||
await circuitHandler.OnCircuitClosedAsync(Circuit, default);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnHandlerError(circuitHandler, nameof(CircuitHandler.OnCircuitClosedAsync), ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Log.DisposingCircuit(_logger, CircuitId);
|
||||
|
||||
await Renderer.InvokeAsync((Func<Task>)(async () =>
|
||||
{
|
||||
await OnConnectionDownAsync(CancellationToken.None);
|
||||
await OnCircuitDownAsync();
|
||||
}));
|
||||
|
||||
_scope.Dispose();
|
||||
Renderer.Dispose();
|
||||
|
|
@ -167,7 +247,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
{
|
||||
if (!_initialized)
|
||||
{
|
||||
throw new InvalidOperationException("Something is calling into the circuit before Initialize() completes");
|
||||
throw new InvalidOperationException("Circuit is being invoked prior to initialization.");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,5 +260,42 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
{
|
||||
UnhandledException?.Invoke(this, e);
|
||||
}
|
||||
|
||||
private static class Log
|
||||
{
|
||||
private static readonly Action<ILogger, Type, string, string, Exception> _unhandledExceptionInvokingCircuitHandler;
|
||||
private static readonly Action<ILogger, string, Exception> _disposingCircuit;
|
||||
|
||||
private static class EventIds
|
||||
{
|
||||
public static readonly EventId ExceptionInvokingCircuitHandlerMethod = new EventId(100, "ExceptionInvokingCircuitHandlerMethod");
|
||||
public static readonly EventId DisposingCircuit = new EventId(101, "DisposingCircuitHost");
|
||||
}
|
||||
|
||||
static Log()
|
||||
{
|
||||
_unhandledExceptionInvokingCircuitHandler = LoggerMessage.Define<Type, string, string>(
|
||||
LogLevel.Error,
|
||||
EventIds.ExceptionInvokingCircuitHandlerMethod,
|
||||
"Unhandled error invoking circuit handler type {handlerType}.{handlerMethod}: {Message}");
|
||||
|
||||
_disposingCircuit = LoggerMessage.Define<string>(
|
||||
LogLevel.Trace,
|
||||
EventIds.DisposingCircuit,
|
||||
"Disposing circuit with identifier {CircuitId}");
|
||||
}
|
||||
|
||||
public static void UnhandledExceptionInvokingCircuitHandler(ILogger logger, CircuitHandler handler, string handlerMethod, Exception exception)
|
||||
{
|
||||
_unhandledExceptionInvokingCircuitHandler(
|
||||
logger,
|
||||
handler.GetType(),
|
||||
handlerMethod,
|
||||
exception.Message,
|
||||
exception);
|
||||
}
|
||||
|
||||
public static void DisposingCircuit(ILogger logger, string circuitId) => _disposingCircuit(logger, circuitId, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
var context = prerenderingContext.Context;
|
||||
var circuitHost = _circuitFactory.CreateCircuitHost(
|
||||
context,
|
||||
client: null,
|
||||
client: CircuitClientProxy.OfflineClient,
|
||||
GetFullUri(context.Request),
|
||||
GetFullBaseUri(context.Request));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,6 @@ using Microsoft.AspNetCore.Components.Rendering;
|
|||
using Microsoft.AspNetCore.Components.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.JSInterop;
|
||||
|
|
@ -33,7 +32,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
|
||||
public override CircuitHost CreateCircuitHost(
|
||||
HttpContext httpContext,
|
||||
IClientProxy client,
|
||||
CircuitClientProxy client,
|
||||
string uriAbsolute,
|
||||
string baseUriAbsolute)
|
||||
{
|
||||
|
|
@ -42,13 +41,10 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
var scope = _scopeFactory.CreateScope();
|
||||
var encoder = scope.ServiceProvider.GetRequiredService<HtmlEncoder>();
|
||||
var jsRuntime = (RemoteJSRuntime)scope.ServiceProvider.GetRequiredService<IJSRuntime>();
|
||||
if (client != null)
|
||||
{
|
||||
jsRuntime.Initialize(client);
|
||||
}
|
||||
jsRuntime.Initialize(client);
|
||||
|
||||
var uriHelper = (RemoteUriHelper)scope.ServiceProvider.GetRequiredService<IUriHelper>();
|
||||
if (client != null)
|
||||
if (client != CircuitClientProxy.OfflineClient)
|
||||
{
|
||||
uriHelper.Initialize(uriAbsolute, baseUriAbsolute, jsRuntime);
|
||||
}
|
||||
|
|
@ -80,7 +76,8 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
components,
|
||||
dispatcher,
|
||||
jsRuntime,
|
||||
circuitHandlers);
|
||||
circuitHandlers,
|
||||
_loggerFactory.CreateLogger<CircuitHost>());
|
||||
|
||||
// Initialize per - circuit data that services need
|
||||
(circuitHost.Services.GetRequiredService<ICircuitAccessor>() as DefaultCircuitAccessor).Circuit = circuitHost.Circuit;
|
||||
|
|
@ -88,9 +85,9 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
return circuitHost;
|
||||
}
|
||||
|
||||
private static IList<ComponentDescriptor> ResolveComponentMetadata(HttpContext httpContext, IClientProxy client)
|
||||
private static IList<ComponentDescriptor> ResolveComponentMetadata(HttpContext httpContext, CircuitClientProxy client)
|
||||
{
|
||||
if (client == null)
|
||||
if (client == CircuitClientProxy.OfflineClient)
|
||||
{
|
||||
// This is the prerendering case.
|
||||
return Array.Empty<ComponentDescriptor>();
|
||||
|
|
|
|||
|
|
@ -11,21 +11,20 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
{
|
||||
private IClientProxy _clientProxy;
|
||||
|
||||
public RemoteJSRuntime()
|
||||
{
|
||||
}
|
||||
|
||||
internal void Initialize(IClientProxy clientProxy)
|
||||
internal void Initialize(CircuitClientProxy clientProxy)
|
||||
{
|
||||
_clientProxy = clientProxy ?? throw new ArgumentNullException(nameof(clientProxy));
|
||||
}
|
||||
|
||||
protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson)
|
||||
{
|
||||
if (_clientProxy == null)
|
||||
if (_clientProxy == CircuitClientProxy.OfflineClient)
|
||||
{
|
||||
throw new InvalidOperationException("The JavaScript runtime is not available during prerendering.");
|
||||
var errorMessage = "JavaScript interop calls cannot be issued while the client is not connected, because the server is not able to interop with the browser at this time. " +
|
||||
"Components must wrap any JavaScript interop calls in conditional logic to ensure those interop calls are not attempted during periods where the client is not connected.";
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
_clientProxy.SendAsync("JS.BeginInvokeJS", asyncHandle, identifier, argsJson);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Microsoft.AspNetCore.Components.Rendering;
|
||||
using Microsoft.AspNetCore.Components.Server;
|
||||
using Microsoft.AspNetCore.Components.Server.Circuits;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
|
@ -23,14 +23,13 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
|
|||
private const int TimeoutMilliseconds = 60 * 1000;
|
||||
|
||||
private readonly int _id;
|
||||
private IClientProxy _client;
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private readonly CircuitClientProxy _client;
|
||||
private readonly RendererRegistry _rendererRegistry;
|
||||
private readonly ConcurrentDictionary<long, AutoCancelTaskCompletionSource<object>> _pendingRenders
|
||||
= new ConcurrentDictionary<long, AutoCancelTaskCompletionSource<object>>();
|
||||
private readonly ILogger _logger;
|
||||
private long _nextRenderId = 1;
|
||||
private bool _prerenderMode;
|
||||
|
||||
/// <summary>
|
||||
/// Notifies when a rendering exception occured.
|
||||
|
|
@ -40,16 +39,11 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
|
|||
/// <summary>
|
||||
/// Creates a new <see cref="RemoteRenderer"/>.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The <see cref="IServiceProvider"/>.</param>
|
||||
/// <param name="rendererRegistry">The <see cref="RendererRegistry"/>.</param>
|
||||
/// <param name="jsRuntime">The <see cref="IJSRuntime"/>.</param>
|
||||
/// <param name="client">The <see cref="IClientProxy"/>.</param>
|
||||
/// <param name="syncContext">A <see cref="SynchronizationContext"/> that can be used to serialize renderer operations.</param>
|
||||
public RemoteRenderer(
|
||||
IServiceProvider serviceProvider,
|
||||
RendererRegistry rendererRegistry,
|
||||
IJSRuntime jsRuntime,
|
||||
IClientProxy client,
|
||||
CircuitClientProxy client,
|
||||
IDispatcher dispatcher,
|
||||
HtmlEncoder encoder,
|
||||
ILogger logger)
|
||||
|
|
@ -63,6 +57,8 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
|
|||
_logger = logger;
|
||||
}
|
||||
|
||||
internal ConcurrentQueue<byte[]> OfflineRenderBatches = new ConcurrentQueue<byte[]>();
|
||||
|
||||
/// <summary>
|
||||
/// Associates the <see cref="IComponent"/> with the <see cref="RemoteRenderer"/>,
|
||||
/// causing it to be displayed in the specified DOM element.
|
||||
|
|
@ -91,20 +87,15 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
|
|||
{
|
||||
foreach (var innerException in aggregateException.Flatten().InnerExceptions)
|
||||
{
|
||||
_logger.UnhandledExceptionRenderingComponent(innerException);
|
||||
Log.UnhandledExceptionRenderingComponent(_logger, innerException);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.UnhandledExceptionRenderingComponent(exception);
|
||||
Log.UnhandledExceptionRenderingComponent(_logger, exception);
|
||||
}
|
||||
}
|
||||
|
||||
internal void StartPrerender()
|
||||
{
|
||||
_prerenderMode = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
|
|
@ -115,14 +106,6 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
|
|||
/// <inheritdoc />
|
||||
protected override Task UpdateDisplayAsync(in RenderBatch batch)
|
||||
{
|
||||
if (_prerenderMode)
|
||||
{
|
||||
// Nothing to do in prerender mode for right now.
|
||||
// In the future we will capture all the serialized render batches and
|
||||
// resend them to the client upon the initial reconnect.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Note that we have to capture the data as a byte[] synchronously here, because
|
||||
// SignalR's SendAsync can wait an arbitrary duration before serializing the params.
|
||||
// The RenderBatch buffer will get reused by subsequent renders, so we need to
|
||||
|
|
@ -131,8 +114,31 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
|
|||
// buffer on every render.
|
||||
var batchBytes = MessagePackSerializer.Serialize(batch, RenderBatchFormatterResolver.Instance);
|
||||
|
||||
// Prepare to track the render process with a timeout
|
||||
if (!_client.Connected)
|
||||
{
|
||||
// Buffer the rendered batches while the client is disconnected. We'll send it down once the client reconnects.
|
||||
OfflineRenderBatches.Enqueue(batchBytes);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Log.BeginUpdateDisplayAsync(_logger, _client.ConnectionId);
|
||||
return WriteBatchBytes(batchBytes);
|
||||
}
|
||||
|
||||
public async Task ProcessBufferedRenderBatches()
|
||||
{
|
||||
// The server may discover that the client disconnected while we're attempting to write empty rendered batches.
|
||||
// Discontinue writing in this event.
|
||||
while (_client.Connected && OfflineRenderBatches.TryDequeue(out var renderBatch))
|
||||
{
|
||||
await WriteBatchBytes(renderBatch);
|
||||
}
|
||||
}
|
||||
|
||||
private Task WriteBatchBytes(byte[] batchBytes)
|
||||
{
|
||||
var renderId = Interlocked.Increment(ref _nextRenderId);
|
||||
|
||||
var pendingRenderInfo = new AutoCancelTaskCompletionSource<object>(TimeoutMilliseconds);
|
||||
_pendingRenders[renderId] = pendingRenderInfo;
|
||||
|
||||
|
|
@ -191,5 +197,61 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static class Log
|
||||
{
|
||||
private static readonly Action<ILogger, string, Exception> _unhandledExceptionRenderingComponent;
|
||||
private static readonly Action<ILogger, string, Exception> _beginUpdateDisplayAsync;
|
||||
private static readonly Action<ILogger, string, Exception> _bufferingRenderDisconnectedClient;
|
||||
|
||||
private static class EventIds
|
||||
{
|
||||
public static readonly EventId UnhandledExceptionRenderingComponent = new EventId(100, "ExceptionRenderingComponent");
|
||||
public static readonly EventId BeginUpdateDisplayAsync = new EventId(101, "BeginUpdateDisplayAsync");
|
||||
public static readonly EventId SkipUpdateDisplayAsync = new EventId(102, "SkipUpdateDisplayAsync");
|
||||
}
|
||||
|
||||
static Log()
|
||||
{
|
||||
_unhandledExceptionRenderingComponent = LoggerMessage.Define<string>(
|
||||
LogLevel.Warning,
|
||||
EventIds.UnhandledExceptionRenderingComponent,
|
||||
"Unhandled exception rendering component: {Message}");
|
||||
|
||||
_beginUpdateDisplayAsync = LoggerMessage.Define<string>(
|
||||
LogLevel.Trace,
|
||||
EventIds.BeginUpdateDisplayAsync,
|
||||
"Begin remote rendering of components on client {ConnectionId}.");
|
||||
|
||||
_bufferingRenderDisconnectedClient = LoggerMessage.Define<string>(
|
||||
LogLevel.Trace,
|
||||
EventIds.SkipUpdateDisplayAsync,
|
||||
"Buffering remote render because the client on connection {ConnectionId} is disconnected.");
|
||||
}
|
||||
|
||||
public static void UnhandledExceptionRenderingComponent(ILogger logger, Exception exception)
|
||||
{
|
||||
_unhandledExceptionRenderingComponent(
|
||||
logger,
|
||||
exception.Message,
|
||||
exception);
|
||||
}
|
||||
|
||||
public static void BeginUpdateDisplayAsync(ILogger logger, string connectionId)
|
||||
{
|
||||
_beginUpdateDisplayAsync(
|
||||
logger,
|
||||
connectionId,
|
||||
null);
|
||||
}
|
||||
|
||||
public static void BufferingRenderDisconnectedClient(ILogger logger, string connectionId)
|
||||
{
|
||||
_bufferingRenderDisconnectedClient(
|
||||
logger,
|
||||
connectionId,
|
||||
null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,10 +16,6 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
{
|
||||
private IJSRuntime _jsRuntime;
|
||||
|
||||
public RemoteUriHelper()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the <see cref="RemoteUriHelper"/>.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Components.Server.Circuits;
|
||||
using Microsoft.AspNetCore.Components.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
|
@ -19,6 +18,7 @@ namespace Microsoft.AspNetCore.Components.Server
|
|||
{
|
||||
private static readonly object CircuitKey = new object();
|
||||
private readonly CircuitFactory _circuitFactory;
|
||||
private readonly CircuitRegistry _circuitRegistry;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -28,6 +28,7 @@ namespace Microsoft.AspNetCore.Components.Server
|
|||
public ComponentHub(IServiceProvider services, ILogger<ComponentHub> logger)
|
||||
{
|
||||
_circuitFactory = services.GetRequiredService<CircuitFactory>();
|
||||
_circuitRegistry = services.GetRequiredService<CircuitRegistry>();
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
|
|
@ -48,19 +49,28 @@ namespace Microsoft.AspNetCore.Components.Server
|
|||
/// <summary>
|
||||
/// Intended for framework use only. Applications should not call this method directly.
|
||||
/// </summary>
|
||||
public override async Task OnDisconnectedAsync(Exception exception)
|
||||
public override Task OnDisconnectedAsync(Exception exception)
|
||||
{
|
||||
await CircuitHost.DisposeAsync();
|
||||
var circuitHost = CircuitHost;
|
||||
if (circuitHost == null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
CircuitHost = null;
|
||||
return _circuitRegistry.DisconnectAsync(circuitHost, Context.ConnectionId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Intended for framework use only. Applications should not call this method directly.
|
||||
/// </summary>
|
||||
public async Task StartCircuit(string uriAbsolute, string baseUriAbsolute)
|
||||
public async Task<string> StartCircuit(string uriAbsolute, string baseUriAbsolute)
|
||||
{
|
||||
var circuitClient = new CircuitClientProxy(Clients.Caller, Context.ConnectionId);
|
||||
|
||||
var circuitHost = _circuitFactory.CreateCircuitHost(
|
||||
Context.GetHttpContext(),
|
||||
Clients.Caller,
|
||||
circuitClient,
|
||||
uriAbsolute,
|
||||
baseUriAbsolute);
|
||||
|
||||
|
|
@ -69,7 +79,31 @@ namespace Microsoft.AspNetCore.Components.Server
|
|||
// If initialization fails, this will throw. The caller will fail if they try to call into any interop API.
|
||||
await circuitHost.InitializeAsync(Context.ConnectionAborted);
|
||||
|
||||
_circuitRegistry.Register(circuitHost);
|
||||
|
||||
CircuitHost = circuitHost;
|
||||
|
||||
return circuitHost.CircuitId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Intended for framework use only. Applications should not call this method directly.
|
||||
/// </summary>
|
||||
public async Task<bool> ConnectCircuit(string circuitId)
|
||||
{
|
||||
var circuitHost = await _circuitRegistry.ConnectAsync(circuitId, Clients.Caller, Context.ConnectionId, Context.ConnectionAborted);
|
||||
if (circuitHost != null)
|
||||
{
|
||||
CircuitHost = circuitHost;
|
||||
|
||||
// Dispatch any buffered renders we accumulated during a disconnect.
|
||||
// Note that while the rendering is async, we cannot await it here. The Task returned by ProcessBufferedRenderBatches relies on
|
||||
// OnRenderCompleted to be invoked to complete, and SignalR does not allow concurrent hub method invocations.
|
||||
_ = circuitHost.Renderer.ProcessBufferedRenderBatches();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ namespace Microsoft.Extensions.DependencyInjection
|
|||
services.TryAddScoped(s => s.GetRequiredService<ICircuitAccessor>().Circuit);
|
||||
services.TryAddScoped<ICircuitAccessor, DefaultCircuitAccessor>();
|
||||
|
||||
services.TryAddSingleton<CircuitRegistry>();
|
||||
|
||||
// We explicitly take over the prerendering and components services here.
|
||||
// We can't have two separate component implementations coexisting at the
|
||||
// same time, so when you register components (Circuits) it takes over
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp3.0</TargetFramework>
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
<Reference Include="Microsoft.AspNetCore.SignalR" />
|
||||
<Reference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" />
|
||||
<Reference Include="Microsoft.AspNetCore.StaticFiles" />
|
||||
<Reference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<Reference Include="Microsoft.Extensions.FileProviders.Composite" />
|
||||
<Reference Include="Microsoft.Extensions.FileProviders.Embedded" />
|
||||
|
||||
|
|
@ -34,7 +35,7 @@
|
|||
|
||||
<ItemGroup Condition="'$(BuildNodeJS)' != 'false'">
|
||||
<!-- We need .Browser.JS to build first so we can embed its .js output -->
|
||||
<ProjectReference Include="..\..\Browser.JS\src\Microsoft.AspNetCore.Components.Browser.JS.npmproj" ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\..\Browser.JS\Microsoft.AspNetCore.Components.Browser.JS.npmproj" ReferenceOutputAssembly="false" />
|
||||
<EmbeddedResource Include="..\..\Browser.JS\src\dist\components.server.js" LogicalName="_framework\%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
// Arrange
|
||||
var serviceScope = new Mock<IServiceScope>();
|
||||
var remoteRenderer = GetRemoteRenderer(Renderer.CreateDefaultDispatcher());
|
||||
var circuitHost = GetCircuitHost(
|
||||
var circuitHost = TestCircuitHost.Create(
|
||||
serviceScope.Object,
|
||||
remoteRenderer);
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
.Returns(Task.CompletedTask)
|
||||
.Verifiable();
|
||||
|
||||
var circuitHost = GetCircuitHost(handlers: new[] { handler1.Object, handler2.Object });
|
||||
var circuitHost = TestCircuitHost.Create(handlers: new[] { handler1.Object, handler2.Object });
|
||||
|
||||
// Act
|
||||
await circuitHost.InitializeAsync(cancellationToken);
|
||||
|
|
@ -114,7 +114,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
.Returns(Task.CompletedTask)
|
||||
.Verifiable();
|
||||
|
||||
var circuitHost = GetCircuitHost(handlers: new[] { handler1.Object, handler2.Object });
|
||||
var circuitHost = TestCircuitHost.Create(handlers: new[] { handler1.Object, handler2.Object });
|
||||
|
||||
// Act
|
||||
await circuitHost.DisposeAsync();
|
||||
|
|
@ -124,30 +124,6 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
handler2.VerifyAll();
|
||||
}
|
||||
|
||||
private static CircuitHost GetCircuitHost(
|
||||
IServiceScope serviceScope = null,
|
||||
RemoteRenderer remoteRenderer = null,
|
||||
CircuitHandler[] handlers = null)
|
||||
{
|
||||
serviceScope = serviceScope ?? Mock.Of<IServiceScope>();
|
||||
var clientProxy = Mock.Of<IClientProxy>();
|
||||
var renderRegistry = new RendererRegistry();
|
||||
var jsRuntime = Mock.Of<IJSRuntime>();
|
||||
var dispatcher = Renderer.CreateDefaultDispatcher();
|
||||
remoteRenderer = remoteRenderer ?? GetRemoteRenderer(dispatcher);
|
||||
handlers = handlers ?? Array.Empty<CircuitHandler>();
|
||||
|
||||
return new CircuitHost(
|
||||
serviceScope,
|
||||
clientProxy,
|
||||
renderRegistry,
|
||||
remoteRenderer,
|
||||
new List<ComponentDescriptor>(),
|
||||
dispatcher,
|
||||
jsRuntime: jsRuntime,
|
||||
handlers);
|
||||
}
|
||||
|
||||
private static TestRemoteRenderer GetRemoteRenderer(IDispatcher dispatcher)
|
||||
{
|
||||
return new TestRemoteRenderer(
|
||||
|
|
@ -161,7 +137,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits
|
|||
private class TestRemoteRenderer : RemoteRenderer
|
||||
{
|
||||
public TestRemoteRenderer(IServiceProvider serviceProvider, RendererRegistry rendererRegistry, IDispatcher dispatcher, IJSRuntime jsRuntime, IClientProxy client)
|
||||
: base(serviceProvider, rendererRegistry, jsRuntime, client, dispatcher, HtmlEncoder.Default, NullLogger.Instance)
|
||||
: base(serviceProvider, rendererRegistry, jsRuntime, new CircuitClientProxy(client, "connection"), dispatcher, HtmlEncoder.Default, NullLogger.Instance)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,4 +11,8 @@
|
|||
<Reference Include="Microsoft.Extensions.Logging.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\Components\test\Rendering\HtmlRendererTestBase.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -17,26 +17,11 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure
|
|||
class SeleniumStandaloneServer
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly object _instanceCreationLock = new object();
|
||||
private static SeleniumStandaloneServer _instance;
|
||||
private static Lazy<SeleniumStandaloneServer> _instance = new Lazy<SeleniumStandaloneServer>(() => new SeleniumStandaloneServer());
|
||||
|
||||
public Uri Uri { get; }
|
||||
|
||||
public static SeleniumStandaloneServer Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_instanceCreationLock)
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new SeleniumStandaloneServer();
|
||||
}
|
||||
}
|
||||
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
public static SeleniumStandaloneServer Instance => _instance.Value;
|
||||
|
||||
private SeleniumStandaloneServer()
|
||||
{
|
||||
|
|
@ -91,7 +76,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure
|
|||
Timeout = TimeSpan.FromSeconds(1),
|
||||
};
|
||||
|
||||
while (true)
|
||||
var retries = 0;
|
||||
while (retries++ < 30)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -109,6 +95,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure
|
|||
}
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
|
||||
throw new Exception("Failed to launch the server");
|
||||
});
|
||||
|
||||
try
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
// Copyright (c) .NET Foundation. All rights reserved.
|
||||
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
|
||||
|
||||
using System;
|
||||
using BasicTestApp;
|
||||
using Microsoft.AspNetCore.Components.Browser.Rendering;
|
||||
using ComponentsApp.App.Pages;
|
||||
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
|
||||
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
|
||||
using Microsoft.AspNetCore.Components.E2ETest.Tests;
|
||||
using OpenQA.Selenium;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,55 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests
|
|||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReconnectUI()
|
||||
{
|
||||
Browser.FindElement(By.LinkText("Counter")).Click();
|
||||
var javascript = (IJavaScriptExecutor)Browser;
|
||||
javascript.ExecuteScript(@"
|
||||
window.modalDisplayState = [];
|
||||
window.Blazor.circuitHandlers.push({
|
||||
onConnectionUp: () => window.modalDisplayState.push(document.getElementById('components-reconnect-modal').style.display),
|
||||
onConnectionDown: () => window.modalDisplayState.push(document.getElementById('components-reconnect-modal').style.display)
|
||||
});
|
||||
window.Blazor._internal.forceCloseConnection();");
|
||||
|
||||
new WebDriverWait(Browser, TimeSpan.FromSeconds(10)).Until(
|
||||
driver => (long)javascript.ExecuteScript("console.log(window.modalDisplayState); return window.modalDisplayState.length") == 2);
|
||||
|
||||
var states = (string)javascript.ExecuteScript("return window.modalDisplayState.join(',')");
|
||||
|
||||
Assert.Equal("block,none", states);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RendersContinueAfterReconnect()
|
||||
{
|
||||
Browser.FindElement(By.LinkText("Ticker")).Click();
|
||||
var selector = By.ClassName("tick-value");
|
||||
var element = Browser.FindElement(selector);
|
||||
|
||||
var initialValue = element.Text;
|
||||
|
||||
var javascript = (IJavaScriptExecutor)Browser;
|
||||
javascript.ExecuteScript(@"
|
||||
window.connectionUp = false;
|
||||
window.Blazor.circuitHandlers.push({
|
||||
onConnectionUp: () => window.connectionUp = true
|
||||
});
|
||||
window.Blazor._internal.forceCloseConnection();");
|
||||
|
||||
new WebDriverWait(Browser, TimeSpan.FromSeconds(10)).Until(
|
||||
driver => (bool)javascript.ExecuteScript("return window.connectionUp"));
|
||||
|
||||
var currentValue = element.Text;
|
||||
Assert.NotEqual(initialValue, currentValue);
|
||||
|
||||
// Verify it continues to tick
|
||||
new WebDriverWait(Browser, TimeSpan.FromSeconds(10)).Until(
|
||||
_ => element.Text != currentValue);
|
||||
}
|
||||
|
||||
private void WaitUntilLoaded()
|
||||
{
|
||||
new WebDriverWait(Browser, TimeSpan.FromSeconds(30)).Until(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
@ -22,6 +22,11 @@
|
|||
<span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="ticker">
|
||||
<span class="oi oi-list-rich" aria-hidden="true"></span> Ticker
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue