Skip to content
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

Custom HTML elements rendering Blazor components #42314

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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions AspNetCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -1742,6 +1742,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Templates.Blazor.Tests", "s
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "stress", "stress", "{A5946454-4788-4871-8F23-A9471D55F115}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.CustomElements", "src\Components\CustomElements\src\Microsoft.AspNetCore.Components.CustomElements.csproj", "{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "CustomElements", "CustomElements", "{0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -10423,6 +10427,22 @@ Global
{281BF9DB-7B8A-446B-9611-10A60903F125}.Release|x64.Build.0 = Release|Any CPU
{281BF9DB-7B8A-446B-9611-10A60903F125}.Release|x86.ActiveCfg = Release|Any CPU
{281BF9DB-7B8A-446B-9611-10A60903F125}.Release|x86.Build.0 = Release|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Debug|arm64.ActiveCfg = Debug|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Debug|arm64.Build.0 = Debug|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Debug|x64.ActiveCfg = Debug|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Debug|x64.Build.0 = Debug|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Debug|x86.ActiveCfg = Debug|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Debug|x86.Build.0 = Debug|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Release|Any CPU.Build.0 = Release|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Release|arm64.ActiveCfg = Release|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Release|arm64.Build.0 = Release|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Release|x64.ActiveCfg = Release|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Release|x64.Build.0 = Release|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Release|x86.ActiveCfg = Release|Any CPU
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -11283,6 +11303,8 @@ Global
{AA7445F5-BD28-400C-8507-E2E0D3CF7D7E} = {08D53E58-4AAE-40C4-8497-63EC8664F304}
{281BF9DB-7B8A-446B-9611-10A60903F125} = {08D53E58-4AAE-40C4-8497-63EC8664F304}
{A5946454-4788-4871-8F23-A9471D55F115} = {4FDDC525-4E60-4CAF-83A3-261C5B43721F}
{76C3E22D-092B-4E8A-81F0-DCF071BFF4CD} = {0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F}
{0BB58FB6-8B66-4C6D-BA8A-DF3AFAF9AB8F} = {60D51C98-2CC0-40DF-B338-44154EFEE2FF}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}
Expand Down
1 change: 1 addition & 0 deletions eng/ProjectReferences.props
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
<ProjectReferenceProvider Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" ProjectPath="$(RepoRoot)src\SignalR\server\StackExchangeRedis\src\Microsoft.AspNetCore.SignalR.StackExchangeRedis.csproj" />
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.Authorization" ProjectPath="$(RepoRoot)src\Components\Authorization\src\Microsoft.AspNetCore.Components.Authorization.csproj" />
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Components" ProjectPath="$(RepoRoot)src\Components\Components\src\Microsoft.AspNetCore.Components.csproj" />
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.CustomElements" ProjectPath="$(RepoRoot)src\Components\CustomElements\src\Microsoft.AspNetCore.Components.CustomElements.csproj" />
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.Forms" ProjectPath="$(RepoRoot)src\Components\Forms\src\Microsoft.AspNetCore.Components.Forms.csproj" />
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.Server" ProjectPath="$(RepoRoot)src\Components\Server\src\Microsoft.AspNetCore.Components.Server.csproj" />
<ProjectReferenceProvider Include="Microsoft.Authentication.WebAssembly.Msal" ProjectPath="$(RepoRoot)src\Components\WebAssembly\Authentication.Msal\src\Microsoft.Authentication.WebAssembly.Msal.csproj" />
Expand Down
1 change: 1 addition & 0 deletions eng/TrimmableProjects.props
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
<TrimmableProject Include="Microsoft.AspNetCore.WebSockets" />
<TrimmableProject Include="Microsoft.AspNetCore.Components.Authorization" />
<TrimmableProject Include="Microsoft.AspNetCore.Components" />
<TrimmableProject Include="Microsoft.AspNetCore.Components.CustomElements" />
<TrimmableProject Include="Microsoft.AspNetCore.Components.Forms" />
<TrimmableProject Include="Microsoft.Authentication.WebAssembly.Msal" />
<TrimmableProject Include="Microsoft.JSInterop.WebAssembly" />
Expand Down
3 changes: 2 additions & 1 deletion src/Components/Components.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"src\\Components\\Components\\perf\\Microsoft.AspNetCore.Components.Performance.csproj",
"src\\Components\\Components\\src\\Microsoft.AspNetCore.Components.csproj",
"src\\Components\\Components\\test\\Microsoft.AspNetCore.Components.Tests.csproj",
"src\\Components\\CustomElements\\src\\Microsoft.AspNetCore.Components.CustomElements.csproj",
"src\\Components\\Forms\\src\\Microsoft.AspNetCore.Components.Forms.csproj",
"src\\Components\\Forms\\test\\Microsoft.AspNetCore.Components.Forms.Tests.csproj",
"src\\Components\\Samples\\BlazorServerApp\\BlazorServerApp.csproj",
Expand Down Expand Up @@ -140,4 +141,4 @@
"src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj"
]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics.CodeAnalysis;

namespace Microsoft.AspNetCore.Components.Web;

/// <summary>
/// Extension methods for registering custom elements from an <see cref="IJSComponentConfiguration"/>.
/// </summary>
public static class CustomElementsJSComponentConfigurationExtensions
{
/// <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="identifier">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 RegisterCustomElement<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>(this IJSComponentConfiguration configuration, string identifier) where TComponent : IComponent
=> configuration.RegisterForJavaScript<TComponent>(identifier, "registerBlazorCustomElement");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">

<Import Project="Sdk.props" Sdk="Yarn.MSBuild" Condition=" '$(DotNetBuildFromSource)' != 'true'" />

<PropertyGroup>
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
<Description>Provides a mechanism for using Blazor components as custom HTML elements.</Description>
<IsTrimmable>true</IsTrimmable>
</PropertyGroup>

<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Components.Web" />
</ItemGroup>

<PropertyGroup>
<YarnWorkingDir>$(MSBuildThisFileDirectory)js\</YarnWorkingDir>
<ResolveStaticWebAssetsInputsDependsOn>
CompileJs;
IncludeCompileJsOutput;
$(ResolveStaticWebAssetsInputsDependsOn)
</ResolveStaticWebAssetsInputsDependsOn>
</PropertyGroup>

<ItemGroup>
<YarnInputs Include="$(YarnWorkingDir)**" Exclude="$(YarnWorkingDir)node_modules\**;$(YarnWorkingDir)*.d.ts;$(YarnWorkingDir)dist\**" />
<YarnOutputs Include="$(YarnWorkingDir)dist\$(Configuration)\BlazorCustomElements.js" />

<Content Remove="$(YarnWorkingDir)**" />
<None Include="$(YarnWorkingDir)*" Exclude="$(YarnWorkingDir)node_modules\**" />

<UpToDateCheckInput Include="@(YarnInputs)" Set="StaticWebassets" />
<UpToDateCheckInput Include="@(YarnOutputs)" Set="StaticWebassets" />
</ItemGroup>

<Target Name="_CreateJsHash" BeforeTargets="CompileJs" Condition="'$(BuildNodeJS)' != 'false' AND '$(DesignTimeBuild)' != 'true'">

<PropertyGroup>
<JsCompilationCacheFile>$(IntermediateOutputPath)js.cache</JsCompilationCacheFile>
</PropertyGroup>

<Hash ItemsToHash="@(YarnInputs)">
<Output TaskParameter="HashResult" PropertyName="_YarnInputsHash" />
</Hash>

<WriteLinesToFile Lines="$(_YarnInputsHash)" File="$(JsCompilationCacheFile)" Overwrite="True" WriteOnlyWhenDifferent="True" />

<ItemGroup>
<FileWrites Include="$(JsCompilationCacheFile)" />
</ItemGroup>

</Target>

<Target Name="CompileJs" Condition="'$(BuildNodeJS)' != 'false' AND '$(DesignTimeBuild)' != 'true'" Inputs="$(JsCompilationCacheFile)" Outputs="@(YarnOutputs)">
<Yarn Command="install --mutex network --frozen-lockfile" WorkingDirectory="$(YarnWorkingDir)" IgnoreStandardErrorWarningFormat="$(IgnoreYarnWarnings)" />
<Yarn Command="run build:production" WorkingDirectory="$(YarnWorkingDir)" Condition="'$(Configuration)' == 'Release'" IgnoreStandardErrorWarningFormat="$(IgnoreYarnWarnings)" />
<Yarn Command="run build:debug" WorkingDirectory="$(YarnWorkingDir)" Condition="'$(Configuration)' == 'Debug'" IgnoreStandardErrorWarningFormat="$(IgnoreYarnWarnings)" />

<Message Importance="high" Text="@(_JsBuildOutput->'Emitted %(FullPath)')" />

</Target>

<Target Name="IncludeCompileJsOutput">
<ItemGroup>
<_JsBuildOutput Include="$(YarnWorkingDir)dist\$(Configuration)\**" Exclude="$(YarnWorkingDir)dist\.gitignore" />
</ItemGroup>

<DefineStaticWebAssets Condition="'@(_JsBuildOutput)' != ''"
SourceType="Computed"
SourceId="$(PackageId)"
ContentRoot="$(YarnWorkingDir)dist\$(Configuration)\"
BasePath="_content\$(PackageId)"
CandidateAssets="@(_JsBuildOutput)"
RelativePathFilter="**.js"
>
<Output TaskParameter="Assets" ItemName="StaticWebAsset" />
</DefineStaticWebAssets>

<ItemGroup>
<_JsBuildOutput Include="$(YarnWorkingDir)dist\$(Configuration)\**" Exclude="$(YarnWorkingDir)dist\.gitignore" />
<FileWrites Include="$(_JsBuildOutput)" />
</ItemGroup>
</Target>

<Import Project="Sdk.targets" Sdk="Yarn.MSBuild" Condition=" '$(DotNetBuildFromSource)' != 'true'" />

</Project>
1 change: 1 addition & 0 deletions src/Components/CustomElements/src/PublicAPI.Shipped.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#nullable enable
3 changes: 3 additions & 0 deletions src/Components/CustomElements/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Components.Web.CustomElementsJSComponentConfigurationExtensions
static Microsoft.AspNetCore.Components.Web.CustomElementsJSComponentConfigurationExtensions.RegisterCustomElement<TComponent>(this Microsoft.AspNetCore.Components.Web.IJSComponentConfiguration! configuration, string! identifier) -> void
156 changes: 156 additions & 0 deletions src/Components/CustomElements/src/js/BlazorCustomElements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
declare const Blazor: any;

// This function is called by the framework because RegisterAsCustomElement sets it as the initializer function
(window as any).registerBlazorCustomElement = function defaultRegisterCustomElement(elementName: string, parameters: JSComponentParameter[]): void {
customElements.define(elementName, class ConfiguredBlazorCustomElement extends BlazorCustomElement {
static get observedAttributes() {
return BlazorCustomElement.getObservedAttributes(parameters);
}

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

export 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 Blazor.rootComponents.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): any {
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';
3 changes: 3 additions & 0 deletions src/Components/CustomElements/src/js/babel.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"presets": [ [ "@babel/preset-env", { "targets": { "node": true } } ] ]
}
3 changes: 3 additions & 0 deletions src/Components/CustomElements/src/js/dist/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**/*.js
**/*.js.map
**/*.txt
29 changes: 29 additions & 0 deletions src/Components/CustomElements/src/js/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"private": true,
"scripts": {
"preclean": "yarn install --mutex network --frozen-lockfile",
"clean": "node node_modules/rimraf/bin.js ./dist/Debug ./dist/Release",
"prebuild": "yarn run clean && yarn install --mutex network --frozen-lockfile",
"build": "yarn run build:debug && yarn run build:production",
"build:debug": "node node_modules/webpack-cli/bin/cli.js --mode development --config ./webpack.config.js",
"build:production": "node node_modules/webpack-cli/bin/cli.js --mode production --config ./webpack.config.js"
},
"devDependencies": {
"@babel/core": "^7.15.0",
"@babel/preset-env": "^7.15.0",
"@typescript-eslint/eslint-plugin": "^5.26.0",
"@typescript-eslint/parser": "^5.26.0",
"eslint": "^8.16.0",
"inspectpack": "^4.7.1",
"rimraf": "^3.0.2",
"terser": "^5.13.1",
"ts-loader": "^9.2.5",
"typescript": "^4.4.2",
"webpack": "^5.72.1",
"webpack-cli": "^4.9.2"
},
"resolutions": {
"ansi-regex": "5.0.1",
"minimist": ">=1.2.6"
}
}
11 changes: 11 additions & 0 deletions src/Components/CustomElements/src/js/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"lib": [ "DOM", "ES2019" ],
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
}
}
Loading