Skip to content

Custom HTML elements rendering Blazor components #42314

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jun 23, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.server.js

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/Components/Web.JS/src/GlobalExports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Platform, Pointer, System_String, System_Array, System_Object, System_B
import { getNextChunk, receiveDotNetDataStream } from './StreamingInterop';
import { RootComponentsFunctions } from './Rendering/JSRootComponents';
import { attachWebRendererInterop } from './Rendering/WebRendererInteropMethods';
import { customElementsFunctions } from './Rendering/CustomElements';

interface IBlazor {
navigateTo: (uri: string, options: NavigationOptions) => void;
Expand All @@ -30,6 +31,7 @@ interface IBlazor {
_internal: {
navigationManager: typeof navigationManagerInternalFunctions | any,
domWrapper: typeof domFunctions,
customElements: typeof customElementsFunctions,
Virtualize: typeof Virtualize,
PageTitle: typeof PageTitle,
forceCloseConnection?: () => Promise<void>;
Expand Down Expand Up @@ -76,6 +78,7 @@ export const Blazor: IBlazor = {
_internal: {
navigationManager: navigationManagerInternalFunctions,
domWrapper: domFunctions,
customElements: customElementsFunctions,
Virtualize,
PageTitle,
InputFile,
Expand Down
166 changes: 166 additions & 0 deletions src/Components/Web.JS/src/Rendering/CustomElements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { RootComponentsFunctions } from './JSRootComponents';

export const customElementsFunctions = {
add(elementName: string, parameters: JSComponentParameter[]): void {
customElements.define(elementName, class ConfiguredBlazorCustomElement extends BlazorCustomElement {
static get observedAttributes() {
return BlazorCustomElement.getObservedAttributes(parameters);
}

constructor() {
super(parameters);
}
});
},
};

class BlazorCustomElement extends HTMLElement {
private _attributeMappings: { [attributeName: string]: JSComponentParameter };

private _parameterValues: { [dotNetName: string]: any } = {};

private _addRootComponentPromise: Promise<any>;

private _hasPendingSetParameters = true; // The constructor will call setParameters, so it starts true

private _isDisposed = false;

private _disposalTimeoutHandle: any;

public renderIntoElement = this;

// Subclasses will need to call this if they want to retain the built-in behavior for knowing which
// attribute names to observe, since they have to return it from a static function
static getObservedAttributes(parameters: JSComponentParameter[]): string[] {
return parameters.map(p => dasherize(p.name));
}

constructor(parameters: JSComponentParameter[]) {
super();

// Keep track of how we'll map the attributes to parameters
this._attributeMappings = {};
parameters.forEach(parameter => {
const attributeName = dasherize(parameter.name);
this._attributeMappings[attributeName] = parameter;
});

// Defer until end of execution cycle so that (1) we know the heap is unlocked, and (2) the initial parameter
// values will be populated from the initial attributes before we send them to .NET
this._addRootComponentPromise = Promise.resolve().then(() => {
this._hasPendingSetParameters = false;
return RootComponentsFunctions.add(this.renderIntoElement, this.localName, this._parameterValues);
});

// Also allow assignment of parameters via properties. This is the only way to set complex-typed values.
for (const [attributeName, parameterInfo] of Object.entries(this._attributeMappings)) {
const dotNetName = parameterInfo.name;
Object.defineProperty(this, camelCase(dotNetName), {
get: () => this._parameterValues[dotNetName],
set: newValue => {
if (this.hasAttribute(attributeName)) {
// It's nice to keep the DOM in sync with the properties. This set a string representation
// of the value, but this will get overwritten with the original typed value before we send it to .NET
this.setAttribute(attributeName, newValue);
}

this._parameterValues[dotNetName] = newValue;
this._supplyUpdatedParameters();
},
});
}
}

connectedCallback() {
if (this._isDisposed) {
throw new Error(`Cannot connect component ${this.localName} to the document after it has been disposed.`);
}

clearTimeout(this._disposalTimeoutHandle);
}

disconnectedCallback() {
this._disposalTimeoutHandle = setTimeout(async () => {
this._isDisposed = true;
const rootComponent = await this._addRootComponentPromise;
rootComponent.dispose();
}, 1000);
}

attributeChangedCallback(name: string, oldValue: string, newValue: string) {
const parameterInfo = this._attributeMappings[name];
if (parameterInfo) {
this._parameterValues[parameterInfo.name] = BlazorCustomElement.parseAttributeValue(newValue, parameterInfo.type, parameterInfo.name);
this._supplyUpdatedParameters();
}
}

private async _supplyUpdatedParameters() {
if (!this._hasPendingSetParameters) {
this._hasPendingSetParameters = true;

// Continuation from here will always be async, so at the earliest it will be at
// the end of the current JS execution cycle
const rootComponent = await this._addRootComponentPromise;
if (!this._isDisposed) {
const setParametersPromise = rootComponent.setParameters(this._parameterValues);
this._hasPendingSetParameters = false; // We just snapshotted _parameterValues, so we need to start allowing new calls in case it changes further
await setParametersPromise;
}
}
}

static parseAttributeValue(attributeValue: string, type: JSComponentParameterType, parameterName: string) {
switch (type) {
case 'string':
return attributeValue;
case 'boolean':
switch (attributeValue) {
case 'true':
case 'True':
return true;
case 'false':
case 'False':
return false;
default:
throw new Error(`Invalid boolean value '${attributeValue}' for parameter '${parameterName}'`);
}
case 'number': {
const number = Number(attributeValue);
if (Number.isNaN(number)) {
throw new Error(`Invalid number value '${attributeValue}' for parameter '${parameterName}'`);
} else {
return number;
}
}
case 'boolean?':
return attributeValue ? BlazorCustomElement.parseAttributeValue(attributeValue, 'boolean', parameterName) : null;
case 'number?':
return attributeValue ? BlazorCustomElement.parseAttributeValue(attributeValue, 'number', parameterName) : null;
case 'object':
throw new Error(`The parameter '${parameterName}' accepts a complex-typed object so it cannot be set using an attribute. Try setting it as a element property instead.`);
default:
throw new Error(`Unknown type '${type}' for parameter '${parameterName}'`);
}
}
}

function dasherize(value: string): string {
return camelCase(value).replace(/([A-Z])/g, '-$1').toLowerCase();
}

function camelCase(value: string): string {
return value[0].toLowerCase() + value.substring(1);
}

interface JSComponentParameter {
name: string;
type: JSComponentParameterType;
}

// JSON-primitive types, plus for those whose .NET equivalent isn't nullable, a '?' to indicate nullability
// This allows custom element authors to coerce attribute strings into the appropriate type
type JSComponentParameterType = 'string' | 'boolean' | 'boolean?' | 'number' | 'number?' | 'object';
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,13 @@ public static void RegisterForJavaScript(this IJSComponentConfiguration configur
[DynamicDependency(DynamicallyAccessedMemberTypes.PublicMethods, typeof(JSComponentInterop))]
public static void RegisterForJavaScript(this IJSComponentConfiguration configuration, [DynamicallyAccessedMembers(Component)] Type componentType, string identifier, string javaScriptInitializer)
=> configuration.JSComponents.Add(componentType, identifier, javaScriptInitializer);

/// <summary>
/// Marks the specified component type as allowed for use as a custom element.
/// </summary>
/// <typeparam name="TComponent">The component type.</typeparam>
/// <param name="configuration">The <see cref="IJSComponentConfiguration"/>.</param>
/// <param name="customElementName">A unique name for the custom element. This must conform to custom element naming rules, so it must contain a dash character.</param>
public static void RegisterAsCustomElement<[DynamicallyAccessedMembers(Component)] TComponent>(this IJSComponentConfiguration configuration, string customElementName) where TComponent : IComponent
=> configuration.RegisterForJavaScript<TComponent>(customElementName, "Blazor._internal.customElements.add");
}
1 change: 1 addition & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#nullable enable
Microsoft.AspNetCore.Components.Forms.InputRadio<TValue>.Element.get -> Microsoft.AspNetCore.Components.ElementReference?
Microsoft.AspNetCore.Components.Forms.InputRadio<TValue>.Element.set -> void
static Microsoft.AspNetCore.Components.Web.JSComponentConfigurationExtensions.RegisterAsCustomElement<TComponent>(this Microsoft.AspNetCore.Components.Web.IJSComponentConfiguration! configuration, string! customElementName) -> void
128 changes: 128 additions & 0 deletions src/Components/test/E2ETest/Tests/CustomElementsTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BasicTestApp;
using Microsoft.AspNetCore.Components.E2ETest;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.Components.E2ETest.Tests;

public class CustomElementsTest : ServerTestBase<ToggleExecutionModeServerFixture<Program>>
{
protected IWebElement app;

public CustomElementsTest(
BrowserFixture browserFixture,
ToggleExecutionModeServerFixture<Program> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
}

protected override void InitializeAsyncCore()
{
Navigate(ServerPathBase, noReload: false);
app = Browser.MountTestComponent<CustomElementsComponent>();
}

[Fact]
public void CanAddAndRemoveCustomElements()
{
// Custom elements can be added.
app.FindElement(By.Id("add-custom-element")).Click();
Browser.Exists(By.Id("custom-element-0"));

app.FindElement(By.Id("add-custom-element")).Click();
Browser.Exists(By.Id("custom-element-1"));

// Custom elements are correctly removed.
app.FindElement(By.Id("remove-custom-element")).Click();
Browser.DoesNotExist(By.Id("custom-element-1"));

app.FindElement(By.Id("remove-custom-element")).Click();
Browser.DoesNotExist(By.Id("custom-element-0"));
}

[Fact]
public void CanUpdateSimpleParameters()
{
app.FindElement(By.Id("add-custom-element")).Click();
Browser.Exists(By.Id("custom-element-0"));

// Initial parameter values are correct.
ValidateSimpleParameterValues(id: 0, clickCount: 0);

app.FindElement(By.Id("increment-0")).Click();

// Parameter values have been updated.
ValidateSimpleParameterValues(id: 0, clickCount: 1);

void ValidateSimpleParameterValues(int id, int clickCount)
{
// Nullable parameters will be "null" every other click.
var doNullableParamsHaveValues = clickCount % 2 == 0;

var customElement = app.FindElement(By.Id($"custom-element-{id}"));

var expectedStringValue = $"Custom element {id} (Clicked {clickCount} times)";
Browser.Equal(expectedStringValue, () => customElement.FindElement(By.ClassName("string-param")).Text);

var expectedBoolValue = clickCount % 2 == 0 ? bool.TrueString : bool.FalseString;
Browser.Equal(expectedBoolValue, () => customElement.FindElement(By.ClassName("bool-param")).Text);

var expectedNullableBoolValue = doNullableParamsHaveValues ? expectedBoolValue : "null";
Browser.Equal(expectedNullableBoolValue, () => customElement.FindElement(By.ClassName("nullable-bool-param")).Text);

var expectedIntegerValue = clickCount.ToString(CultureInfo.InvariantCulture);
Browser.Equal(expectedIntegerValue, () => customElement.FindElement(By.ClassName("int-param")).Text);
Browser.Equal(expectedIntegerValue, () => customElement.FindElement(By.ClassName("long-param")).Text);

var expectedNullableIntegerValue = doNullableParamsHaveValues ? expectedIntegerValue : "null";
Browser.Equal(expectedNullableIntegerValue, () => customElement.FindElement(By.ClassName("nullable-int-param")).Text);
Browser.Equal(expectedNullableIntegerValue, () => customElement.FindElement(By.ClassName("nullable-long-param")).Text);

var expectedFloatValue = expectedIntegerValue + ".5";
Browser.Equal(expectedFloatValue, () => customElement.FindElement(By.ClassName("float-param")).Text);
Browser.Equal(expectedFloatValue, () => customElement.FindElement(By.ClassName("double-param")).Text);
Browser.Equal(expectedFloatValue, () => customElement.FindElement(By.ClassName("decimal-param")).Text);

var expectedNullableFloatValue = doNullableParamsHaveValues ? expectedFloatValue : "null";
Browser.Equal(expectedNullableFloatValue, () => customElement.FindElement(By.ClassName("nullable-float-param")).Text);
Browser.Equal(expectedNullableFloatValue, () => customElement.FindElement(By.ClassName("nullable-double-param")).Text);
Browser.Equal(expectedNullableFloatValue, () => customElement.FindElement(By.ClassName("nullable-decimal-param")).Text);
}
}

[Fact]
public void CanUpdateComplexParameters()
{
app.FindElement(By.Id("add-custom-element")).Click();
Browser.Exists(By.Id("custom-element-0"));

var incrementButton = app.FindElement(By.Id("increment-0"));
incrementButton.Click();
incrementButton.Click();

app.FindElement(By.Id("update-complex-parameters-0")).Click();

// The complex object parameter was updated.
var expectedComplexObjectValue = @"{ Property = ""Clicked 2 times"" }";
Browser.Equal(expectedComplexObjectValue, () => app.FindElement(By.Id("custom-element-0")).FindElement(By.ClassName("complex-type-param")).Text);

app.FindElement(By.Id("custom-element-0")).FindElement(By.ClassName("invoke-callback")).Click();

// The callback parameter was invoked.
var expectedMessage = "Callback with count = 2";
Browser.Equal(expectedMessage, () => app.FindElement(By.Id("message")).Text);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
@using Microsoft.JSInterop

<button class="invoke-callback" @onclick="InvokeCallbackAsync">Invoke callback</button><br />
<strong>@nameof(StringParam)</strong>: <span class="string-param">@StringParam</span><br />
<strong>@nameof(BoolParam)</strong>: <span class="bool-param">@BoolParam</span><br />
<strong>@nameof(IntParam)</strong>: <span class="int-param">@IntParam</span><br />
<strong>@nameof(LongParam)</strong>: <span class="long-param">@LongParam</span><br />
<strong>@nameof(FloatParam)</strong>: <span class="float-param">@FloatParam</span><br />
<strong>@nameof(DoubleParam)</strong>: <span class="double-param">@DoubleParam</span><br />
<strong>@nameof(DecimalParam)</strong>: <span class="decimal-param">@DecimalParam</span><br />
<strong>@nameof(NullableBoolParam)</strong>: <span class="nullable-bool-param">@(NullableBoolParam?.ToString() ?? "null")</span><br />
<strong>@nameof(NullableIntParam)</strong>: <span class="nullable-int-param">@(NullableIntParam?.ToString() ?? "null")</span><br />
<strong>@nameof(NullableLongParam)</strong>: <span class="nullable-long-param">@(NullableLongParam?.ToString() ?? "null")</span><br />
<strong>@nameof(NullableFloatParam)</strong>: <span class="nullable-float-param">@(NullableFloatParam?.ToString() ?? "null")</span><br />
<strong>@nameof(NullableDoubleParam)</strong>: <span class="nullable-double-param">@(NullableDoubleParam?.ToString() ?? "null")</span><br />
<strong>@nameof(NullableDecimalParam)</strong>: <span class="nullable-decimal-param">@(NullableDecimalParam?.ToString() ?? "null")</span><br />
<strong>@nameof(ComplexTypeParam)</strong>: <span class="complex-type-param">@ComplexTypeParam</span><br />

@code {
[Parameter] public string StringParam { get; set; }
[Parameter] public bool BoolParam { get; set; }
[Parameter] public int IntParam { get; set; }
[Parameter] public long LongParam { get; set; }
[Parameter] public float FloatParam { get; set; }
[Parameter] public double DoubleParam { get; set; }
[Parameter] public decimal DecimalParam { get; set; }
[Parameter] public bool? NullableBoolParam { get; set; }
[Parameter] public int? NullableIntParam { get; set; }
[Parameter] public long? NullableLongParam { get; set; }
[Parameter] public float? NullableFloatParam { get; set; }
[Parameter] public double? NullableDoubleParam { get; set; }
[Parameter] public decimal? NullableDecimalParam { get; set; }
[Parameter] public MyComplexType ComplexTypeParam { get; set; }
[Parameter] public EventCallback CallbackParam { get; set; }

private async Task InvokeCallbackAsync()
{
await CallbackParam.InvokeAsync();
}

public class MyComplexType
{
public string Property { get; set; }

public override string ToString()
=> $@"{{ {nameof(Property)} = ""{Property}"" }}";
}
}
Loading