diff --git a/.azure/pipelines/blazor-daily-tests.yml b/.azure/pipelines/blazor-daily-tests.yml
new file mode 100644
index 0000000000000000000000000000000000000000..537751bfed6bbd393684c9031ba4df0c43d8c286
--- /dev/null
+++ b/.azure/pipelines/blazor-daily-tests.yml
@@ -0,0 +1,59 @@
+# Uses Scheduled Triggers, which aren't supported in YAML yet.
+# https://docs.microsoft.com/en-us/azure/devops/pipelines/build/triggers?view=vsts&tabs=yaml#scheduled
+
+# Daily Tests for Blazor
+# These use Sauce Labs resources, hence they run daily rather than per-commit.
+
+# We just need one Windows machine because all it does is trigger SauceLabs.
+variables:
+  SAUCE_CONNECT_DOWNLOAD_ON_INSTALL: true
+  E2ETESTS_SauceTest: true
+  E2ETESTS_Sauce__TunnelIdentifier: 'blazor-e2e-sc-proxy-tunnel'
+  E2ETESTS_Sauce__HostName: 'sauce.local'
+jobs:
+- template: jobs/default-build.yml
+  parameters:
+    buildDirectory: src/Components
+    isTestingJob: true
+    agentOs: Windows
+    jobName: BlazorDailyTests
+    jobDisplayName: "Blazor Daily Tests"
+    afterBuild:
+
+    # macOS/Safari
+    - script: 'dotnet test --filter "StandaloneAppTest"'
+      workingDirectory: 'src/Components/test/E2ETest'
+      displayName: 'Run Blazor tests - macOS/Safari'
+      condition: succeededOrFailed()
+      env:
+        # Secrets need to be explicitly mapped to env variables.
+        E2ETESTS_Sauce__Username: '$(asplab-sauce-labs-username)'
+        E2ETESTS_Sauce__AccessKey: '$(asplab-sauce-labs-access-key)'
+        # Set platform/browser configuration.
+        E2ETESTS_Sauce__TestName: 'Blazor Daily Tests - macOS/Safari'
+        E2ETESTS_Sauce__PlatformName: 'macOS 10.14'
+        E2ETESTS_Sauce__BrowserName: 'Safari'
+        # Need to explicitly set version here because some older versions don't support timeouts in Safari.
+        E2ETESTS_Sauce__SeleniumVersion: '3.4.0'
+
+    # Android/Chrome
+    - script: 'dotnet test --filter "StandaloneAppTest"'
+      workingDirectory: 'src/Components/test/E2ETest'
+      displayName: 'Run Blazor tests - Android/Chrome'
+      condition: succeededOrFailed()
+      env:
+        # Secrets need to be explicitly mapped to env variables.
+        E2ETESTS_Sauce__Username: '$(asplab-sauce-labs-username)'
+        E2ETESTS_Sauce__AccessKey: '$(asplab-sauce-labs-access-key)'
+        # Set platform/browser configuration.
+        E2ETESTS_Sauce__TestName: 'Blazor Daily Tests - Android/Chrome'
+        E2ETESTS_Sauce__PlatformName: 'Android'
+        E2ETESTS_Sauce__PlatformVersion: '10.0'
+        E2ETESTS_Sauce__BrowserName: 'Chrome'
+        E2ETESTS_Sauce__DeviceName: 'Android GoogleAPI Emulator'
+        E2ETESTS_Sauce__DeviceOrientation: 'portrait'
+        E2ETESTS_Sauce__AppiumVersion: '1.9.1'
+    artifacts:
+    - name: Windows_Logs
+      path: ../../artifacts/log/
+      publishOnError: true
\ No newline at end of file
diff --git a/.azure/pipelines/ci.yml b/.azure/pipelines/ci.yml
index 87d067bb5691bf1b1f4d7cf39e5bb6daaf78edbd..db4461e991f22eeaaf92d03e55d5abd1d4946bff 100644
--- a/.azure/pipelines/ci.yml
+++ b/.azure/pipelines/ci.yml
@@ -7,9 +7,9 @@ trigger:
   batch: true
   branches:
     include:
+    - blazor-wasm
     - master
     - release/*
-    - internal/release/3.*
 
 # Run PR validation on all branches
 pr:
@@ -80,11 +80,18 @@ variables:
       value: test
     - name: _PublishArgs
       value: ''
+<<<<<<< HEAD
+=======
+  # used for post-build phases, internal builds only
+  - ${{ if and(ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}:
+    - group: DotNet-AspNet-SDLValidation-Params
+>>>>>>> bbafecc0535e1de3264845e51ea8b3d18eb3ca61
 
 stages:
 - stage: build
   displayName: Build
   jobs:
+<<<<<<< HEAD
   # Code check
   - ${{ if or(eq(variables['System.TeamProject'], 'public'), in(variables['Build.Reason'], 'PullRequest')) }}:
     - template: jobs/default-build.yml
@@ -109,6 +116,8 @@ stages:
           publishOnError: true
           includeForks: true
 
+=======
+>>>>>>> bbafecc0535e1de3264845e51ea8b3d18eb3ca61
   # Build Windows (x64/x86)
   - template: jobs/default-build.yml
     parameters:
@@ -142,12 +151,13 @@ stages:
                 -arch x64
                 -pack
                 -all
-                -buildNative
+                -NoBuildNative
                 /bl:artifacts/log/build.x64.binlog
                 $(_BuildArgs)
                 $(_InternalRuntimeDownloadArgs)
         displayName: Build x64
 
+<<<<<<< HEAD
       # Build the x86 shared framework
       # TODO: make it possible to build for one Windows architecture at a time
       # This is going to actually build x86 native assets. See https://github.com/aspnet/AspNetCore/issues/7196
@@ -173,6 +183,8 @@ stages:
                 $(_InternalRuntimeDownloadArgs)
         displayName: Build SiteExtension
 
+=======
+>>>>>>> bbafecc0535e1de3264845e51ea8b3d18eb3ca61
       # This runs code-signing on all packages, zips, and jar files as defined in build/CodeSign.targets. If https://github.com/dotnet/arcade/issues/1957 is resolved,
       # consider running code-signing inline with the other previous steps.
       # Sign check is disabled because it is run in a separate step below, after installers are built.
@@ -200,16 +212,6 @@ stages:
                 /p:PublishInstallerBaseVersion=true
         displayName: Build Installers
 
-      # A few files must also go to the VS package feed.
-      - ${{ if and(ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}:
-        - task: NuGetCommand@2
-          displayName: Push Visual Studio packages
-          inputs:
-            command: push
-            packagesToPush: 'artifacts/packages/**/VS.Redist.Common.AspNetCore.*.nupkg'
-            nuGetFeedType: external
-            publishFeedCredentials: 'DevDiv - VS package feed'
-
       artifacts:
       - name: Windows_Logs
         path: artifacts/log/
@@ -218,6 +220,7 @@ stages:
       - name: Windows_Packages
         path: artifacts/packages/
 
+<<<<<<< HEAD
   # Build Windows ARM
   - template: jobs/default-build.yml
     parameters:
@@ -512,6 +515,8 @@ stages:
     parameters:
       inputName: Linux_musl_arm64
 
+=======
+>>>>>>> bbafecc0535e1de3264845e51ea8b3d18eb3ca61
   # Test jobs
   - template: jobs/default-build.yml
     parameters:
@@ -656,6 +661,7 @@ stages:
         publishOnError: true
         includeForks: true
 
+<<<<<<< HEAD
   # Source build
   - job: Source_Build
     displayName: 'Test: Linux Source Build'
@@ -714,23 +720,17 @@ stages:
         artifactType: Container
         parallel: true
 
+=======
+>>>>>>> bbafecc0535e1de3264845e51ea8b3d18eb3ca61
   # Publish to the BAR
   - ${{ if and(ne(variables['System.TeamProject'], 'public'), notin(variables['Build.Reason'], 'PullRequest')) }}:
     - template: /eng/common/templates/job/publish-build-assets.yml
       parameters:
         dependsOn:
           - Windows_build
-          - Windows_arm_build
-          - CodeSign_Xplat_MacOS_x64
-          - CodeSign_Xplat_Linux_x64
-          - CodeSign_Xplat_Linux_arm
-          - CodeSign_Xplat_Linux_arm64
-          - CodeSign_Xplat_Linux_musl_x64
-          - CodeSign_Xplat_Linux_musl_arm64
           # In addition to the dependencies above, ensure the build was successful overall.
           - Linux_Test
           - MacOS_Test
-          - Source_Build
           - Windows_Templates_Test
           - Windows_Test
         pool:
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
new file mode 100644
index 0000000000000000000000000000000000000000..be95a01fc50056ff98ed93c7dc50730f5ad18b20
--- /dev/null
+++ b/.config/dotnet-tools.json
@@ -0,0 +1,12 @@
+{
+  "version": 1,
+  "isRoot": true,
+  "tools": {
+    "dotnet-serve": {
+      "version": "1.5.0",
+      "commands": [
+        "dotnet-serve"
+      ]
+    }
+  }
+}
\ No newline at end of file
diff --git a/NuGet.config b/NuGet.config
index 389b3f7ed6f4d17443c1196d7a0e620b28f013bf..b632b50bdf521dd13b3c4b18d72dd5fc87062f1a 100644
--- a/NuGet.config
+++ b/NuGet.config
@@ -3,6 +3,7 @@
   <packageSources>
     <clear />
     <!--Begin: Package sources managed by Dependency Flow automation. Do not edit the sources below.-->
+<<<<<<< HEAD
     <add key="darc-pub-dotnet-corefx-8a3ffed" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-dotnet-corefx-8a3ffed5/nuget/v3/index.json" />
     <!--End: Package sources managed by Dependency Flow automation. Do not edit the sources above.-->
     <add key="dotnet-core" value="https://dotnetfeed.blob.core.windows.net/dotnet-core/index.json" />
@@ -10,5 +11,24 @@
     <add key="dotnet3.1" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet3.1/nuget/v3/index.json" />
     <add key="dotnet3.1-transport" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet3.1-transport/nuget/v3/index.json" />
     <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
+=======
+    <add key="darc-pub-dotnet-core-setup-65f04fb" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-dotnet-core-setup-65f04fb6/nuget/v3/index.json" />
+    <add key="darc-pub-dotnet-corefx-0f7f38c" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/darc-pub-dotnet-corefx-0f7f38c4/nuget/v3/index.json" />
+    <!--End: Package sources managed by Dependency Flow automation. Do not edit the sources above.-->
+    <add key="dotnet-core" value="https://dotnetfeed.blob.core.windows.net/dotnet-core/index.json" />
+    <add key="dotnet-tools" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-tools/nuget/v3/index.json" />
+    <add key="aspnet-blazor" value="https://dotnetfeed.blob.core.windows.net/aspnet-blazor/index.json" />
+    <add key="aspnet-extensions" value="https://dotnetfeed.blob.core.windows.net/aspnet-extensions/index.json" />
+    <add key="aspnet-entityframeworkcore" value="https://dotnetfeed.blob.core.windows.net/aspnet-entityframeworkcore/index.json" />
+    <add key="aspnet-aspnetcore-tooling" value="https://dotnetfeed.blob.core.windows.net/aspnet-aspnetcore-tooling/index.json" />
+    <add key="aspnet-stable" value="https://dotnetfeed.blob.core.windows.net/dotnet-core-3-1-rtm-014727/index.json" />
+    <add key="grpc-nuget-dev" value="https://grpc.jfrog.io/grpc/api/nuget/v3/grpc-nuget-dev" />
+    <add key="roslyn" value="https://dotnet.myget.org/F/roslyn/api/v3/index.json" />
+    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
+    <add key="aspnetcore-dev" value="https://dotnet.myget.org/F/aspnetcore-dev/api/v3/index.json" />
+    <add key="aspnetcore-tools" value="https://dotnet.myget.org/F/aspnetcore-tools/api/v3/index.json" />
+    <add key="roslyn-tools" value="https://dotnet.myget.org/F/roslyn-tools/api/v3/index.json" />
+    <add key="blazor-wasm" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet3.1-blazor/nuget/v3/index.json" />
+>>>>>>> bbafecc0535e1de3264845e51ea8b3d18eb3ca61
   </packageSources>
 </configuration>
diff --git a/eng/Build.props b/eng/Build.props
index 42a4992e7de171cb1c3d5e1fbcce54a39ae9be11..6ba4d0eda6010369d3ec4d304c0693cd151ed0a8 100644
--- a/eng/Build.props
+++ b/eng/Build.props
@@ -34,7 +34,8 @@
                       $(RepoRoot)src\Installers\**\*.*proj;
                       $(RepoRoot)src\SignalR\clients\ts\**\node_modules\**\*.*proj;
                       $(RepoRoot)src\Components\Web.JS\node_modules\**\*.*proj;
-                      $(RepoRoot)src\Components\Blazor\Templates\src\content\**\*.*proj;
+                      $(RepoRoot)src\Components\Blazor\Build\testassets\**\*.*proj;
+                      $(RepoRoot)src\ProjectTemplates\BlazorWasm.ProjectTemplates\content\**\*.csproj;
                       $(RepoRoot)src\ProjectTemplates\Web.ProjectTemplates\content\**\*.csproj;
                       $(RepoRoot)src\ProjectTemplates\Web.ProjectTemplates\content\**\*.fsproj;
                       $(RepoRoot)src\ProjectTemplates\Web.Spa.ProjectTemplates\content\**\*.csproj;
@@ -49,6 +50,11 @@
                       " />
   </ItemGroup>
 
+  <PropertyGroup>
+    <!-- For the Blazor WASM branch, only build a subset of projects -->
+    <ProjectToBuild>$(RepoRoot)src\Components\**\*.csproj;$(RepoRoot)src\ProjectTemplates\**\*.csproj</ProjectToBuild>
+  </PropertyGroup>
+
   <Choose>
     <!-- Project selection can be overridden on the command line by passing in -projects -->
     <When Condition="'$(ProjectToBuild)' != ''">
diff --git a/eng/common/tools.ps1 b/eng/common/tools.ps1
index 92a053bd16b46e3059135daae0f930799030eb64..29d76cd3bed7380033522ad9f74d294fb367254b 100644
--- a/eng/common/tools.ps1
+++ b/eng/common/tools.ps1
@@ -257,7 +257,7 @@ function InitializeVisualStudioMSBuild([bool]$install, [object]$vsRequirements =
     if ($msbuildCmd -ne $null) {
       # Workaround for https://github.com/dotnet/roslyn/issues/35793
       # Due to this issue $msbuildCmd.Version returns 0.0.0.0 for msbuild.exe 16.2+
-      $msbuildVersion = [Version]::new((Get-Item $msbuildCmd.Path).VersionInfo.ProductVersion.Split(@('-', '+'))[0])
+      $msbuildVersion = [Version]::new((Get-Item $msbuildCmd.Path).VersionInfo.ProductVersion.Split([char[]]@('-', '+'))[0])
 
       if ($msbuildVersion -ge $vsMinVersion) {
         return $global:_MSBuildExe = $msbuildCmd.Path
diff --git a/eng/scripts/CodeCheck.ps1 b/eng/scripts/CodeCheck.ps1
index 072f55fe21962edba1690a95c85354d3ee68d906..a7baf079c824bc44fb73fdd4e3df190c59dfef81 100644
--- a/eng/scripts/CodeCheck.ps1
+++ b/eng/scripts/CodeCheck.ps1
@@ -170,10 +170,11 @@ try {
         & $PSScriptRoot\GenerateReferenceAssemblies.ps1 -ci:$ci
     }
 
-    Write-Host "Re-generating package baselines"
-    Invoke-Block {
-        & dotnet run -p "$repoRoot/eng/tools/BaselineGenerator/"
-    }
+    # Temporarily disable package baseline generation while we stage for publishing
+    # Write-Host "Re-generating package baselines"
+    # Invoke-Block {
+    #     & dotnet run -p "$repoRoot/eng/tools/BaselineGenerator/"
+    # }
 
     Write-Host "Run git diff to check for pending changes"
 
diff --git a/global.json b/global.json
index 974708a61113ed99567d82ef9b756719cc054c6f..e0882dd64d6365b1fafb69e4f6c0c65afdf3a83e 100644
--- a/global.json
+++ b/global.json
@@ -1,9 +1,16 @@
 {
   "sdk": {
+<<<<<<< HEAD
     "version": "3.1.103"
   },
   "tools": {
     "dotnet": "3.1.103",
+=======
+    "version": "3.1.100"
+  },
+  "tools": {
+    "dotnet": "3.1.100",
+>>>>>>> bbafecc0535e1de3264845e51ea8b3d18eb3ca61
     "runtimes": {
       "dotnet/x64": [
         "$(MicrosoftNETCoreAppInternalPackageVersion)"
@@ -25,7 +32,12 @@
   },
   "msbuild-sdks": {
     "Yarn.MSBuild": "1.15.2",
+<<<<<<< HEAD
     "Microsoft.DotNet.Arcade.Sdk": "1.0.0-beta.20213.4",
     "Microsoft.DotNet.Helix.Sdk": "2.0.0-beta.20213.4"
+=======
+    "Microsoft.DotNet.Arcade.Sdk": "1.0.0-beta.19577.5",
+    "Microsoft.DotNet.Helix.Sdk": "2.0.0-beta.19577.5"
+>>>>>>> bbafecc0535e1de3264845e51ea8b3d18eb3ca61
   }
 }
diff --git a/src/Components/Blazor/Blazor.Version.props b/src/Components/Blazor/Blazor.Version.props
new file mode 100644
index 0000000000000000000000000000000000000000..123a94c1d7729a447a72669198d96782f7dbd56e
--- /dev/null
+++ b/src/Components/Blazor/Blazor.Version.props
@@ -0,0 +1,8 @@
+<Project>
+  <PropertyGroup>
+    <!-- Override version labels -->
+    <VersionPrefix>3.2.0</VersionPrefix>
+    <PreReleaseVersionLabel>preview1</PreReleaseVersionLabel>
+    <DotNetFinalVersionKind />
+  </PropertyGroup>
+</Project>
\ No newline at end of file
diff --git a/src/Components/Blazor/Blazor/ref/Microsoft.AspNetCore.Blazor.netstandard2.1.cs b/src/Components/Blazor/Blazor/ref/Microsoft.AspNetCore.Blazor.netstandard2.1.cs
new file mode 100644
index 0000000000000000000000000000000000000000..eb33362975188b0867092993bf5e66d5e0d4708a
--- /dev/null
+++ b/src/Components/Blazor/Blazor/ref/Microsoft.AspNetCore.Blazor.netstandard2.1.cs
@@ -0,0 +1,69 @@
+// 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.
+
+namespace Microsoft.AspNetCore.Blazor
+{
+    [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Never)]
+    public static partial class JSInteropMethods
+    {
+        [Microsoft.JSInterop.JSInvokableAttribute("NotifyLocationChanged")]
+        public static void NotifyLocationChanged(string uri, bool isInterceptedLink) { }
+    }
+}
+namespace Microsoft.AspNetCore.Blazor.Hosting
+{
+    [System.Runtime.InteropServices.StructLayoutAttribute(System.Runtime.InteropServices.LayoutKind.Sequential)]
+    public readonly partial struct RootComponentMapping
+    {
+        private readonly object _dummy;
+        public RootComponentMapping(System.Type componentType, string selector) { throw null; }
+        public System.Type ComponentType { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
+        public string Selector { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
+    }
+    public partial class RootComponentMappingCollection : System.Collections.ObjectModel.Collection<Microsoft.AspNetCore.Blazor.Hosting.RootComponentMapping>
+    {
+        public RootComponentMappingCollection() { }
+        public void Add(System.Type componentType, string selector) { }
+        public void AddRange(System.Collections.Generic.IEnumerable<Microsoft.AspNetCore.Blazor.Hosting.RootComponentMapping> items) { }
+        public void Add<TComponent>(string selector) where TComponent : Microsoft.AspNetCore.Components.IComponent { }
+    }
+    public sealed partial class WebAssemblyHost : System.IAsyncDisposable
+    {
+        internal WebAssemblyHost() { }
+        public Microsoft.Extensions.Configuration.IConfiguration Configuration { get { throw null; } }
+        public System.IServiceProvider Services { get { throw null; } }
+        [System.Diagnostics.DebuggerStepThroughAttribute]
+        public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; }
+        public System.Threading.Tasks.Task RunAsync() { throw null; }
+    }
+    public sealed partial class WebAssemblyHostBuilder
+    {
+        internal WebAssemblyHostBuilder() { }
+        public Microsoft.Extensions.Configuration.IConfigurationBuilder Configuration { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
+        public Microsoft.AspNetCore.Blazor.Hosting.RootComponentMappingCollection RootComponents { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
+        public Microsoft.Extensions.DependencyInjection.IServiceCollection Services { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
+        public Microsoft.AspNetCore.Blazor.Hosting.WebAssemblyHost Build() { throw null; }
+        public static Microsoft.AspNetCore.Blazor.Hosting.WebAssemblyHostBuilder CreateDefault(string[] args = null) { throw null; }
+    }
+}
+namespace Microsoft.AspNetCore.Blazor.Http
+{
+    public enum FetchCredentialsOption
+    {
+        Omit = 0,
+        SameOrigin = 1,
+        Include = 2,
+    }
+    public static partial class WebAssemblyHttpMessageHandlerOptions
+    {
+        public static Microsoft.AspNetCore.Blazor.Http.FetchCredentialsOption DefaultCredentials { get { throw null; } set { } }
+    }
+}
+namespace Microsoft.AspNetCore.Blazor.Rendering
+{
+    public static partial class WebAssemblyEventDispatcher
+    {
+        [Microsoft.JSInterop.JSInvokableAttribute("DispatchEvent")]
+        public static System.Threading.Tasks.Task DispatchEvent(Microsoft.AspNetCore.Components.RenderTree.WebEventDescriptor eventDescriptor, string eventArgsJson) { throw null; }
+    }
+}
diff --git a/src/Components/Blazor/Blazor/src/Builder/ComponentsApplicationBuilderExtensions.cs b/src/Components/Blazor/Blazor/src/Builder/ComponentsApplicationBuilderExtensions.cs
deleted file mode 100644
index 66532474c6368d938f3703bb1838b33ce96b1b53..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Builder/ComponentsApplicationBuilderExtensions.cs
+++ /dev/null
@@ -1,24 +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.
-
-namespace Microsoft.AspNetCore.Components.Builder
-{
-    /// <summary>
-    /// Provides extension methods for <see cref="IComponentsApplicationBuilder"/>.
-    /// </summary>
-    public static class ComponentsApplicationBuilderExtensions
-    {
-        /// <summary>
-        /// Associates the component type with the application,
-        /// causing it to be displayed in the specified DOM element.
-        /// </summary>
-        /// <param name="app">The <see cref="IComponentsApplicationBuilder"/>.</param>
-        /// <typeparam name="TComponent">The type of the component.</typeparam>
-        /// <param name="domElementSelector">A CSS selector that uniquely identifies a DOM element.</param>
-        public static void AddComponent<TComponent>(this IComponentsApplicationBuilder app, string domElementSelector)
-            where TComponent : IComponent
-        {
-            app.AddComponent(typeof(TComponent), domElementSelector);
-        }
-    }
-}
diff --git a/src/Components/Blazor/Blazor/src/Builder/IComponentsApplicationBuilder.cs b/src/Components/Blazor/Blazor/src/Builder/IComponentsApplicationBuilder.cs
deleted file mode 100644
index 84806a87697e65fb7641ae649a8fa362e8c75597..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Builder/IComponentsApplicationBuilder.cs
+++ /dev/null
@@ -1,26 +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;
-
-namespace Microsoft.AspNetCore.Components.Builder
-{
-    /// <summary>
-    /// A builder for adding components to an application.
-    /// </summary>
-    public interface IComponentsApplicationBuilder
-    {
-        /// <summary>
-        /// Gets the application services.
-        /// </summary>
-        IServiceProvider Services { get; }
-
-        /// <summary>
-        /// Associates the <see cref="IComponent"/> with the application,
-        /// causing it to be displayed in the specified DOM element.
-        /// </summary>
-        /// <param name="componentType">The type of the component.</param>
-        /// <param name="domElementSelector">A CSS selector that uniquely identifies a DOM element.</param>
-        void AddComponent(Type componentType, string domElementSelector);
-    }
-}
diff --git a/src/Components/Blazor/Blazor/src/Hosting/BlazorWebAssemblyHost.cs b/src/Components/Blazor/Blazor/src/Hosting/BlazorWebAssemblyHost.cs
deleted file mode 100644
index e5631805ef647f4fa1878a0f40c8b807ebe23ee9..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Hosting/BlazorWebAssemblyHost.cs
+++ /dev/null
@@ -1,20 +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.
-
-namespace Microsoft.AspNetCore.Blazor.Hosting
-{
-    /// <summary>
-    /// Used to create an instance of Blazor host builder for a Browser application.
-    /// </summary>
-    public static class BlazorWebAssemblyHost
-    {
-        /// <summary>
-        /// Creates an instance of <see cref="IWebAssemblyHostBuilder"/>.
-        /// </summary>
-        /// <returns>The <see cref="IWebAssemblyHostBuilder"/>.</returns>
-        public static IWebAssemblyHostBuilder CreateDefaultBuilder()
-        {
-            return new WebAssemblyHostBuilder();
-        }
-    }
-}
diff --git a/src/Components/Blazor/Blazor/src/Hosting/ConventionBasedStartup.cs b/src/Components/Blazor/Blazor/src/Hosting/ConventionBasedStartup.cs
deleted file mode 100644
index 53eecbf6e08c65c1a36c45beb6bf58b40b03003a..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Hosting/ConventionBasedStartup.cs
+++ /dev/null
@@ -1,117 +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 System.Diagnostics;
-using System.Linq;
-using System.Reflection;
-using System.Runtime.ExceptionServices;
-using Microsoft.AspNetCore.Components.Builder;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Microsoft.AspNetCore.Blazor.Hosting
-{
-    // Keeping this simple for now to focus on predictable and reasonable behaviors.
-    // Startup in WebHost supports lots of things we don't yet support, and some we
-    // may never support.
-    //
-    // Possible additions:
-    // - environments
-    // - case-insensitivity (makes sense with environments)
-    //
-    // Likely never:
-    // - statics
-    // - DI into constructor
-    internal class ConventionBasedStartup : IBlazorStartup
-    {
-        public ConventionBasedStartup(object instance)
-        {
-            Instance = instance ?? throw new ArgumentNullException(nameof(instance));
-        }
-
-        public object Instance { get; }
-
-        public void Configure(IComponentsApplicationBuilder app, IServiceProvider services)
-        {
-            try
-            {
-                var method = GetConfigureMethod();
-                Debug.Assert(method != null);
-
-                var parameters = method.GetParameters();
-                var arguments = new object[parameters.Length];
-                for (var i = 0; i < parameters.Length; i++)
-                {
-                    var parameter = parameters[i];
-                    arguments[i] = parameter.ParameterType == typeof(IComponentsApplicationBuilder)
-                        ? app
-                        : services.GetRequiredService(parameter.ParameterType);
-                }
-
-                method.Invoke(Instance, arguments);
-            }
-            catch (Exception ex)
-            {
-                if (ex is TargetInvocationException)
-                {
-                    ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
-                }
-
-                throw;
-            }
-        }
-
-        internal MethodInfo GetConfigureMethod()
-        {
-            var methods = Instance.GetType()
-                .GetMethods(BindingFlags.Instance | BindingFlags.Public)
-                .Where(m => string.Equals(m.Name, "Configure", StringComparison.Ordinal))
-                .ToArray();
-
-            if (methods.Length == 1)
-            {
-                return methods[0];
-            }
-            else if (methods.Length == 0)
-            {
-                throw new InvalidOperationException("The startup class must define a 'Configure' method.");
-            }
-            else
-            {
-                throw new InvalidOperationException("Overloading the 'Configure' method is not supported.");
-            }
-        }
-
-        public void ConfigureServices(IServiceCollection services)
-        {
-            try
-            {
-                var method = GetConfigureServicesMethod();
-                if (method != null)
-                {
-                     method.Invoke(Instance, new object[] { services });
-                }
-            }
-            catch (Exception ex)
-            {
-                if (ex is TargetInvocationException)
-                {
-                    ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
-                }
-
-                throw;
-            }
-        }
-
-        internal MethodInfo GetConfigureServicesMethod()
-        {
-            return Instance.GetType()
-                .GetMethod(
-                    "ConfigureServices",
-                    BindingFlags.Public | BindingFlags.Instance,
-                    null,
-                    new Type[] { typeof(IServiceCollection), },
-                    Array.Empty<ParameterModifier>());
-        }
-    }
-}
\ No newline at end of file
diff --git a/src/Components/Blazor/Blazor/src/Hosting/EntrypointInvoker.cs b/src/Components/Blazor/Blazor/src/Hosting/EntrypointInvoker.cs
new file mode 100644
index 0000000000000000000000000000000000000000..40a5d07de4312bbe885f91cf67bfc1801b47d606
--- /dev/null
+++ b/src/Components/Blazor/Blazor/src/Hosting/EntrypointInvoker.cs
@@ -0,0 +1,89 @@
+// 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.Linq;
+using System.Reflection;
+using System.Threading.Tasks;
+
+namespace Microsoft.AspNetCore.Blazor.Hosting
+{
+    internal static class EntrypointInvoker
+    {
+        // This method returns void because currently the JS side is not listening to any result,
+        // nor will it handle any exceptions. We handle all exceptions internally to this method.
+        // In the future we may want Blazor.start to return something that exposes the possibly-async
+        // entrypoint result to the JS caller. There's no requirement to do that today, and if we
+        // do change this it will be non-breaking.
+        public static void InvokeEntrypoint(string assemblyName, string[] args)
+        {
+            object entrypointResult;
+            try
+            {
+                var assembly = Assembly.Load(assemblyName);
+                var entrypoint = FindUnderlyingEntrypoint(assembly);
+                var @params = entrypoint.GetParameters().Length == 1 ? new object[] { args ?? Array.Empty<string>() } : new object[] { };
+                entrypointResult = entrypoint.Invoke(null, @params);
+            }
+            catch (Exception syncException)
+            {
+                HandleStartupException(syncException);
+                return;
+            }
+
+            // If the entrypoint is async, handle async exceptions in the same way that we would
+            // have handled sync ones
+            if (entrypointResult is Task entrypointTask)
+            {
+                entrypointTask.ContinueWith(task =>
+                {
+                    if (task.Exception != null)
+                    {
+                        HandleStartupException(task.Exception);
+                    }
+                });
+            }
+        }
+
+        private static MethodBase FindUnderlyingEntrypoint(Assembly assembly)
+        {
+            // This is the entrypoint declared in .NET metadata. In the case of async main, it's the
+            // compiler-generated wrapper method. Otherwise it's the developer-defined method.
+            var metadataEntrypointMethodBase = assembly.EntryPoint;
+
+            // For "async Task Main", the C# compiler generates a method called "<Main>"
+            // that is marked as the assembly entrypoint. Detect this case, and instead of
+            // calling "<Whatever>", call the sibling "Whatever".
+            if (metadataEntrypointMethodBase.IsSpecialName)
+            {
+                var origName = metadataEntrypointMethodBase.Name;
+                var origNameLength = origName.Length;
+                if (origNameLength > 2)
+                {
+                    var candidateMethodName = origName.Substring(1, origNameLength - 2);
+                    var candidateMethod = metadataEntrypointMethodBase.DeclaringType.GetMethod(
+                        candidateMethodName,
+                        BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic,
+                        null,
+                        metadataEntrypointMethodBase.GetParameters().Select(p => p.ParameterType).ToArray(),
+                        null);
+
+                    if (candidateMethod != null)
+                    {
+                        return candidateMethod;
+                    }
+                }
+            }
+
+            // Either it's not async main, or for some reason we couldn't locate the underlying entrypoint,
+            // so use the one from assembly metadata.
+            return metadataEntrypointMethodBase;
+        }
+
+        private static void HandleStartupException(Exception exception)
+        {
+            // Logs to console, and causes the error UI to appear
+            Console.Error.WriteLine(exception);
+        }
+    }
+}
diff --git a/src/Components/Blazor/Blazor/src/Hosting/IBlazorStartup.cs b/src/Components/Blazor/Blazor/src/Hosting/IBlazorStartup.cs
deleted file mode 100644
index e87138387f212349a77730b403d20013adfa42f7..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Hosting/IBlazorStartup.cs
+++ /dev/null
@@ -1,16 +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.AspNetCore.Components.Builder;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Microsoft.AspNetCore.Blazor.Hosting
-{
-    internal interface IBlazorStartup
-    {
-        void ConfigureServices(IServiceCollection services);
-
-        void Configure(IComponentsApplicationBuilder app, IServiceProvider services);
-    }
-}
diff --git a/src/Components/Blazor/Blazor/src/Hosting/IWebAssemblyHost.cs b/src/Components/Blazor/Blazor/src/Hosting/IWebAssemblyHost.cs
deleted file mode 100644
index 8f21f93421bb01d67b42228a5f707140ddbef009..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Hosting/IWebAssemblyHost.cs
+++ /dev/null
@@ -1,34 +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 System.Threading;
-using System.Threading.Tasks;
-
-namespace Microsoft.AspNetCore.Blazor.Hosting
-{
-    /// <summary>
-    /// A program abstraction.
-    /// </summary>
-    public interface IWebAssemblyHost : IDisposable
-    {
-        /// <summary>
-        /// The programs configured services.
-        /// </summary>
-        IServiceProvider Services { get; }
-
-        /// <summary>
-        /// Start the program.
-        /// </summary>
-        /// <param name="cancellationToken">Used to abort program start.</param>
-        /// <returns></returns>
-        Task StartAsync(CancellationToken cancellationToken = default);
-
-        /// <summary>
-        /// Attempts to gracefully stop the program.
-        /// </summary>
-        /// <param name="cancellationToken">Used to indicate when stop should no longer be graceful.</param>
-        /// <returns></returns>
-        Task StopAsync(CancellationToken cancellationToken = default);
-    }
-}
diff --git a/src/Components/Blazor/Blazor/src/Hosting/IWebAssemblyHostBuilder.cs b/src/Components/Blazor/Blazor/src/Hosting/IWebAssemblyHostBuilder.cs
deleted file mode 100644
index c5a52bc280c9fb627e2e4f5fb44a34cf2ccaf809..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Hosting/IWebAssemblyHostBuilder.cs
+++ /dev/null
@@ -1,46 +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 System.Collections.Generic;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Microsoft.AspNetCore.Blazor.Hosting
-{
-    /// <summary>
-    /// Abstraction for configuring a Blazor browser-based application.
-    /// </summary>
-    public interface IWebAssemblyHostBuilder
-    {
-        /// <summary>
-        /// A central location for sharing state between components during the host building process.
-        /// </summary>
-        IDictionary<object, object> Properties { get; }
-
-        /// <summary>
-        /// Overrides the factory used to create the service provider.
-        /// </summary>
-        /// <returns>The same instance of the <see cref="IWebAssemblyHostBuilder"/> for chaining.</returns>
-        IWebAssemblyHostBuilder UseServiceProviderFactory<TContainerBuilder>(IServiceProviderFactory<TContainerBuilder> factory);
-
-        /// <summary>
-        /// Overrides the factory used to create the service provider.
-        /// </summary>
-        /// <returns>The same instance of the <see cref="IWebAssemblyHostBuilder"/> for chaining.</returns>
-        IWebAssemblyHostBuilder UseServiceProviderFactory<TContainerBuilder>(Func<WebAssemblyHostBuilderContext, IServiceProviderFactory<TContainerBuilder>> factory);
-
-        /// <summary>
-        /// Adds services to the container. This can be called multiple times and the results will be additive.
-        /// </summary>
-        /// <param name="configureDelegate">The delegate for configuring the <see cref="IServiceCollection"/> that will be used
-        /// to construct the <see cref="IServiceProvider"/>.</param>
-        /// <returns>The same instance of the <see cref="IWebAssemblyHostBuilder"/> for chaining.</returns>
-        IWebAssemblyHostBuilder ConfigureServices(Action<WebAssemblyHostBuilderContext, IServiceCollection> configureDelegate);
-
-        /// <summary>
-        /// Run the given actions to initialize the host. This can only be called once.
-        /// </summary>
-        /// <returns>An initialized <see cref="IWebAssemblyHost"/></returns>
-        IWebAssemblyHost Build();
-    }
-}
diff --git a/src/Components/Blazor/Blazor/src/Hosting/IWebAssemblyServiceFactoryAdapter.cs b/src/Components/Blazor/Blazor/src/Hosting/IWebAssemblyServiceFactoryAdapter.cs
deleted file mode 100644
index c790a3c8791f2c973f99ee537cc18cb30d98d0da..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Hosting/IWebAssemblyServiceFactoryAdapter.cs
+++ /dev/null
@@ -1,17 +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.DependencyInjection;
-
-namespace Microsoft.AspNetCore.Blazor.Hosting
-{
-    // Equivalent to https://github.com/aspnet/Extensions/blob/master/src/Hosting/Hosting/src/Internal/IServiceFactoryAdapter.cs
-
-    internal interface IWebAssemblyServiceFactoryAdapter
-    {
-        object CreateBuilder(IServiceCollection services);
-
-        IServiceProvider CreateServiceProvider(object containerBuilder);
-    }
-}
diff --git a/src/Components/Blazor/Blazor/src/Hosting/RootComponentMapping.cs b/src/Components/Blazor/Blazor/src/Hosting/RootComponentMapping.cs
new file mode 100644
index 0000000000000000000000000000000000000000..53b34d882285df7bae74bb18649a73ab9ace5b39
--- /dev/null
+++ b/src/Components/Blazor/Blazor/src/Hosting/RootComponentMapping.cs
@@ -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 Microsoft.AspNetCore.Components;
+
+namespace Microsoft.AspNetCore.Blazor.Hosting
+{
+    /// <summary>
+    /// Defines a mapping between a root <see cref="IComponent"/> and a DOM element selector.
+    /// </summary>
+    public readonly struct RootComponentMapping
+    {
+        /// <summary>
+        /// Creates a new instance of <see cref="RootComponentMapping"/> with the provided <paramref name="componentType"/>
+        /// and <paramref name="selector"/>.
+        /// </summary>
+        /// <param name="componentType">The component type. Must implement <see cref="IComponent"/>.</param>
+        /// <param name="selector">The DOM element selector.</param>
+        public RootComponentMapping(Type componentType, string selector)
+        {
+            if (componentType is null)
+            {
+                throw new ArgumentNullException(nameof(componentType));
+            }
+
+            if (!typeof(IComponent).IsAssignableFrom(componentType))
+            {
+                throw new ArgumentException(
+                    $"The type '{componentType.Name}' must implement {nameof(IComponent)} to be used as a root component.",
+                    nameof(componentType));
+            }
+
+            if (selector is null)
+            {
+                throw new ArgumentNullException(nameof(selector));
+            }
+
+            ComponentType = componentType;
+            Selector = selector;
+        }
+
+        /// <summary>
+        /// Gets the component type.
+        /// </summary>
+        public Type ComponentType { get; }
+
+        /// <summary>
+        /// Gets the DOM element selector.
+        /// </summary>
+        public string Selector { get; }
+    }
+}
diff --git a/src/Components/Blazor/Blazor/src/Hosting/RootComponentMappingCollection.cs b/src/Components/Blazor/Blazor/src/Hosting/RootComponentMappingCollection.cs
new file mode 100644
index 0000000000000000000000000000000000000000..0e488f0deb85f54c624ac029b6d396c945151900
--- /dev/null
+++ b/src/Components/Blazor/Blazor/src/Hosting/RootComponentMappingCollection.cs
@@ -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.Collections.ObjectModel;
+using Microsoft.AspNetCore.Components;
+
+namespace Microsoft.AspNetCore.Blazor.Hosting
+{
+    /// <summary>
+    /// Defines a collection of <see cref="RootComponentMapping"/> items.
+    /// </summary>
+    public class RootComponentMappingCollection : Collection<RootComponentMapping>
+    {
+        /// <summary>
+        /// Adds a component mapping to the collection.
+        /// </summary>
+        /// <typeparam name="TComponent">The component type.</typeparam>
+        /// <param name="selector">The DOM element selector.</param>
+        public void Add<TComponent>(string selector) where TComponent : IComponent
+        {
+            if (selector is null)
+            {
+                throw new ArgumentNullException(nameof(selector));
+            }
+
+            Add(new RootComponentMapping(typeof(TComponent), selector));
+        }
+
+        /// <summary>
+        /// Adds a component mapping to the collection.
+        /// </summary>
+        /// <param name="componentType">The component type. Must implement <see cref="IComponent"/>.</param>
+        /// <param name="selector">The DOM element selector.</param>
+        public void Add(Type componentType, string selector)
+        {
+            if (componentType is null)
+            {
+                throw new ArgumentNullException(nameof(componentType));
+            }
+
+            if (selector is null)
+            {
+                throw new ArgumentNullException(nameof(selector));
+            }
+
+            Add(new RootComponentMapping(componentType, selector));
+        }
+
+        /// <summary>
+        /// Adds a collection of items to this collection.
+        /// </summary>
+        /// <param name="items">The items to add.</param>
+        public void AddRange(IEnumerable<RootComponentMapping> items)
+        {
+            if (items is null)
+            {
+                throw new ArgumentNullException(nameof(items));
+            }
+
+            foreach (var item in items)
+            {
+                Add(item);
+            }
+        }
+    }
+}
diff --git a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyBlazorApplicationBuilder.cs b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyBlazorApplicationBuilder.cs
deleted file mode 100644
index 8cb5d875610077d96c3394eb431dd8900b24c2f7..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyBlazorApplicationBuilder.cs
+++ /dev/null
@@ -1,54 +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 System.Collections.Generic;
-using System.Threading.Tasks;
-using Microsoft.AspNetCore.Blazor.Rendering;
-using Microsoft.AspNetCore.Components.Builder;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-
-namespace Microsoft.AspNetCore.Blazor.Hosting
-{
-    internal class WebAssemblyBlazorApplicationBuilder : IComponentsApplicationBuilder
-    {
-        public WebAssemblyBlazorApplicationBuilder(IServiceProvider services)
-        {
-            Entries = new List<(Type componentType, string domElementSelector)>();
-            Services = services;
-        }
-
-        public List<(Type componentType, string domElementSelector)> Entries { get; }
-
-        public IServiceProvider Services { get; }
-
-        public void AddComponent(Type componentType, string domElementSelector)
-        {
-            if (componentType == null)
-            {
-                throw new ArgumentNullException(nameof(componentType));
-            }
-
-            if (domElementSelector == null)
-            {
-                throw new ArgumentNullException(nameof(domElementSelector));
-            }
-
-            Entries.Add((componentType, domElementSelector));
-        }
-
-        public async Task<WebAssemblyRenderer> CreateRendererAsync()
-        {
-            var loggerFactory = (ILoggerFactory)Services.GetService(typeof(ILoggerFactory));
-            var renderer = new WebAssemblyRenderer(Services, loggerFactory);
-            for (var i = 0; i < Entries.Count; i++)
-            {
-                var (componentType, domElementSelector) = Entries[i];
-                await renderer.AddComponentAsync(componentType, domElementSelector);
-            }
-
-            return renderer;
-        }
-    }
-}
diff --git a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHost.cs b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHost.cs
index 3a2ccfbaaee2b1ecde2d84821a8da5121ddf5288..02c2693ff7eceb0a0dd4f87c14b56f45a68af8ad 100644
--- a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHost.cs
+++ b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHost.cs
@@ -5,89 +5,135 @@ using System;
 using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.AspNetCore.Blazor.Rendering;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
-using Microsoft.JSInterop;
+using Microsoft.Extensions.Logging;
 
 namespace Microsoft.AspNetCore.Blazor.Hosting
 {
-    internal class WebAssemblyHost : IWebAssemblyHost
+    /// <summary>
+    /// A host object for Blazor running under WebAssembly. Use <see cref="WebAssemblyHostBuilder"/>
+    /// to initialize a <see cref="WebAssemblyHost"/>.
+    /// </summary>
+    public sealed class WebAssemblyHost : IAsyncDisposable
     {
-        private readonly IJSRuntime _runtime;
-
-        private IServiceScope _scope;
+        private readonly IServiceScope _scope;
+        private readonly IServiceProvider _services;
+        private readonly IConfiguration _configuration;
+        private readonly RootComponentMapping[] _rootComponents;
+
+        // NOTE: the host is disposable because it OWNs references to disposable things.
+        //
+        // The twist is that in general dispose is not going to run even if the user puts it in a using.
+        // When a user refreshes or navigates away that terminates the app, like a process.exit. So the
+        // dispose functionality here is basically so that it can be used in unit tests.
+        //
+        // Based on the APIs that exist in Blazor today it's not possible for the
+        // app to get disposed, however if we add something like that in the future, most of the work is
+        // already done.
+        private bool _disposed;
+        private bool _started;
         private WebAssemblyRenderer _renderer;
 
-        public WebAssemblyHost(IServiceProvider services, IJSRuntime runtime)
-        {
-            Services = services ?? throw new ArgumentNullException(nameof(services));
-            _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
-        }
-
-        public IServiceProvider Services { get; }
-
-        public Task StartAsync(CancellationToken cancellationToken = default)
+        internal WebAssemblyHost(IServiceProvider services, IServiceScope scope, IConfiguration configuration, RootComponentMapping[] rootComponents)
         {
-            return StartAsyncAwaited();
+            // To ensure JS-invoked methods don't get linked out, have a reference to their enclosing types
+            GC.KeepAlive(typeof(EntrypointInvoker));
+            GC.KeepAlive(typeof(JSInteropMethods));
+            GC.KeepAlive(typeof(WebAssemblyEventDispatcher));
+
+            _services = services;
+            _scope = scope;
+            _configuration = configuration;
+            _rootComponents = rootComponents;
         }
 
-        private async Task StartAsyncAwaited()
+        /// <summary>
+        /// Gets the application configuration.
+        /// </summary>
+        public IConfiguration Configuration => _configuration;
+
+        /// <summary>
+        /// Gets the service provider associated with the application.
+        /// </summary>
+        public IServiceProvider Services => _scope.ServiceProvider;
+
+        /// <summary>
+        /// Disposes the host asynchronously.
+        /// </summary>
+        /// <returns>A <see cref="ValueTask"/> which respresents the completion of disposal.</returns>
+        public async ValueTask DisposeAsync()
         {
-            var scopeFactory = Services.GetRequiredService<IServiceScopeFactory>();
-            _scope = scopeFactory.CreateScope();
-
-            try
+            if (_disposed)
             {
-                var startup = _scope.ServiceProvider.GetService<IBlazorStartup>();
-                if (startup == null)
-                {
-                    var message =
-                        $"Could not find a registered Blazor Startup class. " +
-                        $"Using {nameof(IWebAssemblyHost)} requires a call to {nameof(IWebAssemblyHostBuilder)}.UseBlazorStartup.";
-                    throw new InvalidOperationException(message);
-                }
+                return;
+            }
+
+            _disposed = true;
 
-                // Note that we differ from the WebHost startup path here by using a 'scope' for the app builder
-                // as well as the Configure method.
-                var builder = new WebAssemblyBlazorApplicationBuilder(_scope.ServiceProvider);
-                startup.Configure(builder, _scope.ServiceProvider);
+            _renderer?.Dispose();
 
-                _renderer = await builder.CreateRendererAsync();
+            if (_scope is IAsyncDisposable asyncDisposableScope)
+            {
+                await asyncDisposableScope.DisposeAsync();
             }
-            catch
+            else
             {
-                _scope.Dispose();
-                _scope = null;
-
-                if (_renderer != null)
-                {
-                    _renderer.Dispose();
-                    _renderer = null;
-                }
+                _scope?.Dispose();
+            }
 
-                throw;
+            if (_services is IAsyncDisposable asyncDisposableServices)
+            {
+                await asyncDisposableServices.DisposeAsync();
+            }
+            else if (_services is IDisposable disposableServices)
+            {
+                disposableServices.Dispose();
             }
         }
 
-        public Task StopAsync(CancellationToken cancellationToken = default)
+        /// <summary>
+        /// Runs the application associated with this host. 
+        /// </summary>
+        /// <returns>A <see cref="Task"/> which represents exit of the application.</returns>
+        /// <remarks>
+        /// At this time, it's not possible to shut down a Blazor WebAssembly application using imperative code.
+        /// The application only stops when the hosting page is reloaded or navigated to another page. As a result
+        /// the task returned from this method does not complete. This method is not suitable for use in unit-testing.
+        /// </remarks>
+        public Task RunAsync()
         {
-            if (_scope != null)
+            // RunAsyncCore will await until the CancellationToken fires. However, we don't fire it
+            // currently, so the app will "run" forever.
+            return RunAsyncCore(CancellationToken.None);
+        }
+
+        // Internal for testing.
+        internal async Task RunAsyncCore(CancellationToken cancellationToken)
+        {
+            if (_started)
             {
-                _scope.Dispose();
-                _scope = null;
+                throw new InvalidOperationException("The host has already started.");
             }
 
-            if (_renderer != null)
+            _started = true;
+
+            var tcs = new TaskCompletionSource<object>();
+
+            using (cancellationToken.Register(() => { tcs.TrySetResult(null); }))
             {
-                _renderer.Dispose();
-                _renderer = null;
-            }
+                var loggerFactory = Services.GetRequiredService<ILoggerFactory>();
+                _renderer = new WebAssemblyRenderer(Services, loggerFactory);
 
-            return Task.CompletedTask;
-        }
+                var rootComponents = _rootComponents;
+                for (var i = 0; i < rootComponents.Length; i++)
+                {
+                    var rootComponent = rootComponents[i];
+                    await _renderer.AddComponentAsync(rootComponent.ComponentType, rootComponent.Selector);
+                }
 
-        public void Dispose()
-        {
-            (Services as IDisposable)?.Dispose();
+                await tcs.Task;
+            }
         }
     }
 }
diff --git a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilder.cs
index 7fa4dd0ae1ca6934145be6530f1500369071b22a..1d1f05dce7c269ef13d2f25588c290f2a3fa6eef 100644
--- a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilder.cs
+++ b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilder.cs
@@ -3,10 +3,12 @@
 
 using System;
 using System.Collections.Generic;
+using System.Linq;
 using System.Net.Http;
 using Microsoft.AspNetCore.Blazor.Services;
 using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Components.Routing;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection.Extensions;
 using Microsoft.Extensions.Logging;
@@ -14,87 +16,89 @@ using Microsoft.JSInterop;
 
 namespace Microsoft.AspNetCore.Blazor.Hosting
 {
-    //
-    // This code was taken virtually as-is from the Microsoft.Extensions.Hosting project in aspnet/Hosting and then
-    // lots of things were removed.
-    //
-    internal class WebAssemblyHostBuilder : IWebAssemblyHostBuilder
+    /// <summary>
+    /// A builder for configuring and creating a <see cref="WebAssemblyHost"/>.
+    /// </summary>
+    public sealed class WebAssemblyHostBuilder
     {
-        private List<Action<WebAssemblyHostBuilderContext, IServiceCollection>> _configureServicesActions = new List<Action<WebAssemblyHostBuilderContext, IServiceCollection>>();
-        private bool _hostBuilt;
-        private WebAssemblyHostBuilderContext _BrowserHostBuilderContext;
-        private IWebAssemblyServiceFactoryAdapter _serviceProviderFactory = new WebAssemblyServiceFactoryAdapter<IServiceCollection>(new DefaultServiceProviderFactory());
-        private IServiceProvider _appServices;
-
         /// <summary>
-        /// A central location for sharing state between components during the host building process.
+        /// Creates an instance of <see cref="WebAssemblyHostBuilder"/> using the most common
+        /// conventions and settings.
         /// </summary>
-        public IDictionary<object, object> Properties { get; } = new Dictionary<object, object>();
+        /// <param name="args">The argument passed to the application's main method.</param>
+        /// <returns>A <see cref="WebAssemblyHostBuilder"/>.</returns>
+        public static WebAssemblyHostBuilder CreateDefault(string[] args = default)
+        {
+            // We don't use the args for anything right now, but we want to accept them
+            // here so that it shows up this way in the project templates.
+            args ??= Array.Empty<string>();
+            var builder = new WebAssemblyHostBuilder();
+
+            // Right now we don't have conventions or behaviors that are specific to this method
+            // however, making this the default for the template allows us to add things like that
+            // in the future, while giving `new WebAssemblyHostBuilder` as an opt-out of opinionated
+            // settings.
+            return builder;
+        }
 
         /// <summary>
-        /// Adds services to the container. This can be called multiple times and the results will be additive.
+        /// Creates an instance of <see cref="WebAssemblyHostBuilder"/> with the minimal configuration.
         /// </summary>
-        /// <param name="configureDelegate"></param>
-        /// <returns>The same instance of the <see cref="IWebAssemblyHostBuilder"/> for chaining.</returns>
-        public IWebAssemblyHostBuilder ConfigureServices(Action<WebAssemblyHostBuilderContext, IServiceCollection> configureDelegate)
+        private WebAssemblyHostBuilder()
         {
-            _configureServicesActions.Add(configureDelegate ?? throw new ArgumentNullException(nameof(configureDelegate)));
-            return this;
+            // Private right now because we don't have much reason to expose it. This can be exposed
+            // in the future if we want to give people a choice between CreateDefault and something
+            // less opinionated.
+            Configuration = new ConfigurationBuilder();
+            RootComponents = new RootComponentMappingCollection();
+            Services = new ServiceCollection();
+
+            InitializeDefaultServices();
         }
 
         /// <summary>
-        /// Overrides the factory used to create the service provider.
+        /// Gets an <see cref="IConfigurationBuilder"/> that can be used to customize the application's
+        /// configuration sources.
         /// </summary>
-        /// <returns>The same instance of the <see cref="IWebAssemblyHostBuilder"/> for chaining.</returns>
-        public IWebAssemblyHostBuilder UseServiceProviderFactory<TContainerBuilder>(IServiceProviderFactory<TContainerBuilder> factory)
-        {
-            _serviceProviderFactory = new WebAssemblyServiceFactoryAdapter<TContainerBuilder>(factory ?? throw new ArgumentNullException(nameof(factory)));
-            return this;
-        }
+        public IConfigurationBuilder Configuration { get; }
 
         /// <summary>
-        /// Overrides the factory used to create the service provider.
+        /// Gets the collection of root component mappings configured for the application.
         /// </summary>
-        /// <returns>The same instance of the <see cref="IWebAssemblyHostBuilder"/> for chaining.</returns>
-        public IWebAssemblyHostBuilder UseServiceProviderFactory<TContainerBuilder>(Func<WebAssemblyHostBuilderContext, IServiceProviderFactory<TContainerBuilder>> factory)
-        {
-            _serviceProviderFactory = new WebAssemblyServiceFactoryAdapter<TContainerBuilder>(() => _BrowserHostBuilderContext, factory ?? throw new ArgumentNullException(nameof(factory)));
-            return this;
-        }
+        public RootComponentMappingCollection RootComponents { get; }
 
         /// <summary>
-        /// Run the given actions to initialize the host. This can only be called once.
+        /// Gets the service collection.
         /// </summary>
-        /// <returns>An initialized <see cref="IWebAssemblyHost"/></returns>
-        public IWebAssemblyHost Build()
-        {
-            if (_hostBuilt)
-            {
-                throw new InvalidOperationException("Build can only be called once.");
-            }
-            _hostBuilt = true;
+        public IServiceCollection Services { get; }
 
-            CreateBrowserHostBuilderContext();
-            CreateServiceProvider();
+        /// <summary>
+        /// Builds a <see cref="WebAssemblyHost"/> instance based on the configuration of this builder.
+        /// </summary>
+        /// <returns>A <see cref="WebAssemblyHost"/> object.</returns>
+        public WebAssemblyHost Build()
+        {
+            // Intentionally overwrite configuration with the one we're creating.
+            var configuration = Configuration.Build();
+            Services.AddSingleton<IConfiguration>(configuration);
 
-            return _appServices.GetRequiredService<IWebAssemblyHost>();
-        }
+            // A Blazor application always runs in a scope. Since we want to make it possible for the user
+            // to configure services inside *that scope* inside their startup code, we create *both* the
+            // service provider and the scope here.
+            var services = Services.BuildServiceProvider();
+            var scope = services.GetRequiredService<IServiceScopeFactory>().CreateScope();
 
-        private void CreateBrowserHostBuilderContext()
-        {
-            _BrowserHostBuilderContext = new WebAssemblyHostBuilderContext(Properties);
+            return new WebAssemblyHost(services, scope, configuration, RootComponents.ToArray());
         }
 
-        private void CreateServiceProvider()
+        private void InitializeDefaultServices()
         {
-            var services = new ServiceCollection();
-            services.AddSingleton(_BrowserHostBuilderContext);
-            services.AddSingleton<IWebAssemblyHost, WebAssemblyHost>();
-            services.AddSingleton<IJSRuntime>(WebAssemblyJSRuntime.Instance);
-            services.AddSingleton<NavigationManager>(WebAssemblyNavigationManager.Instance);
-            services.AddSingleton<INavigationInterception>(WebAssemblyNavigationInterception.Instance);
-            services.AddSingleton<ILoggerFactory, WebAssemblyLoggerFactory>();
-            services.AddSingleton<HttpClient>(s =>
+            Services.AddSingleton<IJSRuntime>(WebAssemblyJSRuntime.Instance);
+            Services.AddSingleton<NavigationManager>(WebAssemblyNavigationManager.Instance);
+            Services.AddSingleton<INavigationInterception>(WebAssemblyNavigationInterception.Instance);
+            Services.AddSingleton<ILoggerFactory, WebAssemblyLoggerFactory>();
+            Services.TryAdd(ServiceDescriptor.Singleton(typeof(ILogger<>), typeof(WebAssemblyConsoleLogger<>)));
+            Services.AddSingleton<HttpClient>(s =>
             {
                 // Creating the URI helper needs to wait until the JS Runtime is initialized, so defer it.
                 var navigationManager = s.GetRequiredService<NavigationManager>();
@@ -103,20 +107,6 @@ namespace Microsoft.AspNetCore.Blazor.Hosting
                     BaseAddress = new Uri(navigationManager.BaseUri)
                 };
             });
-
-            // Needed for authorization
-            // However, since authorization isn't on by default, we could consider removing these and
-            // having a separate services.AddBlazorAuthorization() call that brings in the required services.
-            services.AddOptions();
-            services.TryAdd(ServiceDescriptor.Singleton(typeof(ILogger<>), typeof(WebAssemblyConsoleLogger<>)));
-
-            foreach (var configureServicesAction in _configureServicesActions)
-            {
-                configureServicesAction(_BrowserHostBuilderContext, services);
-            }
-
-            var builder = _serviceProviderFactory.CreateBuilder(services);
-            _appServices = _serviceProviderFactory.CreateServiceProvider(builder);
         }
     }
 }
diff --git a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilderContext.cs b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilderContext.cs
deleted file mode 100644
index c7b7dd6f1989a0b839877c76ee6117534b916e72..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilderContext.cs
+++ /dev/null
@@ -1,27 +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.Collections.Generic;
-
-namespace Microsoft.AspNetCore.Blazor.Hosting
-{
-    /// <summary>
-    /// Context containing the common services on the <see cref="IWebAssemblyHost" />. Some properties may be null until set by the <see cref="IWebAssemblyHost" />.
-    /// </summary>
-    public sealed class WebAssemblyHostBuilderContext
-    {
-        /// <summary>
-        /// Creates a new <see cref="WebAssemblyHostBuilderContext" />.
-        /// </summary>
-        /// <param name="properties">The property collection.</param>
-        public WebAssemblyHostBuilderContext(IDictionary<object, object> properties)
-        {
-            Properties = properties ?? throw new System.ArgumentNullException(nameof(properties));
-        }
-
-        /// <summary>
-        /// A central location for sharing state between components during the host building process.
-        /// </summary>
-        public IDictionary<object, object> Properties { get; }
-    }
-}
\ No newline at end of file
diff --git a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilderExtensions.cs b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilderExtensions.cs
deleted file mode 100644
index 9b03c097665a9d0f92df5ba359dda1714adf86de..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilderExtensions.cs
+++ /dev/null
@@ -1,73 +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.DependencyInjection;
-
-namespace Microsoft.AspNetCore.Blazor.Hosting
-{
-    /// <summary>
-    /// Provides Blazor-specific support for <see cref="IWebAssemblyHost"/>.
-    /// </summary>
-    public static class WebAssemblyHostBuilderExtensions
-    {
-        private const string BlazorStartupKey = "Blazor.Startup";
-
-        /// <summary>
-        /// Adds services to the container. This can be called multiple times and the results will be additive.
-        /// </summary>
-        /// <param name="hostBuilder">The <see cref="IWebAssemblyHostBuilder" /> to configure.</param>
-        /// <param name="configureDelegate"></param>
-        /// <returns>The same instance of the <see cref="IWebAssemblyHostBuilder"/> for chaining.</returns>
-        public static IWebAssemblyHostBuilder ConfigureServices(this IWebAssemblyHostBuilder hostBuilder, Action<IServiceCollection> configureDelegate)
-        {
-            return hostBuilder.ConfigureServices((context, collection) => configureDelegate(collection));
-        }
-
-        /// <summary>
-        /// Configures the <see cref="IWebAssemblyHostBuilder"/> to use the provided startup class.
-        /// </summary>
-        /// <param name="builder">The <see cref="IWebAssemblyHostBuilder"/>.</param>
-        /// <param name="startupType">A type that configures a Blazor application.</param>
-        /// <returns>The <see cref="IWebAssemblyHostBuilder"/>.</returns>
-        public static IWebAssemblyHostBuilder UseBlazorStartup(this IWebAssemblyHostBuilder builder, Type startupType)
-        {
-            if (builder == null)
-            {
-                throw new ArgumentNullException(nameof(builder));
-            }
-
-            if (builder.Properties.ContainsKey(BlazorStartupKey))
-            {
-                throw new InvalidOperationException("A startup class has already been registered.");
-            }
-
-            // It would complicate the implementation to allow multiple startup classes, and we don't
-            // really have a need for it.
-            builder.Properties.Add(BlazorStartupKey, bool.TrueString);
-
-            var startup = new ConventionBasedStartup(Activator.CreateInstance(startupType));
-
-            builder.ConfigureServices(startup.ConfigureServices);
-            builder.ConfigureServices(s => s.AddSingleton<IBlazorStartup>(startup));
-
-            return builder;
-        }
-
-        /// <summary>
-        /// Configures the <see cref="IWebAssemblyHostBuilder"/> to use the provided startup class.
-        /// </summary>
-        /// <typeparam name="TStartup">A type that configures a Blazor application.</typeparam>
-        /// <param name="builder">The <see cref="IWebAssemblyHostBuilder"/>.</param>
-        /// <returns>The <see cref="IWebAssemblyHostBuilder"/>.</returns>
-        public static IWebAssemblyHostBuilder UseBlazorStartup<TStartup>(this IWebAssemblyHostBuilder builder)
-        {
-            if (builder == null)
-            {
-                throw new ArgumentNullException(nameof(builder));
-            }
-
-            return UseBlazorStartup(builder, typeof(TStartup));
-        }
-    }
-}
diff --git a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostExtensions.cs b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostExtensions.cs
deleted file mode 100644
index d08162a590ec890622ee7e63ec177ad3447e4892..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostExtensions.cs
+++ /dev/null
@@ -1,36 +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;
-
-namespace Microsoft.AspNetCore.Blazor.Hosting
-{
-    /// <summary>
-    /// Extension methods for <see cref="IWebAssemblyHost"/>.
-    /// </summary>
-    public static class WebAssemblyHostExtensions
-    {
-        /// <summary>
-        /// Runs the application.
-        /// </summary>
-        /// <param name="host">The <see cref="IWebAssemblyHost"/> to run.</param>
-        /// <remarks>
-        /// Currently, Blazor applications running in the browser don't have a lifecycle - the application does not
-        /// get a chance to gracefully shut down. For now, <see cref="Run(IWebAssemblyHost)"/> simply starts the host
-        /// and allows execution to continue.
-        /// </remarks>
-        public static void Run(this IWebAssemblyHost host)
-        {
-            // Behave like async void, because we don't yet support async-main properly on WebAssembly.
-            // However, don't actualy make this method async, because we rely on startup being synchronous
-            // for things like attaching navigation event handlers.
-            host.StartAsync().ContinueWith(task =>
-            {
-                if (task.Exception != null)
-                {
-                    Console.WriteLine(task.Exception);
-                }
-            });
-        }
-    }
-}
diff --git a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyServiceFactoryAdapter.cs b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyServiceFactoryAdapter.cs
deleted file mode 100644
index fcc879653abb5dbaba4d2766d0f3547c62e97ac6..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyServiceFactoryAdapter.cs
+++ /dev/null
@@ -1,52 +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.DependencyInjection;
-
-namespace Microsoft.AspNetCore.Blazor.Hosting
-{
-    // Equivalent to https://github.com/aspnet/Extensions/blob/master/src/Hosting/Hosting/src/Internal/ServiceFactoryAdapter.cs
-
-    internal class WebAssemblyServiceFactoryAdapter<TContainerBuilder> : IWebAssemblyServiceFactoryAdapter
-    {
-        private IServiceProviderFactory<TContainerBuilder> _serviceProviderFactory;
-        private readonly Func<WebAssemblyHostBuilderContext> _contextResolver;
-        private Func<WebAssemblyHostBuilderContext, IServiceProviderFactory<TContainerBuilder>> _factoryResolver;
-
-        public WebAssemblyServiceFactoryAdapter(IServiceProviderFactory<TContainerBuilder> serviceProviderFactory)
-        {
-            _serviceProviderFactory = serviceProviderFactory ?? throw new ArgumentNullException(nameof(serviceProviderFactory));
-        }
-
-        public WebAssemblyServiceFactoryAdapter(Func<WebAssemblyHostBuilderContext> contextResolver, Func<WebAssemblyHostBuilderContext, IServiceProviderFactory<TContainerBuilder>> factoryResolver)
-        {
-            _contextResolver = contextResolver ?? throw new ArgumentNullException(nameof(contextResolver));
-            _factoryResolver = factoryResolver ?? throw new ArgumentNullException(nameof(factoryResolver));
-        }
-
-        public object CreateBuilder(IServiceCollection services)
-        {
-            if (_serviceProviderFactory == null)
-            {
-                _serviceProviderFactory = _factoryResolver(_contextResolver());
-
-                if (_serviceProviderFactory == null)
-                {
-                    throw new InvalidOperationException("The resolver returned a null IServiceProviderFactory");
-                }
-            }
-            return _serviceProviderFactory.CreateBuilder(services);
-        }
-
-        public IServiceProvider CreateServiceProvider(object containerBuilder)
-        {
-            if (_serviceProviderFactory == null)
-            {
-                throw new InvalidOperationException("CreateBuilder must be called before CreateServiceProvider");
-            }
-
-            return _serviceProviderFactory.CreateServiceProvider((TContainerBuilder)containerBuilder);
-        }
-    }
-}
diff --git a/src/Components/Blazor/Blazor/src/Microsoft.AspNetCore.Blazor.csproj b/src/Components/Blazor/Blazor/src/Microsoft.AspNetCore.Blazor.csproj
index e98ef09268506c17c34bd464554da8a4f2fadfc5..3a4e98a8b7e21b94394ca9c0fd84a8235020fd7a 100644
--- a/src/Components/Blazor/Blazor/src/Microsoft.AspNetCore.Blazor.csproj
+++ b/src/Components/Blazor/Blazor/src/Microsoft.AspNetCore.Blazor.csproj
@@ -1,7 +1,7 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
-    <TargetFramework>netstandard2.0</TargetFramework>
+    <TargetFramework>netstandard2.1</TargetFramework>
     <Description>Build client-side single-page applications (SPAs) with Blazor running under WebAssembly.</Description>
     <IsShippingPackage>false</IsShippingPackage>
   </PropertyGroup>
@@ -9,7 +9,7 @@
   <ItemGroup>
     <Reference Include="Mono.WebAssembly.Interop" />
     <Reference Include="Microsoft.AspNetCore.Components.Web" />
-    <Reference Include="Microsoft.Extensions.Options" />
+    <Reference Include="Microsoft.Extensions.Configuration" />
   </ItemGroup>
 
   <ItemGroup>
diff --git a/src/Components/Blazor/Blazor/test/Hosting/ConventionBasedStartupTest.cs b/src/Components/Blazor/Blazor/test/Hosting/ConventionBasedStartupTest.cs
deleted file mode 100644
index cbc73b79f832dbfc43045bce37cbf31ba1d42072..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Blazor/test/Hosting/ConventionBasedStartupTest.cs
+++ /dev/null
@@ -1,210 +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 System.Collections.Generic;
-using Microsoft.AspNetCore.Blazor.Hosting;
-using Microsoft.AspNetCore.Components.Builder;
-using Microsoft.Extensions.DependencyInjection;
-using Xunit;
-
-namespace Microsoft.AspNetCore.Components.Hosting
-{
-    public class ConventionBasedStartupTest
-    {
-        [Fact]
-        public void ConventionBasedStartup_GetConfigureServicesMethod_FindsConfigureServices()
-        {
-            // Arrange
-            var startup = new ConventionBasedStartup(new MyStartup1());
-
-            // Act
-            var method = startup.GetConfigureServicesMethod();
-
-            // Assert
-            Assert.Equal(typeof(IServiceCollection), method.GetParameters()[0].ParameterType);
-        }
-
-        private class MyStartup1
-        {
-            public void ConfigureServices(IServiceCollection services)
-            {
-            }
-
-            // Ignored
-            public void ConfigureServices(DateTime x)
-            {
-
-            }
-
-            // Ignored
-            private void ConfigureServices(int x)
-            {
-            }
-
-            // Ignored
-            public static void ConfigureServices(string x)
-            {
-            }
-        }
-
-        [Fact]
-        public void ConventionBasedStartup_GetConfigureServicesMethod_NoMethodFound()
-        {
-            // Arrange
-            var startup = new ConventionBasedStartup(new MyStartup2());
-
-            // Act
-            var method = startup.GetConfigureServicesMethod();
-
-            // Assert
-            Assert.Null(method);
-        }
-
-        private class MyStartup2
-        {
-        }
-
-        [Fact]
-        public void ConventionBasedStartup_ConfigureServices_CallsMethod()
-        {
-            // Arrange
-            var startup = new ConventionBasedStartup(new MyStartup3());
-            var services = new ServiceCollection();
-
-            // Act
-            startup.ConfigureServices(services);
-
-            // Assert
-            Assert.NotEmpty(services);
-        }
-
-        private class MyStartup3
-        {
-            public void ConfigureServices(IServiceCollection services)
-            {
-                services.AddSingleton("foo");
-            }
-        }
-
-        [Fact]
-        public void ConventionBasedStartup_ConfigureServices_NoMethodFound()
-        {
-            // Arrange
-            var startup = new ConventionBasedStartup(new MyStartup4());
-            var services = new ServiceCollection();
-
-            // Act
-            startup.ConfigureServices(services);
-
-            // Assert
-            Assert.Empty(services);
-        }
-
-        private class MyStartup4
-        {
-        }
-
-        [Fact]
-        public void ConventionBasedStartup_GetConfigureMethod_FindsConfigure()
-        {
-            // Arrange
-            var startup = new ConventionBasedStartup(new MyStartup5());
-
-            // Act
-            var method = startup.GetConfigureMethod();
-
-            // Assert
-            Assert.Empty(method.GetParameters());
-        }
-
-        private class MyStartup5
-        {
-            public void Configure()
-            {
-            }
-
-            // Ignored
-            private void Configure(int x)
-            {
-            }
-
-            // Ignored
-            public static void Configure(string x)
-            {
-            }
-        }
-
-        [Fact]
-        public void ConventionBasedStartup_GetConfigureMethod_NoMethodFoundThrows()
-        {
-            // Arrange
-            var startup = new ConventionBasedStartup(new MyStartup6());
-
-            // Act
-            var ex = Assert.Throws<InvalidOperationException>(() => startup.GetConfigureMethod());
-
-            // Assert
-            Assert.Equal("The startup class must define a 'Configure' method.", ex.Message);
-        }
-
-        private class MyStartup6
-        {
-        }
-
-        [Fact]
-        public void ConventionBasedStartup_GetConfigureMethod_OverloadedThrows()
-        {
-            // Arrange
-            var startup = new ConventionBasedStartup(new MyStartup7());
-
-            // Act
-            var ex = Assert.Throws<InvalidOperationException>(() => startup.GetConfigureMethod());
-
-            // Assert
-            Assert.Equal("Overloading the 'Configure' method is not supported.", ex.Message);
-        }
-
-        private class MyStartup7
-        {
-            public void Configure()
-            {
-            }
-
-            public void Configure(string x)
-            {
-            }
-        }
-
-        [Fact]
-        public void ConventionBasedStartup_Configure()
-        {
-            // Arrange
-            var instance = new MyStartup8();
-            var startup = new ConventionBasedStartup(instance);
-
-            var services = new ServiceCollection().AddSingleton("foo").BuildServiceProvider();
-            var builder = new WebAssemblyBlazorApplicationBuilder(services);
-
-            // Act
-            startup.Configure(builder, services);
-
-            // Assert
-            Assert.Collection(
-                instance.Arguments,
-                a => Assert.Same(builder, a),
-                a => Assert.Equal("foo", a));
-        }
-
-        private class MyStartup8
-        {
-            public List<object> Arguments { get; } = new List<object>();
-
-            public void Configure(IComponentsApplicationBuilder app, string foo)
-            {
-                Arguments.Add(app);
-                Arguments.Add(foo);
-            }
-        }
-    }
-}
diff --git a/src/Components/Blazor/Blazor/test/Hosting/EntrypointInvokerTest.cs b/src/Components/Blazor/Blazor/test/Hosting/EntrypointInvokerTest.cs
new file mode 100644
index 0000000000000000000000000000000000000000..60a3d1638b8f71cb465ddba9e1e652874a069542
--- /dev/null
+++ b/src/Components/Blazor/Blazor/test/Hosting/EntrypointInvokerTest.cs
@@ -0,0 +1,153 @@
+// 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.IO;
+using System.Reflection;
+using System.Runtime.Loader;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Blazor.Hosting
+{
+    public class EntrypointInvokerTest
+    {
+        [Theory]
+        [InlineData(false, false)]
+        [InlineData(false, true)]
+        [InlineData(true, false)]
+        [InlineData(true, true)]
+        public void InvokesEntrypoint_Sync_Success(bool hasReturnValue, bool hasParams)
+        {
+            // Arrange
+            var returnType = hasReturnValue ? "int" : "void";
+            var paramsDecl = hasParams ? "string[] args" : string.Empty;
+            var returnStatement = hasReturnValue ? "return 123;" : "return;";
+            var assembly = CompileToAssembly(@"
+static " + returnType + @" Main(" + paramsDecl + @")
+{
+    DidMainExecute = true;
+    " + returnStatement + @"
+}", out var didMainExecute);
+
+            // Act
+            EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
+
+            // Assert
+            Assert.True(didMainExecute());
+        }
+
+        [Theory]
+        [InlineData(false, false)]
+        [InlineData(false, true)]
+        [InlineData(true, false)]
+        [InlineData(true, true)]
+        public void InvokesEntrypoint_Async_Success(bool hasReturnValue, bool hasParams)
+        {
+            // Arrange
+            var returnTypeGenericParam = hasReturnValue ? "<int>" : string.Empty;
+            var paramsDecl = hasParams ? "string[] args" : string.Empty;
+            var returnStatement = hasReturnValue ? "return 123;" : "return;";
+            var assembly = CompileToAssembly(@"
+public static TaskCompletionSource<object> ContinueTcs { get; } = new TaskCompletionSource<object>();
+
+static async Task" + returnTypeGenericParam + @" Main(" + paramsDecl + @")
+{
+    await ContinueTcs.Task;
+    DidMainExecute = true;
+    " + returnStatement + @"
+}", out var didMainExecute);
+
+            // Act/Assert 1: Waits for task
+            // The fact that we're not blocking here proves that we're not executing the
+            // metadata-declared entrypoint, as that would block
+            EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
+            Assert.False(didMainExecute());
+
+            // Act/Assert 2: Continues
+            var tcs = (TaskCompletionSource<object>)assembly.GetType("SomeApp.Program").GetProperty("ContinueTcs").GetValue(null);
+            tcs.SetResult(null);
+            Assert.True(didMainExecute());
+        }
+
+        [Fact]
+        public void InvokesEntrypoint_Sync_Exception()
+        {
+            // Arrange
+            var assembly = CompileToAssembly(@"
+public static void Main()
+{
+    DidMainExecute = true;
+    throw new InvalidTimeZoneException(""Test message"");
+}", out var didMainExecute);
+
+            // Act/Assert
+            // The fact that this doesn't throw shows that EntrypointInvoker is doing something
+            // to handle the exception. We can't assert about what it does here, because that
+            // would involve capturing console output, which isn't safe in unit tests. Instead
+            // we'll check this in E2E tests.
+            EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
+            Assert.True(didMainExecute());
+        }
+
+        [Fact]
+        public void InvokesEntrypoint_Async_Exception()
+        {
+            // Arrange
+            var assembly = CompileToAssembly(@"
+public static TaskCompletionSource<object> ContinueTcs { get; } = new TaskCompletionSource<object>();
+
+public static async Task Main()
+{
+    await ContinueTcs.Task;
+    DidMainExecute = true;
+    throw new InvalidTimeZoneException(""Test message"");
+}", out var didMainExecute);
+
+            // Act/Assert 1: Waits for task
+            EntrypointInvoker.InvokeEntrypoint(assembly.FullName, new string[] { });
+            Assert.False(didMainExecute());
+
+            // Act/Assert 2: Continues
+            // As above, we can't directly observe the exception handling behavior here,
+            // so this is covered in E2E tests instead.
+            var tcs = (TaskCompletionSource<object>)assembly.GetType("SomeApp.Program").GetProperty("ContinueTcs").GetValue(null);
+            tcs.SetResult(null);
+            Assert.True(didMainExecute());
+        }
+
+        private static Assembly CompileToAssembly(string mainMethod, out Func<bool> didMainExecute)
+        {
+            var syntaxTree = CSharpSyntaxTree.ParseText(@"
+using System;
+using System.Threading.Tasks;
+
+namespace SomeApp
+{
+    public static class Program
+    {
+        public static bool DidMainExecute { get; private set; }
+
+        " + mainMethod + @"
+    }
+}");
+
+            var compilation = CSharpCompilation.Create(
+                $"TestAssembly-{Guid.NewGuid().ToString("D")}",
+                new[] { syntaxTree },
+                new[] { MetadataReference.CreateFromFile(typeof(object).GetTypeInfo().Assembly.Location) },
+                new CSharpCompilationOptions(OutputKind.ConsoleApplication));
+            using var ms = new MemoryStream();
+            var compilationResult = compilation.Emit(ms);
+            ms.Seek(0, SeekOrigin.Begin);
+            var assembly = AssemblyLoadContext.Default.LoadFromStream(ms);
+
+            var didMainExecuteProp = assembly.GetType("SomeApp.Program").GetProperty("DidMainExecute");
+            didMainExecute = () => (bool)didMainExecuteProp.GetValue(null); 
+
+            return assembly;
+        }
+    }
+}
diff --git a/src/Components/Blazor/Blazor/test/Hosting/RootComponentMappingTest.cs b/src/Components/Blazor/Blazor/test/Hosting/RootComponentMappingTest.cs
new file mode 100644
index 0000000000000000000000000000000000000000..7249402880711ce771c6d63efd7a12918aced71e
--- /dev/null
+++ b/src/Components/Blazor/Blazor/test/Hosting/RootComponentMappingTest.cs
@@ -0,0 +1,37 @@
+// 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.Text;
+using Microsoft.AspNetCore.Components.Routing;
+using Microsoft.AspNetCore.Testing;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Blazor.Hosting
+{
+    public class RootComponentMappingTest
+    {
+        [Fact]
+        public void Constructor_ValidatesComponentType_Success()
+        {
+            // Arrange
+            // Act
+            var mapping = new RootComponentMapping(typeof(Router), "test");
+
+            // Assert (does not throw)
+            GC.KeepAlive(mapping);
+        }
+
+        [Fact]
+        public void Constructor_ValidatesComponentType_Failure()
+        {
+            // Arrange
+            // Act & Assert
+            ExceptionAssert.ThrowsArgument(
+                () => new RootComponentMapping(typeof(StringBuilder), "test"),
+                "componentType",
+                $"The type '{nameof(StringBuilder)}' must implement IComponent to be used as a root component.");
+        }
+    }
+}
diff --git a/src/Components/Blazor/Blazor/test/Hosting/WebAssemblyHostBuilderTest.cs b/src/Components/Blazor/Blazor/test/Hosting/WebAssemblyHostBuilderTest.cs
index 4acf6f99a30e8c28abb18afa1bb477ad734a443c..77b6583d260e303c8781361750266c9577817507 100644
--- a/src/Components/Blazor/Blazor/test/Hosting/WebAssemblyHostBuilderTest.cs
+++ b/src/Components/Blazor/Blazor/test/Hosting/WebAssemblyHostBuilderTest.cs
@@ -3,160 +3,100 @@
 
 using System;
 using System.Collections.Generic;
+using System.Net.Http;
 using System.Text;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Routing;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
 using Microsoft.JSInterop;
 using Xunit;
 
-namespace Microsoft.AspNetCore.Blazor.Hosting.Test
+namespace Microsoft.AspNetCore.Blazor.Hosting
 {
     public class WebAssemblyHostBuilderTest
     {
         [Fact]
-        public void HostBuilder_CanCallBuild_BuildsServices()
+        public void Build_AllowsConfiguringConfiguration()
         {
             // Arrange
-            var builder = new WebAssemblyHostBuilder();
+            var builder = WebAssemblyHostBuilder.CreateDefault();
 
-            // Act
-            var host = builder.Build();
-
-            // Assert
-            Assert.NotNull(host.Services.GetService(typeof(IWebAssemblyHost)));
-        }
-
-        [Fact]
-        public void HostBuilder_CanConfigureAdditionalServices()
-        {
-            // Arrange
-            var builder = new WebAssemblyHostBuilder();
-            builder.ConfigureServices((c, s) => s.AddSingleton<string>("foo"));
-            builder.ConfigureServices((c, s) => s.AddSingleton<StringBuilder>(new StringBuilder("bar")));
+            builder.Configuration.AddInMemoryCollection(new[]
+            {
+                new KeyValuePair<string, string>("key", "value"),
+            });
 
             // Act
             var host = builder.Build();
 
             // Assert
-            Assert.Equal("foo", host.Services.GetService(typeof(string)));
-            Assert.Equal("bar", host.Services.GetService(typeof(StringBuilder)).ToString());
+            Assert.Equal("value", host.Configuration["key"]);
         }
 
-        [Fact]
-        public void HostBuilder_UseBlazorStartup_CanConfigureAdditionalServices()
+        [Fact] 
+        public void Build_AllowsConfiguringServices()
         {
             // Arrange
-            var builder = new WebAssemblyHostBuilder();
-            builder.UseBlazorStartup<MyStartup>();
-            builder.ConfigureServices((c, s) => s.AddSingleton<StringBuilder>(new StringBuilder("bar")));
+            var builder = WebAssemblyHostBuilder.CreateDefault();
 
-            // Act
-            var host = builder.Build();
-
-            // Assert
-            Assert.Equal("foo", host.Services.GetService(typeof(string)));
-            Assert.Equal("bar", host.Services.GetService(typeof(StringBuilder)).ToString());
-        }
-
-        [Fact]
-        public void HostBuilder_UseBlazorStartup_DoesNotAllowMultiple()
-        {
-            // Arrange
-            var builder = new WebAssemblyHostBuilder();
-            builder.UseBlazorStartup<MyStartup>();
-
-            // Act
-            var ex = Assert.Throws<InvalidOperationException>(() => builder.UseBlazorStartup<MyStartup>());
-
-            // Assert
-            Assert.Equal("A startup class has already been registered.", ex.Message);
-        }
-
-        private class MyStartup
-        {
-            public void ConfigureServices(IServiceCollection services)
-            {
-                services.AddSingleton<string>("foo");
-            }
-        }
-
-        [Fact]
-        public void HostBuilder_CanCustomizeServiceFactory()
-        {
-            // Arrange
-            var builder = new WebAssemblyHostBuilder();
-            builder.UseServiceProviderFactory(new TestServiceProviderFactory());
+            // This test also verifies that we create a scope.
+            builder.Services.AddScoped<StringBuilder>();
 
             // Act
             var host = builder.Build();
 
             // Assert
-            Assert.IsType<TestServiceProvider>(host.Services);
+            Assert.NotNull(host.Services.GetRequiredService<StringBuilder>());
         }
 
         [Fact]
-        public void HostBuilder_CanCustomizeServiceFactoryWithContext()
+        public void Build_AddsConfigurationToServices()
         {
             // Arrange
-            var builder = new WebAssemblyHostBuilder();
-            builder.UseServiceProviderFactory(context =>
+            var builder = WebAssemblyHostBuilder.CreateDefault();
+
+            builder.Configuration.AddInMemoryCollection(new[]
             {
-                Assert.NotNull(context.Properties);
-                Assert.Same(builder.Properties, context.Properties);
-                return new TestServiceProviderFactory();
+                new KeyValuePair<string, string>("key", "value"),
             });
 
             // Act
             var host = builder.Build();
 
             // Assert
-            Assert.IsType<TestServiceProvider>(host.Services);
+            var configuration = host.Services.GetRequiredService<IConfiguration>();
+            Assert.Equal("value", configuration["key"]);
         }
 
-        private class TestServiceProvider : IServiceProvider
+        private static IReadOnlyList<Type> DefaultServiceTypes
         {
-            private readonly IServiceProvider _underlyingProvider;
-
-            public TestServiceProvider(IServiceProvider underlyingProvider)
+            get
             {
-                _underlyingProvider = underlyingProvider;
-            }
-
-            public object GetService(Type serviceType)
-            {
-                if (serviceType == typeof(IWebAssemblyHost))
+                return new Type[]
                 {
-                    // Since the test will make assertions about the resulting IWebAssemblyHost,
-                    // show that custom DI containers have the power to substitute themselves
-                    // as the IServiceProvider
-                    return new WebAssemblyHost(
-                        this, _underlyingProvider.GetRequiredService<IJSRuntime>());
-                }
-                else
-                {
-                    return _underlyingProvider.GetService(serviceType);
-                }
+                    typeof(IJSRuntime),
+                    typeof(NavigationManager),
+                    typeof(INavigationInterception),
+                    typeof(ILoggerFactory),
+                    typeof(HttpClient),
+                    typeof(ILogger<>),
+                };
             }
         }
 
-        private class TestServiceProviderFactory : IServiceProviderFactory<IServiceCollection>
+        [Fact]
+        public void Constructor_AddsDefaultServices()
         {
-            public IServiceCollection CreateBuilder(IServiceCollection services)
-            {
-                return new TestServiceCollection(services);
-            }
-
-            public IServiceProvider CreateServiceProvider(IServiceCollection serviceCollection)
-            {
-                Assert.IsType<TestServiceCollection>(serviceCollection);
-                return new TestServiceProvider(serviceCollection.BuildServiceProvider());
-            }
+            // Arrange & Act
+            var builder = WebAssemblyHostBuilder.CreateDefault();
 
-            class TestServiceCollection : List<ServiceDescriptor>, IServiceCollection
+            // Assert
+            Assert.Equal(DefaultServiceTypes.Count, builder.Services.Count);
+            foreach (var type in DefaultServiceTypes)
             {
-                public TestServiceCollection(IEnumerable<ServiceDescriptor> collection)
-                    : base(collection)
-                {
-                }
+                Assert.Single(builder.Services, d => d.ServiceType == type);
             }
         }
     }
diff --git a/src/Components/Blazor/Blazor/test/Hosting/WebAssemblyHostTest.cs b/src/Components/Blazor/Blazor/test/Hosting/WebAssemblyHostTest.cs
index f99245e317d57821e6e85c87639a7dd6321f9be0..b838334566082d1ca5e716414f6a75e09c701e35 100644
--- a/src/Components/Blazor/Blazor/test/Hosting/WebAssemblyHostTest.cs
+++ b/src/Components/Blazor/Blazor/test/Hosting/WebAssemblyHostTest.cs
@@ -2,64 +2,89 @@
 // 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.Components.Builder;
-using Microsoft.AspNetCore.Components.Hosting;
+using Microsoft.AspNetCore.Testing;
 using Microsoft.Extensions.DependencyInjection;
-using Microsoft.JSInterop;
-using Mono.WebAssembly.Interop;
 using Xunit;
 
-namespace Microsoft.AspNetCore.Blazor.Hosting.Test
+namespace Microsoft.AspNetCore.Blazor.Hosting
 {
     public class WebAssemblyHostTest
     {
-        [Fact]
-        public async Task BrowserHost_StartAsync_ThrowsWithoutStartup()
+        // This won't happen in the product code, but we need to be able to safely call RunAsync
+        // to be able to test a few of the other details.
+        [Fact] 
+        public async Task RunAsync_CanExitBasedOnCancellationToken()
         {
             // Arrange
-            var builder = new WebAssemblyHostBuilder();
+            var builder = WebAssemblyHostBuilder.CreateDefault();
             var host = builder.Build();
 
+            var cts = new CancellationTokenSource();
+
             // Act
-            var ex = await Assert.ThrowsAsync<InvalidOperationException>(async () => await host.StartAsync());
+            var task = host.RunAsyncCore(cts.Token);
 
-            // Assert
-            Assert.Equal(
-                "Could not find a registered Blazor Startup class. " +
-                "Using IWebAssemblyHost requires a call to IWebAssemblyHostBuilder.UseBlazorStartup.",
-                ex.Message);
+            cts.Cancel();
+            await task.TimeoutAfter(TimeSpan.FromSeconds(3));
+
+            // Assert (does not throw)
         }
 
         [Fact]
-        public async Task BrowserHost_StartAsync_RunsConfigureMethod()
+        public async Task RunAsync_CallingTwiceCausesException()
         {
             // Arrange
-            var builder = new WebAssemblyHostBuilder();
-
-            var startup = new MockStartup();
-            builder.ConfigureServices((c, s) => { s.AddSingleton<IBlazorStartup>(startup); });
-
+            var builder = WebAssemblyHostBuilder.CreateDefault();
             var host = builder.Build();
 
+            var cts = new CancellationTokenSource();
+            var task = host.RunAsyncCore(cts.Token);
+
             // Act
-            await host.StartAsync();
+            var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => host.RunAsyncCore(cts.Token));
+
+            cts.Cancel();
+            await task.TimeoutAfter(TimeSpan.FromSeconds(3));
 
             // Assert
-            Assert.True(startup.ConfigureCalled);
+            Assert.Equal("The host has already started.", ex.Message);
         }
 
-        private class MockStartup : IBlazorStartup
+        [Fact]
+        public async Task DisposeAsync_CanDisposeAfterCallingRunAsync()
         {
-            public bool ConfigureCalled { get; set; }
+            // Arrange
+            var builder = WebAssemblyHostBuilder.CreateDefault();
+            builder.Services.AddSingleton<DisposableService>();
+            var host = builder.Build();
+
+            var disposable = host.Services.GetRequiredService<DisposableService>();
+
+            var cts = new CancellationTokenSource();
 
-            public void Configure(IComponentsApplicationBuilder app, IServiceProvider services)
+            // Act
+            await using (host)
             {
-                ConfigureCalled = true;
+                var task = host.RunAsyncCore(cts.Token);
+
+                cts.Cancel();
+                await task.TimeoutAfter(TimeSpan.FromSeconds(3));
             }
 
-            public void ConfigureServices(IServiceCollection services)
+            // Assert
+            Assert.Equal(1, disposable.DisposeCount);
+        }
+
+        private class DisposableService : IAsyncDisposable
+        {
+            public int DisposeCount { get; private set; }
+
+            public ValueTask DisposeAsync()
             {
+                DisposeCount++;
+                return new ValueTask(Task.CompletedTask);
             }
         }
     }
diff --git a/src/Components/Blazor/Blazor/test/Microsoft.AspNetCore.Blazor.Tests.csproj b/src/Components/Blazor/Blazor/test/Microsoft.AspNetCore.Blazor.Tests.csproj
index c93519dfbd7c25e6d88b09ccfd53c03e01330369..40c5a5b7025edfeef5469861f33c580d6c100bd4 100644
--- a/src/Components/Blazor/Blazor/test/Microsoft.AspNetCore.Blazor.Tests.csproj
+++ b/src/Components/Blazor/Blazor/test/Microsoft.AspNetCore.Blazor.Tests.csproj
@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
diff --git a/src/Components/Blazor/Build/src/Cli/Commands/ResolveRuntimeDependenciesCommand.cs b/src/Components/Blazor/Build/src/Cli/Commands/ResolveRuntimeDependenciesCommand.cs
deleted file mode 100644
index d5d37bb83390e6d1369e0066f0cc19c64bd72a4b..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Build/src/Cli/Commands/ResolveRuntimeDependenciesCommand.cs
+++ /dev/null
@@ -1,61 +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 System.IO;
-using Microsoft.Extensions.CommandLineUtils;
-
-namespace Microsoft.AspNetCore.Blazor.Build.DevServer.Commands
-{
-    class ResolveRuntimeDependenciesCommand
-    {
-        public static void Command(CommandLineApplication command)
-        {
-            var referencesFile = command.Option("--references",
-                "The path to a file that lists the paths to given referenced dll files",
-                CommandOptionType.SingleValue);
-
-            var baseClassLibrary = command.Option("--base-class-library",
-                "Full path to a directory in which BCL assemblies can be found",
-                CommandOptionType.MultipleValue);
-
-            var outputPath = command.Option("--output",
-                "Path to the output file that will contain the list with the full paths of the resolved assemblies",
-                CommandOptionType.SingleValue);
-
-            var mainAssemblyPath = command.Argument("assembly",
-                "Path to the assembly containing the entry point of the application.");
-
-            command.OnExecute(() =>
-            {
-                if (string.IsNullOrEmpty(mainAssemblyPath.Value) ||
-                    !baseClassLibrary.HasValue() || !outputPath.HasValue())
-                {
-                    command.ShowHelp(command.Name);
-                    return 1;
-                }
-
-                try
-                {
-                    var referencesSources = referencesFile.HasValue()
-                        ? File.ReadAllLines(referencesFile.Value())
-                        : Array.Empty<string>();
-
-                    RuntimeDependenciesResolver.ResolveRuntimeDependencies(
-                        mainAssemblyPath.Value,
-                        referencesSources,
-                        baseClassLibrary.Values.ToArray(),
-                        outputPath.Value());
-
-                    return 0;
-                }
-                catch (Exception ex)
-                {
-                    Console.WriteLine($"ERROR: {ex.Message}");
-                    Console.WriteLine(ex.StackTrace);
-                    return 1;
-                }
-            });
-        }
-    }
-}
diff --git a/src/Components/Blazor/Build/src/Cli/Commands/WriteBootJsonCommand.cs b/src/Components/Blazor/Build/src/Cli/Commands/WriteBootJsonCommand.cs
deleted file mode 100644
index dea217958c98345d3f050e074b3a3ecd4f922464..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Build/src/Cli/Commands/WriteBootJsonCommand.cs
+++ /dev/null
@@ -1,68 +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 Microsoft.Extensions.CommandLineUtils;
-using System;
-using System.IO;
-
-namespace Microsoft.AspNetCore.Blazor.Build.DevServer.Commands
-{
-    internal class WriteBootJsonCommand
-    {
-        public static void Command(CommandLineApplication command)
-        {
-            var referencesFile = command.Option("--references",
-                "The path to a file that lists the paths to given referenced dll files",
-                CommandOptionType.SingleValue);
-
-            var embeddedResourcesFile = command.Option("--embedded-resources",
-                "The path to a file that lists the paths of .NET assemblies that may contain embedded resources (typically, referenced assemblies in their pre-linked states)",
-                CommandOptionType.SingleValue);
-
-            var outputPath = command.Option("--output",
-                "Path to the output file",
-                CommandOptionType.SingleValue);
-
-            var mainAssemblyPath = command.Argument("assembly",
-                "Path to the assembly containing the entry point of the application.");
-
-            var linkerEnabledFlag = command.Option("--linker-enabled",
-                "If set, specifies that the application is being built with linking enabled.",
-                CommandOptionType.NoValue);
-
-            command.OnExecute(() =>
-            {
-                if (string.IsNullOrEmpty(mainAssemblyPath.Value) || !outputPath.HasValue())
-                {
-                    command.ShowHelp(command.Name);
-                    return 1;
-                }
-
-                try
-                {
-                    var referencesSources = referencesFile.HasValue()
-                        ? File.ReadAllLines(referencesFile.Value())
-                        : Array.Empty<string>();
-
-                    var embeddedResourcesSources = embeddedResourcesFile.HasValue()
-                        ? File.ReadAllLines(embeddedResourcesFile.Value())
-                        : Array.Empty<string>();
-
-                    BootJsonWriter.WriteFile(
-                        mainAssemblyPath.Value,
-                        referencesSources,
-                        embeddedResourcesSources,
-                        linkerEnabledFlag.HasValue(),
-                        outputPath.Value());
-                    return 0;
-                }
-                catch (Exception ex)
-                {
-                    Console.WriteLine($"ERROR: {ex.Message}");
-                    Console.WriteLine(ex.StackTrace);
-                    return 1;
-                }
-            });
-        }
-    }
-}
diff --git a/src/Components/Blazor/Build/src/Cli/Program.cs b/src/Components/Blazor/Build/src/Cli/Program.cs
deleted file mode 100644
index 3bd530453f8a6a5d4ed148f58bc325f9d9de7215..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Build/src/Cli/Program.cs
+++ /dev/null
@@ -1,33 +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 Microsoft.AspNetCore.Blazor.Build.DevServer.Commands;
-using Microsoft.Extensions.CommandLineUtils;
-
-namespace Microsoft.AspNetCore.Blazor.Build
-{
-    static class Program
-    {
-        static int Main(string[] args)
-        {
-            var app = new CommandLineApplication
-            {
-                Name = "Microsoft.AspNetCore.Blazor.Build"
-            };
-            app.HelpOption("-?|-h|--help");
-
-            app.Command("resolve-dependencies", ResolveRuntimeDependenciesCommand.Command);
-            app.Command("write-boot-json", WriteBootJsonCommand.Command);
-
-            if (args.Length > 0)
-            {
-                return app.Execute(args);
-            }
-            else
-            {
-                app.ShowHelp();
-                return 0;
-            }
-        }
-    }
-}
diff --git a/src/Components/Blazor/Build/src/Core/BootJsonWriter.cs b/src/Components/Blazor/Build/src/Core/BootJsonWriter.cs
deleted file mode 100644
index 4d4c114158d9d57b6fb9dc9e58ce9620f3754157..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Build/src/Core/BootJsonWriter.cs
+++ /dev/null
@@ -1,95 +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 System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text.Json;
-using Microsoft.AspNetCore.Components;
-using Mono.Cecil;
-
-namespace Microsoft.AspNetCore.Blazor.Build
-{
-    internal class BootJsonWriter
-    {
-        public static void WriteFile(
-            string assemblyPath,
-            string[] assemblyReferences,
-            string[] embeddedResourcesSources,
-            bool linkerEnabled,
-            string outputPath)
-        {
-            var embeddedContent = EmbeddedResourcesProcessor.ExtractEmbeddedResources(
-                embeddedResourcesSources, Path.GetDirectoryName(outputPath));
-            var bootJsonText = GetBootJsonContent(
-                Path.GetFileName(assemblyPath),
-                GetAssemblyEntryPoint(assemblyPath),
-                assemblyReferences,
-                embeddedContent,
-                linkerEnabled);
-            var normalizedOutputPath = Path.GetFullPath(outputPath);
-            Console.WriteLine("Writing boot data to: " + normalizedOutputPath);
-            File.WriteAllText(normalizedOutputPath, bootJsonText);
-        }
-
-        public static string GetBootJsonContent(string assemblyFileName, string entryPoint, string[] assemblyReferences, IEnumerable<EmbeddedResourceInfo> embeddedContent, bool linkerEnabled)
-        {
-            var data = new BootJsonData(
-                assemblyFileName,
-                entryPoint,
-                assemblyReferences,
-                embeddedContent,
-                linkerEnabled);
-            return JsonSerializer.Serialize(data, JsonSerializerOptionsProvider.Options);
-        }
-
-        private static string GetAssemblyEntryPoint(string assemblyPath)
-        {
-            using (var assemblyDefinition = AssemblyDefinition.ReadAssembly(assemblyPath))
-            {
-                var entryPoint = assemblyDefinition.EntryPoint;
-                if (entryPoint == null)
-                {
-                    throw new ArgumentException($"The assembly at {assemblyPath} has no specified entry point.");
-                }
-
-                return $"{entryPoint.DeclaringType.FullName}::{entryPoint.Name}";
-            }
-        }
-
-        /// <summary>
-        /// Defines the structure of a Blazor boot JSON file
-        /// </summary>
-        class BootJsonData
-        {
-            public string Main { get; }
-            public string EntryPoint { get; }
-            public IEnumerable<string> AssemblyReferences { get; }
-            public IEnumerable<string> CssReferences { get; }
-            public IEnumerable<string> JsReferences { get; }
-            public bool LinkerEnabled { get; }
-
-            public BootJsonData(
-                string entrypointAssemblyWithExtension,
-                string entryPoint,
-                IEnumerable<string> assemblyReferences,
-                IEnumerable<EmbeddedResourceInfo> embeddedContent,
-                bool linkerEnabled)
-            {
-                Main = entrypointAssemblyWithExtension;
-                EntryPoint = entryPoint;
-                AssemblyReferences = assemblyReferences;
-                LinkerEnabled = linkerEnabled;
-
-                CssReferences = embeddedContent
-                    .Where(c => c.Kind == EmbeddedResourceKind.Css)
-                    .Select(c => c.RelativePath);
-
-                JsReferences = embeddedContent
-                    .Where(c => c.Kind == EmbeddedResourceKind.JavaScript)
-                    .Select(c => c.RelativePath);
-            }
-        }
-    }
-}
diff --git a/src/Components/Blazor/Build/src/Core/EmbeddedResources/EmbeddedResourceInfo.cs b/src/Components/Blazor/Build/src/Core/EmbeddedResources/EmbeddedResourceInfo.cs
deleted file mode 100644
index 97331537f28c36c5c35c42fa20032dc48b5528aa..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Build/src/Core/EmbeddedResources/EmbeddedResourceInfo.cs
+++ /dev/null
@@ -1,17 +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.
-
-namespace Microsoft.AspNetCore.Blazor.Build
-{
-    internal class EmbeddedResourceInfo
-    {
-        public EmbeddedResourceKind Kind { get; }
-        public string RelativePath { get; }
-
-        public EmbeddedResourceInfo(EmbeddedResourceKind kind, string relativePath)
-        {
-            Kind = kind;
-            RelativePath = relativePath;
-        }
-    }
-}
diff --git a/src/Components/Blazor/Build/src/Core/EmbeddedResources/EmbeddedResourceKind.cs b/src/Components/Blazor/Build/src/Core/EmbeddedResources/EmbeddedResourceKind.cs
deleted file mode 100644
index caf322ee152f705a7c3d8a9c30442172be7ff7c0..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Build/src/Core/EmbeddedResources/EmbeddedResourceKind.cs
+++ /dev/null
@@ -1,12 +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.
-
-namespace Microsoft.AspNetCore.Blazor.Build
-{
-    internal enum EmbeddedResourceKind
-    {
-        JavaScript,
-        Css,
-        Static
-    }
-}
diff --git a/src/Components/Blazor/Build/src/Core/EmbeddedResources/EmbeddedResourcesProcessor.cs b/src/Components/Blazor/Build/src/Core/EmbeddedResources/EmbeddedResourcesProcessor.cs
deleted file mode 100644
index 21a28597e1b9224b6608b95fc7c00454d864ef66..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Build/src/Core/EmbeddedResources/EmbeddedResourcesProcessor.cs
+++ /dev/null
@@ -1,137 +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 Mono.Cecil;
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-
-namespace Microsoft.AspNetCore.Blazor.Build
-{
-    internal class EmbeddedResourcesProcessor
-    {
-        const string ContentSubdirName = "_content";
-
-        private readonly static Dictionary<string, EmbeddedResourceKind> _knownResourceKindsByNamePrefix = new Dictionary<string, EmbeddedResourceKind>
-        {
-            { "blazor:js:", EmbeddedResourceKind.JavaScript },
-            { "blazor:css:", EmbeddedResourceKind.Css },
-            { "blazor:file:", EmbeddedResourceKind.Static },
-        };
-
-        /// <summary>
-        /// Finds Blazor-specific embedded resources in the specified assemblies, writes them
-        /// to disk, and returns a description of those resources in dependency order.
-        /// </summary>
-        /// <param name="referencedAssemblyPaths">The paths to assemblies that may contain embedded resources.</param>
-        /// <param name="outputDir">The path to the directory where output is being written.</param>
-        /// <returns>A description of the embedded resources that were written to disk.</returns>
-        public static IReadOnlyList<EmbeddedResourceInfo> ExtractEmbeddedResources(
-            IEnumerable<string> referencedAssemblyPaths, string outputDir)
-        {
-            // Clean away any earlier state
-            var contentDir = Path.Combine(outputDir, ContentSubdirName);
-            if (Directory.Exists(contentDir))
-            {
-                Directory.Delete(contentDir, recursive: true);
-            }
-
-            // First, get an ordered list of AssemblyDefinition instances
-            var referencedAssemblyDefinitions = referencedAssemblyPaths
-                .Where(path => !Path.GetFileName(path).StartsWith("System.", StringComparison.Ordinal)) // Skip System.* because they are never going to contain embedded resources that we want
-                .Select(path => AssemblyDefinition.ReadAssembly(path))
-                .ToList();
-            referencedAssemblyDefinitions.Sort(OrderWithReferenceSubjectFirst);
-
-            // Now process them in turn
-            return referencedAssemblyDefinitions
-                .SelectMany(def => ExtractEmbeddedResourcesFromSingleAssembly(def, outputDir))
-                .ToList()
-                .AsReadOnly();
-        }
-
-        private static IEnumerable<EmbeddedResourceInfo> ExtractEmbeddedResourcesFromSingleAssembly(
-            AssemblyDefinition assemblyDefinition, string outputDirPath)
-        {
-            var assemblyName = assemblyDefinition.Name.Name;
-            foreach (var res in assemblyDefinition.MainModule.Resources)
-            {
-                if (TryExtractEmbeddedResource(assemblyName, res, outputDirPath, out var extractedResourceInfo))
-                {
-                    yield return extractedResourceInfo;
-                }
-            }
-        }
-
-        private static bool TryExtractEmbeddedResource(string assemblyName, Resource resource, string outputDirPath, out EmbeddedResourceInfo extractedResourceInfo)
-        {
-            if (resource is EmbeddedResource embeddedResource)
-            {
-                if (TryInterpretLogicalName(resource.Name, out var kind, out var name))
-                {
-                    // Prefix the output path with the assembly name to ensure no clashes
-                    // Also be invariant to the OS on which the package was built
-                    name = Path.Combine(ContentSubdirName, assemblyName, EnsureHasPathSeparators(name, Path.DirectorySeparatorChar));
-
-                    // Write the file content to disk, ensuring we don't try to write outside the output root
-                    var outputPath = Path.GetFullPath(Path.Combine(outputDirPath, name));
-                    if (!outputPath.StartsWith(outputDirPath))
-                    {
-                        throw new InvalidOperationException($"Cannot write embedded resource from assembly '{assemblyName}' to '{outputPath}' because it is outside the expected directory {outputDirPath}");
-                    }
-                    WriteResourceFile(embeddedResource, outputPath);
-
-                    // The URLs we write into the boot json file need to use web-style directory separators
-                    extractedResourceInfo = new EmbeddedResourceInfo(kind, EnsureHasPathSeparators(name, '/'));
-                    return true;
-                }
-            }
-
-            extractedResourceInfo = null;
-            return false;
-        }
-
-        private static void WriteResourceFile(EmbeddedResource resource, string outputPath)
-        {
-            Directory.CreateDirectory(Path.GetDirectoryName(outputPath));
-            using (var outputStream = File.OpenWrite(outputPath))
-            {
-                resource.GetResourceStream().CopyTo(outputStream);
-            }
-        }
-
-        private static string EnsureHasPathSeparators(string name, char desiredSeparatorChar) => name
-            .Replace('\\', desiredSeparatorChar)
-            .Replace('/', desiredSeparatorChar);
-
-        private static bool TryInterpretLogicalName(string logicalName, out EmbeddedResourceKind kind, out string resolvedName)
-        {
-            foreach (var kvp in _knownResourceKindsByNamePrefix)
-            {
-                if (logicalName.StartsWith(kvp.Key, StringComparison.Ordinal))
-                {
-                    kind = kvp.Value;
-                    resolvedName = logicalName.Substring(kvp.Key.Length);
-                    return true;
-                }
-            }
-
-            kind = default;
-            resolvedName = default;
-            return false;
-        }
-
-        // For each assembly B that references A, we want the resources from A to be loaded before
-        // the references for B (because B's resources might depend on A's resources)
-        private static int OrderWithReferenceSubjectFirst(AssemblyDefinition a, AssemblyDefinition b)
-            => AssemblyHasReference(a, b) ? 1
-            : AssemblyHasReference(b, a) ? -1
-            : 0;
-
-        private static bool AssemblyHasReference(AssemblyDefinition from, AssemblyDefinition to)
-            => from.MainModule.AssemblyReferences
-                .Select(reference => reference.Name)
-                .Contains(to.Name.Name, StringComparer.Ordinal);
-    }
-}
diff --git a/src/Components/Blazor/Build/src/Core/RuntimeDependenciesResolver.cs b/src/Components/Blazor/Build/src/Core/RuntimeDependenciesResolver.cs
deleted file mode 100644
index 18637753cc7508c6fa728101ed2b15232edec2d7..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Build/src/Core/RuntimeDependenciesResolver.cs
+++ /dev/null
@@ -1,165 +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 System.Collections.Generic;
-using System.Diagnostics;
-using System.IO;
-using System.Linq;
-using Mono.Cecil;
-
-namespace Microsoft.AspNetCore.Blazor.Build
-{
-    internal class RuntimeDependenciesResolver
-    {
-        public static void ResolveRuntimeDependencies(
-            string entryPoint,
-            string[] applicationDependencies,
-            string[] monoBclDirectories,
-            string outputFile)
-        {
-            var paths = ResolveRuntimeDependenciesCore(entryPoint, applicationDependencies, monoBclDirectories);
-            File.WriteAllLines(outputFile, paths);
-        }
-
-        public static IEnumerable<string> ResolveRuntimeDependenciesCore(
-            string entryPoint,
-            string[] applicationDependencies,
-            string[] monoBclDirectories)
-        {
-            var assembly = new AssemblyEntry(entryPoint, AssemblyDefinition.ReadAssembly(entryPoint));
-
-            var dependencies = applicationDependencies
-                .Select(a => new AssemblyEntry(a, AssemblyDefinition.ReadAssembly(a)))
-                .ToArray();
-
-            var bcl = monoBclDirectories
-                .SelectMany(d => Directory.EnumerateFiles(d, "*.dll").Select(f => Path.Combine(d, f)))
-                .Select(a => new AssemblyEntry(a, AssemblyDefinition.ReadAssembly(a)))
-                .ToArray();
-
-            var assemblyResolutionContext = new AssemblyResolutionContext(
-                assembly,
-                dependencies,
-                bcl);
-
-            assemblyResolutionContext.ResolveAssemblies();
-
-            var paths = assemblyResolutionContext.Results.Select(r => r.Path);
-            return paths.Concat(FindPdbs(paths));
-        }
-
-        private static IEnumerable<string> FindPdbs(IEnumerable<string> dllPaths)
-        {
-            return dllPaths
-                .Select(path => Path.ChangeExtension(path, "pdb"))
-                .Where(path => File.Exists(path));
-        }
-
-        public class AssemblyResolutionContext
-        {
-            public AssemblyResolutionContext(
-                AssemblyEntry assembly,
-                AssemblyEntry[] dependencies,
-                AssemblyEntry[] bcl)
-            {
-                Assembly = assembly;
-                Dependencies = dependencies;
-                Bcl = bcl;
-            }
-
-            public AssemblyEntry Assembly { get; }
-            public AssemblyEntry[] Dependencies { get; }
-            public AssemblyEntry[] Bcl { get; }
-
-            public IList<AssemblyEntry> Results { get; } = new List<AssemblyEntry>();
-
-            internal void ResolveAssemblies()
-            {
-                var visitedAssemblies = new HashSet<string>();
-                var pendingAssemblies = new Stack<AssemblyNameReference>();
-                pendingAssemblies.Push(Assembly.Definition.Name);
-                ResolveAssembliesCore();
-
-                void ResolveAssembliesCore()
-                {
-                    while (pendingAssemblies.TryPop(out var current))
-                    {
-                        if (!visitedAssemblies.Contains(current.Name))
-                        {
-                            visitedAssemblies.Add(current.Name);
-
-                            // Not all references will be resolvable within the Mono BCL, particularly
-                            // when building for server-side Blazor as you will be running on CoreCLR
-                            // and therefore may depend on System.* BCL assemblies that aren't present
-                            // in Mono WebAssembly. Skipping unresolved assemblies here is equivalent
-                            // to passing "--skip-unresolved true" to the Mono linker.
-                            var resolved = Resolve(current);
-                            if (resolved != null)
-                            {
-                                Results.Add(resolved);
-                                var references = GetAssemblyReferences(resolved);
-                                foreach (var reference in references)
-                                {
-                                    pendingAssemblies.Push(reference);
-                                }
-                            }
-                        }
-                    }
-                }
-
-                IEnumerable<AssemblyNameReference> GetAssemblyReferences(AssemblyEntry current) =>
-                    current.Definition.Modules.SelectMany(m => m.AssemblyReferences);
-
-                AssemblyEntry Resolve(AssemblyNameReference current)
-                {
-                    if (Assembly.Definition.Name.Name == current.Name)
-                    {
-                        return Assembly;
-                    }
-
-                    var referencedAssemblyCandidate = FindCandidate(current, Dependencies);
-                    var bclAssemblyCandidate = FindCandidate(current, Bcl);
-
-                    // Resolution logic. For right now, we will prefer the mono BCL version of a given
-                    // assembly if there is a candidate assembly and an equivalent mono assembly.
-                    if (bclAssemblyCandidate != null)
-                    {
-                        return bclAssemblyCandidate;
-                    }
-
-                    return referencedAssemblyCandidate;
-                }
-
-                AssemblyEntry FindCandidate(AssemblyNameReference current, AssemblyEntry[] candidates)
-                {
-                    // Do simple name match. Assume no duplicates.
-                    foreach (var candidate in candidates)
-                    {
-                        if (current.Name == candidate.Definition.Name.Name)
-                        {
-                            return candidate;
-                        }
-                    }
-
-                    return null;
-                }
-            }
-        }
-
-        [DebuggerDisplay("{ToString(),nq}")]
-        public class AssemblyEntry
-        {
-            public AssemblyEntry(string path, AssemblyDefinition definition)
-            {
-                Path = path;
-                Definition = definition;
-            }
-
-            public string Path { get; set; }
-            public AssemblyDefinition Definition { get; set; }
-
-            public override string ToString() => Definition.FullName;
-        }
-    }
-}
diff --git a/src/Components/Blazor/Build/src/Microsoft.AspNetCore.Blazor.Build.csproj b/src/Components/Blazor/Build/src/Microsoft.AspNetCore.Blazor.Build.csproj
index 93fa3fa9d6d0f5068c2c21dcbe5f28d8cdd73f6b..8262e1139de9e107a94a7b6778b3fa1cf1b90dba 100644
--- a/src/Components/Blazor/Build/src/Microsoft.AspNetCore.Blazor.Build.csproj
+++ b/src/Components/Blazor/Build/src/Microsoft.AspNetCore.Blazor.Build.csproj
@@ -1,25 +1,26 @@
-<Project Sdk="Microsoft.NET.Sdk">
+<Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
-    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <TargetFrameworks>$(DefaultNetCoreTargetFramework);net46</TargetFrameworks>
+    <TargetName>Microsoft.AspNetCore.Blazor.Build.Tasks</TargetName>
+    <AssemblyName>Microsoft.AspNetCore.Blazor.Build</AssemblyName>
     <Description>Build mechanism for ASP.NET Core Blazor applications.</Description>
-    <OutputType>Exe</OutputType>
     <IsShippingPackage>false</IsShippingPackage>
     <HasReferenceAssembly>false</HasReferenceAssembly>
+    <GenerateDependencyFile>false</GenerateDependencyFile>
   </PropertyGroup>
 
   <!-- Pack settings -->
   <PropertyGroup>
     <!-- Producing this package requires building with NodeJS enabled. -->
     <IsPackable Condition="'$(BuildNodeJS)' == 'false'">false</IsPackable>
-    <GenerateNuspecDependsOn>$(GenerateNuspecDependsOn);Publish</GenerateNuspecDependsOn>
     <NoPackageAnalysis>true</NoPackageAnalysis>
     <NuspecFile>Microsoft.AspNetCore.Blazor.Build.nuspec</NuspecFile>
   </PropertyGroup>
 
   <ItemGroup>
     <NuspecProperty Include="configuration=$(Configuration)" />
-    <NuspecProperty Include="publishDir=$(PublishDir)" />
+    <NuspecProperty Include="taskskDir=$(OutputPath)tools" />
     <NuspecProperty Include="componentsversion=$(ComponentsPackageVersion)" />
     <NuspecProperty Include="razorversion=$(MicrosoftAspNetCoreRazorDesignPackageVersion)" />
     <NuspecProperty Include="blazormonoversion=$(MicrosoftAspNetCoreBlazorMonoPackageVersion)" />
@@ -27,16 +28,45 @@
   </ItemGroup>
 
   <ItemGroup>
-    <ProjectReference Condition="'$(BuildNodeJS)' != 'false' and '$(BuildingInsideVisualStudio)' != 'true'" Include="$(RepoRoot)src\Components\Web.JS\Microsoft.AspNetCore.Components.Web.JS.npmproj" ReferenceOutputAssembly="false" />
-    <Reference Include="Microsoft.AspNetCore.Components" />
-    <Reference Include="Microsoft.Extensions.CommandLineUtils.Sources" />
-    <Reference Include="Microsoft.Extensions.FileProviders.Composite" />
-    <Reference Include="Microsoft.Extensions.FileProviders.Physical" />
-    <Reference Include="Mono.Cecil" />
-    <Reference Include="System.CodeDom" />
+    <!-- Add a project dependency without reference output assemblies to enforce build order -->
+    <!-- Applying workaround for https://github.com/microsoft/msbuild/issues/2661 and https://github.com/dotnet/sdk/issues/952 -->
+    <ProjectReference
+      Include="$(RepoRoot)src\Components\Web.JS\Microsoft.AspNetCore.Components.Web.JS.npmproj"
+      ReferenceOutputAssemblies="false"
+      SkipGetTargetFrameworkProperties="true"
+      UndefineProperties="TargetFramework"
+      Private="false"
+      Condition="'$(BuildNodeJS)' != 'false' and '$(BuildingInsideVisualStudio)' != 'true'" />
+
+    <Reference Include="Microsoft.Build.Framework" ExcludeAssets="Runtime" />
+    <Reference Include="Microsoft.Build.Utilities.Core"  ExcludeAssets="Runtime" />
+    <Reference Include="System.Reflection.Metadata" Condition="'$(TargetFramework)' == 'net46'" />
   </ItemGroup>
 
+  <Target Name="CopyBuildTask" BeforeTargets="Build" Condition="'$(DotNetBuildFromSource)' != 'true' AND '$(IsInnerBuild)' != 'true'">
+      <!--
+      The task produced by this project is referenced within this solution. When building, Visual Studio will lock up the assembly.
+      Any attempts to overwrite the binary with a newer version will fail. This is particularly grating if a developer "Rebuilds" the project
+      after an initial build since that would always attempt to overwrite the tasks dll
+
+      This target attempts to make this solution more usable at the cost of a more onerous inner-loop build of the Blazor.Build tasks.
+      We'll copy the tasks to a location other that than the build output and use that in the Blazor.Build.targets. In the most common
+      case where these tasks aren't being worked on, everything should work great. However, if you're attemping to modify these tasks,
+      you will need to manaully stop MSBuild.exe processes
+      -->
+
   <ItemGroup>
-    <Compile Include="..\..\..\Shared\src\JsonSerializerOptionsProvider.cs" />
+      <_NetCoreFilesToCopy Include="$(OutputPath)$(DefaultNetCoreTargetFramework)\*" TargetPath="netcoreapp\" />
+      <_DesktopFilesToCopy Include="$(OutputPath)net46\*" TargetPath="netfx\" />
+      <_AllFilesToCopy Include="@(_NetCoreFilesToCopy);@(_DesktopFilesToCopy)" />
   </ItemGroup>
+
+    <Error Text="No files found in $(OutputPath)$(DefaultNetCoreTargetFramework)" Condition="@(_NetCoreFilesToCopy->Count()) == 0" />
+    <Error Text="No files found in $(OutputPath)net46" Condition="@(_DesktopFilesToCopy->Count()) == 0" />
+
+    <Copy SourceFiles="@(_AllFilesToCopy)" DestinationFiles="@(_AllFilesToCopy->'$(OutputPath)tools\%(TargetPath)%(FileName)%(Extension)')" SkipUnchangedFiles="true" Retries="1" ContinueOnError="true">
+      <Output TaskParameter="CopiedFiles" ItemName="FileWrites" />
+    </Copy>
+  </Target>
+
 </Project>
diff --git a/src/Components/Blazor/Build/src/Microsoft.AspNetCore.Blazor.Build.nuspec b/src/Components/Blazor/Build/src/Microsoft.AspNetCore.Blazor.Build.nuspec
index 2094d3fc5f7f03e9db6a95f6eb6f5d44cc43cc62..459fed97d0a7a429aa0d5f841d44699552889970 100644
--- a/src/Components/Blazor/Build/src/Microsoft.AspNetCore.Blazor.Build.nuspec
+++ b/src/Components/Blazor/Build/src/Microsoft.AspNetCore.Blazor.Build.nuspec
@@ -11,7 +11,7 @@
     <file src="$PackageThirdPartyNoticesFile$" target=".\THIRD-PARTY-NOTICES.txt" />
     <file src="build\**" target="build" />
     <file src="targets\**" target="targets" />
-    <file src="$publishdir$**\*" target="tools/" />
-    <file src="..\..\..\Web.JS\dist\$configuration$\blazor.*.js" target="tools/blazor" />
+    <file src="$taskskDir$\**" target="tools/" />
+    <file src="..\..\..\Web.JS\dist\$configuration$\blazor.webassembly.js" target="tools/blazor" />
   </files>
 </package>
diff --git a/src/Components/Blazor/Build/src/ReferenceBlazorBuildFromSource.props b/src/Components/Blazor/Build/src/ReferenceBlazorBuildFromSource.props
new file mode 100644
index 0000000000000000000000000000000000000000..0bcebe22fa129877d6ea9a464f6e59495244fe86
--- /dev/null
+++ b/src/Components/Blazor/Build/src/ReferenceBlazorBuildFromSource.props
@@ -0,0 +1,25 @@
+<Project>
+  <!--
+  Importing this file is equivalent to having:
+    <PackageDependency Include="Microsoft.AspNetCore.Blazor.Build" />
+  ... except it's much more convenient when working in this repo, because it consumes the
+  Blazor.Build targets/exe directly without needing this project to be packed into a .nupkg.
+
+  This is only intended for use by other projects in this repo.
+  -->
+
+  <PropertyGroup>
+    <ComponentsRoot Condition="'$(ComponentsRoot)'==''">$(MSBuildThisFileDirectory)..\..\..\</ComponentsRoot>
+    <BlazorJsPath>$(ComponentsRoot)Web.JS\dist\$(Configuration)\blazor.webassembly.js</BlazorJsPath>
+    <BlazorJsMapPath>$(ComponentsRoot)Web.JS\dist\$(Configuration)\blazor.webassembly.js.map</BlazorJsMapPath>
+    <BlazorToolsDir>$(MSBuildThisFileDirectory)bin\$(Configuration)\tools\</BlazorToolsDir>
+  </PropertyGroup>
+
+  <Target Name="CheckBlazorJSFiles" BeforeTargets="Build">
+    <Error Text="blazor.webassembly.js file could not be found at $(BlazorJsPath)" Condition="!Exists($(BlazorJsPath))" />
+  </Target>
+
+  <Import Project="$(MSBuildThisFileDirectory)targets/All.props" />
+  <Import Project="$(MSBuildThisFileDirectory)targets/All.targets" />
+
+</Project>
diff --git a/src/Components/Blazor/Build/src/ReferenceFromSource.props b/src/Components/Blazor/Build/src/ReferenceFromSource.props
index 8067cdc131b988e1d596a931db4d568995e8e295..37e2b60e16f06eb3315af13c6263c255467c390f 100644
--- a/src/Components/Blazor/Build/src/ReferenceFromSource.props
+++ b/src/Components/Blazor/Build/src/ReferenceFromSource.props
@@ -1,21 +1,6 @@
 <Project>
 
-  <!--
-  Importing this file is equivalent to having:
-    <PackageDependency Include="Microsoft.AspNetCore.Blazor.Build" />
-  ... except it's much more convenient when working in this repo, because it consumes the
-  Blazor.Build targets/exe directly without needing this project to be packed into a .nupkg.
-
-  This is only intended for use by other projects in this repo.
-  -->
-
-  <PropertyGroup>
-    <BlazorBuildReferenceFromSource>true</BlazorBuildReferenceFromSource>
-    <BlazorJsPath>$(RepoRoot)src\Components\Web.JS\dist\$(Configuration)\blazor.*.js.*</BlazorJsPath>
-  </PropertyGroup>
-
-  <Import Project="$(MSBuildThisFileDirectory)targets/All.props" />
-  <Import Project="$(MSBuildThisFileDirectory)targets/All.targets" />
+  <Import Project="ReferenceBlazorBuildFromSource.props" />
 
   <!--
     Debugging support using blazor-devserver serve.
@@ -35,6 +20,14 @@
     <Reference Include="Microsoft.AspNetCore.Blazor.Mono" />
   </ItemGroup>
 
+  <Target Name="_BuildBlazorBuildProject" BeforeTargets="ResolveProjectReferences">
+    <!--
+      The Blazor.Build project cross-compiles and we need the output of all TFMs to be available in the build output.
+      We can't represent this in any good way using ProjectReference elements, but we can try and build it instead.
+    -->
+    <MSBuild Projects="$(MSBuildThisFileDirectory)Microsoft.AspNetCore.Blazor.Build.csproj" />
+  </Target>
+
   <!-- This is used as a P2P when building the repo. Normal Blazor projects will get this as a reference through the Blazor.Build package -->
   <ItemGroup>
     <!-- Ensures these projects are built before the consuming project, but without
@@ -42,9 +35,8 @@
          given that the packed version of this project wouldn't add a .dll reference) -->
     <ProjectReference Include="$(MSBuildThisFileDirectory)Microsoft.AspNetCore.Blazor.Build.csproj">
       <ReferenceOutputAssembly>false</ReferenceOutputAssembly>
-      <!-- Optimization. Do not require framework compatibility between these projects. -->
       <SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
-      <UndefineProperties>TargetFramework</UndefineProperties>
+      <Properties>TargetFramework=$(DefaultNetCoreTargetFramework)</Properties>
     </ProjectReference>
     <ProjectReference Include="$(MSBuildThisFileDirectory)..\..\DevServer\src\Microsoft.AspNetCore.Blazor.DevServer.csproj">
       <ReferenceOutputAssembly>false</ReferenceOutputAssembly>
diff --git a/src/Components/Blazor/Build/src/Tasks/BlazorCreateRootDescriptorFile.cs b/src/Components/Blazor/Build/src/Tasks/BlazorCreateRootDescriptorFile.cs
new file mode 100644
index 0000000000000000000000000000000000000000..1aa853685671a61f982f7f386ea69f1b5d2bc5ce
--- /dev/null
+++ b/src/Components/Blazor/Build/src/Tasks/BlazorCreateRootDescriptorFile.cs
@@ -0,0 +1,56 @@
+// 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.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Xml;
+using System.Xml.Linq;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    // Based on https://github.com/mono/linker/blob/3b329b9481e300bcf4fb88a2eebf8cb5ef8b323b/src/ILLink.Tasks/CreateRootDescriptorFile.cs
+    public class BlazorCreateRootDescriptorFile : Task
+    {
+        [Required]
+        public ITaskItem[] AssemblyNames { get; set; }
+
+        [Required]
+        public ITaskItem RootDescriptorFilePath { get; set; }
+
+        public override bool Execute()
+        {
+            using var fileStream = File.Create(RootDescriptorFilePath.ItemSpec);
+            var assemblyNames = AssemblyNames.Select(a => a.ItemSpec);
+
+            WriteRootDescriptor(fileStream, assemblyNames);
+            return true;
+        }
+
+        internal static void WriteRootDescriptor(Stream stream, IEnumerable<string> assemblyNames)
+        {
+            var roots = new XElement("linker");
+            foreach (var assemblyName in assemblyNames)
+            {
+                roots.Add(new XElement("assembly",
+                    new XAttribute("fullname", assemblyName),
+                    new XElement("type",
+                        new XAttribute("fullname", "*"),
+                        new XAttribute("required", "true"))));
+            }
+
+            var xmlWriterSettings = new XmlWriterSettings
+            {
+                Indent = true,
+                OmitXmlDeclaration = true
+            };
+
+            using var writer = XmlWriter.Create(stream, xmlWriterSettings);
+            var xDocument = new XDocument(roots);
+
+            xDocument.Save(writer);
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/src/Tasks/BlazorILLink.cs b/src/Components/Blazor/Build/src/Tasks/BlazorILLink.cs
new file mode 100644
index 0000000000000000000000000000000000000000..d5dc22cde02c29bc424d41abe8319e7bb40a80bd
--- /dev/null
+++ b/src/Components/Blazor/Build/src/Tasks/BlazorILLink.cs
@@ -0,0 +1,194 @@
+// 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.IO;
+using System.Text;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace Microsoft.AspNetCore.Blazor.Build.Tasks
+{
+    // Based on https://github.com/mono/linker/blob/3b329b9481e300bcf4fb88a2eebf8cb5ef8b323b/src/ILLink.Tasks/LinkTask.cs
+    public class BlazorILLink : ToolTask
+    {
+        private const string DotNetHostPathEnvironmentName = "DOTNET_HOST_PATH";
+
+        [Required]
+        public string ILLinkPath { get; set; }
+
+        [Required]
+        public ITaskItem[] AssemblyPaths { get; set; }
+
+        public ITaskItem[] ReferenceAssemblyPaths { get; set; }
+
+        [Required]
+        public ITaskItem[] RootAssemblyNames { get; set; }
+
+        [Required]
+        public ITaskItem OutputDirectory { get; set; }
+
+        public ITaskItem[] RootDescriptorFiles { get; set; }
+
+        public bool ClearInitLocals { get; set; }
+
+        public string ClearInitLocalsAssemblies { get; set; }
+
+        public string ExtraArgs { get; set; }
+
+        public bool DumpDependencies { get; set; }
+
+        private string _dotnetPath;
+
+        private string DotNetPath
+        {
+            get
+            {
+                if (!string.IsNullOrEmpty(_dotnetPath))
+                {
+                    return _dotnetPath;
+                }
+
+                _dotnetPath = Environment.GetEnvironmentVariable(DotNetHostPathEnvironmentName);
+                if (string.IsNullOrEmpty(_dotnetPath))
+                {
+                    throw new InvalidOperationException($"{DotNetHostPathEnvironmentName} is not set");
+                }
+
+                return _dotnetPath;
+            }
+        }
+
+        protected override MessageImportance StandardErrorLoggingImportance => MessageImportance.High;
+
+        protected override string ToolName => Path.GetFileName(DotNetPath);
+
+        protected override string GenerateFullPathToTool() => DotNetPath;
+
+        protected override string GenerateCommandLineCommands()
+        {
+            var args = new StringBuilder();
+            args.Append(Quote(ILLinkPath));
+            return args.ToString();
+        }
+
+        private static string Quote(string path)
+        {
+            return $"\"{path.TrimEnd('\\')}\"";
+        }
+
+        protected override string GenerateResponseFileCommands()
+        {
+            var args = new StringBuilder();
+
+            if (RootDescriptorFiles != null)
+            {
+                foreach (var rootFile in RootDescriptorFiles)
+                {
+                    args.Append("-x ").AppendLine(Quote(rootFile.ItemSpec));
+                }
+            }
+
+            foreach (var assemblyItem in RootAssemblyNames)
+            {
+                args.Append("-a ").AppendLine(Quote(assemblyItem.ItemSpec));
+            }
+
+            var assemblyNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+            foreach (var assembly in AssemblyPaths)
+            {
+                var assemblyPath = assembly.ItemSpec;
+                var assemblyName = Path.GetFileNameWithoutExtension(assemblyPath);
+
+                // If there are multiple paths with the same assembly name, only use the first one.
+                if (!assemblyNames.Add(assemblyName))
+                {
+                    continue;
+                }
+
+                args.Append("-reference ")
+                    .AppendLine(Quote(assemblyPath));
+
+                var action = assembly.GetMetadata("action");
+                if ((action != null) && (action.Length > 0))
+                {
+                    args.Append("-p ");
+                    args.Append(action);
+                    args.Append(" ").AppendLine(Quote(assemblyName));
+                }
+            }
+
+            if (ReferenceAssemblyPaths != null)
+            {
+                foreach (var assembly in ReferenceAssemblyPaths)
+                {
+                    var assemblyPath = assembly.ItemSpec;
+                    var assemblyName = Path.GetFileNameWithoutExtension(assemblyPath);
+
+                    // Don't process references for which we already have
+                    // implementation assemblies.
+                    if (assemblyNames.Contains(assemblyName))
+                    {
+                        continue;
+                    }
+
+                    args.Append("-reference ").AppendLine(Quote(assemblyPath));
+
+                    // Treat reference assemblies as "skip". Ideally we
+                    // would not even look at the IL, but only use them to
+                    // resolve surface area.
+                    args.Append("-p skip ").AppendLine(Quote(assemblyName));
+                }
+            }
+
+            if (OutputDirectory != null)
+            {
+                args.Append("-out ").AppendLine(Quote(OutputDirectory.ItemSpec));
+            }
+
+            if (ClearInitLocals)
+            {
+                args.AppendLine("--enable-opt clearinitlocals");
+                if ((ClearInitLocalsAssemblies != null) && (ClearInitLocalsAssemblies.Length > 0))
+                {
+                    args.Append("-m ClearInitLocalsAssemblies ");
+                    args.AppendLine(ClearInitLocalsAssemblies);
+                }
+            }
+
+            if (ExtraArgs != null)
+            {
+                args.AppendLine(ExtraArgs);
+            }
+
+            if (DumpDependencies)
+            {
+                args.AppendLine("--dump-dependencies");
+            }
+
+            return args.ToString();
+        }
+
+        protected override bool HandleTaskExecutionErrors()
+        {
+            // Show a slightly better error than the standard ToolTask message that says "dotnet" failed.
+            Log.LogError($"ILLink failed with exit code {ExitCode}.");
+            return false;
+        }
+
+        protected override void LogEventsFromTextOutput(string singleLine, MessageImportance messageImportance)
+        {
+            if (!string.IsNullOrEmpty(singleLine) && singleLine.StartsWith("Unhandled exception.", StringComparison.Ordinal))
+            {
+                // The Mono linker currently prints out an entire stack trace when the linker fails.
+                // We want to show something actionable in the VS Error window.
+                Log.LogError(singleLine);
+            }
+            else
+            {
+                base.LogEventsFromTextOutput(singleLine, messageImportance);
+            }
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/src/Tasks/GenerateBlazorBootJson.cs b/src/Components/Blazor/Build/src/Tasks/GenerateBlazorBootJson.cs
new file mode 100644
index 0000000000000000000000000000000000000000..1984de0a5798c737b342f5cbbaa07be3ca50a2ed
--- /dev/null
+++ b/src/Components/Blazor/Build/src/Tasks/GenerateBlazorBootJson.cs
@@ -0,0 +1,86 @@
+// 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.IO;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.Serialization.Json;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    public class GenerateBlazorBootJson : Task
+    {
+        [Required]
+        public string AssemblyPath { get; set; }
+
+        [Required]
+        public ITaskItem[] References { get; set; }
+
+        [Required]
+        public bool LinkerEnabled { get; set; }
+
+        [Required]
+        public string OutputPath { get; set; }
+
+        public override bool Execute()
+        {
+            var entryAssemblyName = AssemblyName.GetAssemblyName(AssemblyPath).Name;
+            var assemblies = References.Select(GetUriPath).OrderBy(c => c, StringComparer.Ordinal).ToArray();
+
+            using var fileStream = File.Create(OutputPath);
+            WriteBootJson(fileStream, entryAssemblyName, assemblies, LinkerEnabled);
+
+            return true;
+
+            static string GetUriPath(ITaskItem item)
+            {
+                var outputPath = item.GetMetadata("RelativeOutputPath");
+                if (string.IsNullOrEmpty(outputPath))
+                {
+                    outputPath = Path.GetFileName(item.ItemSpec);
+                }
+
+                return outputPath.Replace('\\', '/');
+            }
+        }
+
+        internal static void WriteBootJson(Stream stream, string entryAssemblyName, string[] assemblies, bool linkerEnabled)
+        {
+            var data = new BootJsonData
+            {
+                entryAssembly = entryAssemblyName,
+                assemblies = assemblies,
+                linkerEnabled = linkerEnabled,
+            };
+
+            var serializer = new DataContractJsonSerializer(typeof(BootJsonData));
+            serializer.WriteObject(stream, data);
+        }
+
+        /// <summary>
+        /// Defines the structure of a Blazor boot JSON file
+        /// </summary>
+#pragma warning disable IDE1006 // Naming Styles
+        public class BootJsonData
+        {
+            /// <summary>
+            /// Gets the name of the assembly with the application entry point
+            /// </summary>
+            public string entryAssembly { get; set; }
+
+            /// <summary>
+            /// Gets the closure of assemblies to be loaded by Blazor WASM. This includes the application entry assembly.
+            /// </summary>
+            public string[] assemblies { get; set; }
+
+            /// <summary>
+            /// Gets a value that determines if the linker is enabled.
+            /// </summary>
+            public bool linkerEnabled { get; set; }
+        }
+#pragma warning restore IDE1006 // Naming Styles
+    }
+}
diff --git a/src/Components/Blazor/Build/src/Tasks/GenerateTypeGranularityLinkingConfig.cs b/src/Components/Blazor/Build/src/Tasks/GenerateTypeGranularityLinkingConfig.cs
new file mode 100644
index 0000000000000000000000000000000000000000..8a56b7fc3deb3cfa0d192749710d26c0aa3590f2
--- /dev/null
+++ b/src/Components/Blazor/Build/src/Tasks/GenerateTypeGranularityLinkingConfig.cs
@@ -0,0 +1,48 @@
+// 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.IO;
+using System.Xml.Linq;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace Microsoft.AspNetCore.Blazor.Build.Tasks
+{
+    public class GenerateTypeGranularityLinkingConfig : Task
+    {
+        [Required]
+        public ITaskItem[] Assemblies { get; set; }
+
+        [Required]
+        public string OutputPath { get; set; }
+
+        public override bool Execute()
+        {
+            var linkerElement = new XElement("linker",
+                new XComment(" THIS IS A GENERATED FILE - DO NOT EDIT MANUALLY "));
+
+            foreach (var assembly in Assemblies)
+            {
+                var assemblyElement = CreateTypeGranularityConfig(assembly);
+                linkerElement.Add(assemblyElement);
+            }
+
+            using var fileStream = File.Open(OutputPath, FileMode.Create);
+            new XDocument(linkerElement).Save(fileStream);
+
+            return true;
+        }
+
+        private XElement CreateTypeGranularityConfig(ITaskItem assembly)
+        {
+            // We match all types in the assembly, and for each one, tell the linker to preserve all
+            // its members (preserve=all) but only if there's some reference to the type (required=false)
+            return new XElement("assembly",
+                new XAttribute("fullname", Path.GetFileNameWithoutExtension(assembly.ItemSpec)),
+                new XElement("type",
+                    new XAttribute("fullname", "*"),
+                    new XAttribute("preserve", "all"),
+                    new XAttribute("required", "false")));
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/src/Tasks/ResolveBlazorRuntimeDependencies.cs b/src/Components/Blazor/Build/src/Tasks/ResolveBlazorRuntimeDependencies.cs
new file mode 100644
index 0000000000000000000000000000000000000000..1181ea337d72a84e3678fc8d1b0563ded44581ea
--- /dev/null
+++ b/src/Components/Blazor/Build/src/Tasks/ResolveBlazorRuntimeDependencies.cs
@@ -0,0 +1,203 @@
+// 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.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Reflection.Metadata;
+using System.Reflection.PortableExecutable;
+using Microsoft.Build.Framework;
+using Microsoft.Build.Utilities;
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    public class ResolveBlazorRuntimeDependencies : Task
+    {
+        [Required]
+        public string EntryPoint { get; set; }
+
+        [Required]
+        public ITaskItem[] ApplicationDependencies { get; set; }
+
+        [Required]
+        public ITaskItem[] WebAssemblyBCLAssemblies { get; set; }
+
+        [Output]
+        public ITaskItem[] Dependencies { get; set; }
+
+        public override bool Execute()
+        {
+            var paths = ResolveRuntimeDependenciesCore(EntryPoint, ApplicationDependencies.Select(c => c.ItemSpec), WebAssemblyBCLAssemblies.Select(c => c.ItemSpec));
+            Dependencies = paths.Select(p => new TaskItem(p)).ToArray();
+
+            return true;
+        }
+
+        public static IEnumerable<string> ResolveRuntimeDependenciesCore(
+            string entryPoint,
+            IEnumerable<string> applicationDependencies,
+            IEnumerable<string> monoBclAssemblies)
+        {
+            var entryAssembly = new AssemblyEntry(entryPoint, GetAssemblyName(entryPoint));
+
+            var dependencies = CreateAssemblyLookup(applicationDependencies);
+
+            var bcl = CreateAssemblyLookup(monoBclAssemblies);
+
+            var assemblyResolutionContext = new AssemblyResolutionContext(
+                entryAssembly,
+                dependencies,
+                bcl);
+
+            assemblyResolutionContext.ResolveAssemblies();
+
+            var paths = assemblyResolutionContext.Results.Select(r => r.Path);
+            return paths.Concat(FindPdbs(paths));
+
+            static Dictionary<string, AssemblyEntry> CreateAssemblyLookup(IEnumerable<string> assemblyPaths)
+            {
+                var dictionary = new Dictionary<string, AssemblyEntry>(StringComparer.Ordinal);
+                foreach (var path in assemblyPaths)
+                {
+                    var assemblyName = AssemblyName.GetAssemblyName(path).Name;
+                    if (dictionary.TryGetValue(assemblyName, out var previous))
+                    {
+                        throw new InvalidOperationException($"Multiple assemblies found with the same assembly name '{assemblyName}':" +
+                            Environment.NewLine + string.Join(Environment.NewLine, previous, path));
+                    }
+                    dictionary[assemblyName] = new AssemblyEntry(path, assemblyName);
+                }
+
+                return dictionary;
+            }
+        }
+
+        private static string GetAssemblyName(string assemblyPath)
+        {
+            return AssemblyName.GetAssemblyName(assemblyPath).Name;
+        }
+
+        private static IEnumerable<string> FindPdbs(IEnumerable<string> dllPaths)
+        {
+            return dllPaths
+                .Select(path => Path.ChangeExtension(path, "pdb"))
+                .Where(path => File.Exists(path));
+        }
+
+        public class AssemblyResolutionContext
+        {
+            public AssemblyResolutionContext(
+                AssemblyEntry entryAssembly,
+                Dictionary<string, AssemblyEntry> dependencies,
+                Dictionary<string, AssemblyEntry> bcl)
+            {
+                EntryAssembly = entryAssembly;
+                Dependencies = dependencies;
+                Bcl = bcl;
+            }
+
+            public AssemblyEntry EntryAssembly { get; }
+            public Dictionary<string, AssemblyEntry> Dependencies { get; }
+            public Dictionary<string, AssemblyEntry> Bcl { get; }
+
+            public IList<AssemblyEntry> Results { get; } = new List<AssemblyEntry>();
+
+            internal void ResolveAssemblies()
+            {
+                var visitedAssemblies = new HashSet<string>();
+                var pendingAssemblies = new Stack<string>();
+                pendingAssemblies.Push(EntryAssembly.Name);
+                ResolveAssembliesCore();
+
+                void ResolveAssembliesCore()
+                {
+                    while (pendingAssemblies.Count > 0)
+                    {
+                        var current = pendingAssemblies.Pop();
+                        if (visitedAssemblies.Add(current))
+                        {
+                            // Not all references will be resolvable within the Mono BCL.
+                            // Skipping unresolved assemblies here is equivalent to passing "--skip-unresolved true" to the Mono linker.
+                            if (Resolve(current) is AssemblyEntry resolved)
+                            {
+                                Results.Add(resolved);
+                                var references = GetAssemblyReferences(resolved.Path);
+                                foreach (var reference in references)
+                                {
+                                    pendingAssemblies.Push(reference);
+                                }
+                            }
+                        }
+                    }
+                }
+
+                AssemblyEntry? Resolve(string assemblyName)
+                {
+                    if (EntryAssembly.Name == assemblyName)
+                    {
+                        return EntryAssembly;
+                    }
+
+                    // Resolution logic. For right now, we will prefer the mono BCL version of a given
+                    // assembly if there is a candidate assembly and an equivalent mono assembly.
+                    if (Bcl.TryGetValue(assemblyName, out var assembly) ||
+                        Dependencies.TryGetValue(assemblyName, out assembly))
+                    {
+                        return assembly;
+                    }
+
+                    return null;
+                }
+
+                static IReadOnlyList<string> GetAssemblyReferences(string assemblyPath)
+                {
+                    try
+                    {
+                        using var peReader = new PEReader(File.OpenRead(assemblyPath));
+                        if (!peReader.HasMetadata)
+                        {
+                            return Array.Empty<string>(); // not a managed assembly
+                        }
+
+                        var metadataReader = peReader.GetMetadataReader();
+
+                        var references = new List<string>();
+                        foreach (var handle in metadataReader.AssemblyReferences)
+                        {
+                            var reference = metadataReader.GetAssemblyReference(handle);
+                            var referenceName = metadataReader.GetString(reference.Name);
+
+                            references.Add(referenceName);
+                        }
+
+                        return references;
+                    }
+                    catch (BadImageFormatException)
+                    {
+                        // not a PE file, or invalid metadata
+                    }
+
+                    return Array.Empty<string>(); // not a managed assembly
+                }
+            }
+        }
+
+        [DebuggerDisplay("{ToString(),nq}")]
+        public readonly struct AssemblyEntry
+        {
+            public AssemblyEntry(string path, string name)
+            {
+                Path = path;
+                Name = name;
+            }
+
+            public string Path { get; }
+            public string Name { get; }
+
+            public override string ToString() => Name;
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/src/targets/All.props b/src/Components/Blazor/Build/src/targets/All.props
index 690a29fbab8fdd76d423fb0c47eb5a73cd8d02b2..d1d242f4d92fca434bedaa61233350a29e0bc015 100644
--- a/src/Components/Blazor/Build/src/targets/All.props
+++ b/src/Components/Blazor/Build/src/targets/All.props
@@ -4,12 +4,6 @@
   <PropertyGroup>
     <DefaultWebContentItemExcludes>$(DefaultWebContentItemExcludes);wwwroot\**</DefaultWebContentItemExcludes>
 
-    <!-- By default, enable auto rebuilds for debug builds. Note that the server will not enable it in production environments regardless. -->
-    <BlazorRebuildOnFileChange Condition="'$(Configuration)' == 'Debug' AND '$(BlazorRebuildOnFileChange)' == ''">true</BlazorRebuildOnFileChange>
-
-    <!-- By default, enable debugging for debug builds. -->
-    <BlazorEnableDebugging Condition="'$(Configuration)' == 'Debug' AND '$(BlazorEnableDebugging)' == ''">true</BlazorEnableDebugging>
-      
     <!-- When using IISExpress with a standalone app, there's no point restarting IISExpress after build. It slows things unnecessarily and breaks in-flight HTTP requests. -->
     <NoRestartServerOnBuild>true</NoRestartServerOnBuild>
   </PropertyGroup>
diff --git a/src/Components/Blazor/Build/src/targets/All.targets b/src/Components/Blazor/Build/src/targets/All.targets
index dd4fbf1b7a9a0e02ba87de047c361d2e9ad06813..6c69e85a40113d3f0fd58291407c1b14d0144cf8 100644
--- a/src/Components/Blazor/Build/src/targets/All.targets
+++ b/src/Components/Blazor/Build/src/targets/All.targets
@@ -6,40 +6,43 @@
   </PropertyGroup>
 
   <PropertyGroup>
-    <BlazorToolsDir Condition="'$(BlazorToolsDir)' == ''">$(MSBuildThisFileDirectory)../tools/</BlazorToolsDir>
-    <BlazorBuildExe>dotnet &quot;$(BlazorToolsDir)Microsoft.AspNetCore.Blazor.Build.dll&quot;</BlazorBuildExe>
+    <BlazorToolsDir Condition="'$(BlazorToolsDir)' == ''">$(MSBuildThisFileDirectory)..\tools\</BlazorToolsDir>
+    <_BlazorTasksTFM Condition=" '$(MSBuildRuntimeType)' == 'Core'">netcoreapp</_BlazorTasksTFM>
+    <_BlazorTasksTFM Condition=" '$(_BlazorTasksTFM)' == ''">netfx</_BlazorTasksTFM>
+    <BlazorTasksPath>$(BlazorToolsDir)$(_BlazorTasksTFM)\Microsoft.AspNetCore.Blazor.Build.Tasks.dll</BlazorTasksPath>
 
     <!-- The Blazor build code can only find your referenced assemblies if they are in the output directory -->
     <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
+
+    <!-- By default, enable debugging for debug builds. -->
+    <BlazorEnableDebugging Condition="'$(Configuration)' == 'Debug' AND '$(BlazorEnableDebugging)' == ''">true</BlazorEnableDebugging>
   </PropertyGroup>
 
   <Import Project="Blazor.MonoRuntime.targets" />
   <Import Project="Publish.targets" />
+  <Import Project="StaticWebAssets.targets" />
 
-  <Target Name="GenerateBlazorMetadataFile" BeforeTargets="GetCopyToOutputDirectoryItems">
+  <Target Name="GenerateBlazorMetadataFile"
+    BeforeTargets="GetCopyToOutputDirectoryItems">
     <PropertyGroup>
       <BlazorMetadataFileName>$(AssemblyName).blazor.config</BlazorMetadataFileName>
       <BlazorMetadataFilePath>$(TargetDir)$(BlazorMetadataFileName)</BlazorMetadataFilePath>
     </PropertyGroup>
-    <WriteLinesToFile File="$(BlazorMetadataFilePath)" Lines="$(MSBuildProjectFullPath)" Overwrite="true" Encoding="Unicode"/>
-    <WriteLinesToFile File="$(BlazorMetadataFilePath)" Lines="$(OutDir)$(AssemblyName).dll" Overwrite="false" Encoding="Unicode"/>
-    <WriteLinesToFile File="$(BlazorMetadataFilePath)" Condition="'$(BlazorRebuildOnFileChange)'=='true'" Lines="autorebuild:true" Overwrite="false" Encoding="Unicode"/>
-    <WriteLinesToFile File="$(BlazorMetadataFilePath)" Condition="'$(BlazorEnableDebugging)'=='true'" Lines="debug:true" Overwrite="false" Encoding="Unicode"/>
+
     <ItemGroup>
-      <ContentWithTargetPath Include="$(BlazorMetadataFilePath)" TargetPath="$(BlazorMetadataFileName)" CopyToOutputDirectory="PreserveNewest" />
+      <_BlazorConfigContent Include="$(MSBuildProjectFullPath)" />
+      <_BlazorConfigContent Include="$(TargetPath)" />
+      <_BlazorConfigContent Include="debug:true" Condition="'$(BlazorEnableDebugging)'=='true'" />
     </ItemGroup>
-  </Target>
 
-  <PropertyGroup>
-    <GetCurrentProjectStaticWebAssetsDependsOn>
-      $(GetCurrentProjectStaticWebAssetsDependsOn);
-      _ClearCurrentStaticWebAssetsForReferenceDiscovery
-    </GetCurrentProjectStaticWebAssetsDependsOn>
-  </PropertyGroup>
+    <WriteLinesToFile
+      File="$(BlazorMetadataFilePath)"
+      Lines="@(_BlazorConfigContent)"
+      Overwrite="true"
+      WriteOnlyWhenDifferent="True" />
 
-  <Target Name="_ClearCurrentStaticWebAssetsForReferenceDiscovery">
     <ItemGroup>
-      <StaticWebAsset Remove="@(StaticWebAsset)" Condition="'%(SourceType)' == ''" />
+      <ContentWithTargetPath Include="$(BlazorMetadataFilePath)" TargetPath="$(BlazorMetadataFileName)" CopyToOutputDirectory="PreserveNewest" />
     </ItemGroup>
   </Target>
 
diff --git a/src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.props b/src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.props
index 03f70748ffb60c931e7a78735fd84db0569c604c..f49c1f8f2ff5e0d96b6476a1f16fa4cf23b02a84 100644
--- a/src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.props
+++ b/src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.props
@@ -1,22 +1,20 @@
 <Project>
 
-  <PropertyGroup Condition="'$(BlazorBuildReferenceFromSource)'==''">
-    <BlazorJsPath>$(MSBuildThisFileDirectory)../tools/blazor/blazor.*.js</BlazorJsPath>
+  <PropertyGroup>
+    <BlazorJsPath Condition="'$(BlazorJsPath)' == ''">$(MSBuildThisFileDirectory)..\tools\blazor\blazor.webassembly.js</BlazorJsPath>
   </PropertyGroup>
 
   <PropertyGroup Label="Blazor build outputs">
     <MonoLinkerI18NAssemblies>none</MonoLinkerI18NAssemblies> <!-- See Mono linker docs - allows comma-separated values from: none,all,cjk,mideast,other,rare,west -->
-    <AdditionalMonoLinkerOptions>--disable-opt unreachablebodies --verbose --strip-security true --exclude-feature com --exclude-feature sre -v false -c link -u link -b true</AdditionalMonoLinkerOptions>
-    <BaseBlazorDistPath>dist/</BaseBlazorDistPath>
-    <BaseBlazorPackageContentOutputPath>$(BaseBlazorDistPath)_content/</BaseBlazorPackageContentOutputPath>
-    <BaseBlazorRuntimeOutputPath>$(BaseBlazorDistPath)_framework/</BaseBlazorRuntimeOutputPath>
-    <BaseBlazorRuntimeBinOutputPath>$(BaseBlazorRuntimeOutputPath)_bin/</BaseBlazorRuntimeBinOutputPath>
-    <BaseBlazorRuntimeWasmOutputPath>$(BaseBlazorRuntimeOutputPath)wasm/</BaseBlazorRuntimeWasmOutputPath>
-    <BaseBlazorJsOutputPath>$(BaseBlazorRuntimeOutputPath)</BaseBlazorJsOutputPath>
-    <BaseBlazorIntermediateOutputPath>blazor/</BaseBlazorIntermediateOutputPath>
-    <BlazorWebRootName>wwwroot/</BlazorWebRootName>
+    <AdditionalMonoLinkerOptions>--disable-opt unreachablebodies --verbose --strip-security true --exclude-feature com -v false -c link -u link -b true</AdditionalMonoLinkerOptions>
+    <BaseBlazorDistPath>dist\</BaseBlazorDistPath>
+    <BaseBlazorPackageContentOutputPath>$(BaseBlazorDistPath)_content\</BaseBlazorPackageContentOutputPath>
+    <BaseBlazorRuntimeOutputPath>$(BaseBlazorDistPath)_framework\</BaseBlazorRuntimeOutputPath>
+    <BlazorRuntimeBinOutputPath>$(BaseBlazorRuntimeOutputPath)_bin\</BlazorRuntimeBinOutputPath>
+    <BlazorRuntimeWasmOutputPath>$(BaseBlazorRuntimeOutputPath)wasm\</BlazorRuntimeWasmOutputPath>
+    <BlazorWebRootName>wwwroot\</BlazorWebRootName>
     <BlazorBootJsonName>blazor.boot.json</BlazorBootJsonName>
-    <BlazorBootJsonOutputPath>$(BaseBlazorRuntimeOutputPath)$(BlazorBootJsonName)</BlazorBootJsonOutputPath>
+    <_BlazorBuiltInBclLinkerDescriptor>$(MSBuildThisFileDirectory)BuiltInBclLinkerDescriptor.xml</_BlazorBuiltInBclLinkerDescriptor>
   </PropertyGroup>
 
 </Project>
diff --git a/src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.targets b/src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.targets
index 69976d519b685946e34213d93a9a9b13e6f2ce50..3c7d126561928d78dc3dac5dccea4d6320e3d047 100644
--- a/src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.targets
+++ b/src/Components/Blazor/Build/src/targets/Blazor.MonoRuntime.targets
@@ -1,653 +1,336 @@
 <Project>
+  <PropertyGroup>
+    <BlazorLinkOnBuild Condition="$(BlazorLinkOnBuild) == ''">true</BlazorLinkOnBuild>
+  </PropertyGroup>
+
+  <PropertyGroup>
+    <!-- Stop-gap until we can migrate Blazor.Mono package to use better naming convention -->
+    <DotNetWebAssemblyBCLPath Condition="'$(DotNetWebAssemblyBCLPath)' == '' AND '$(MonoBaseClassLibraryPath)' != ''">$(MonoBaseClassLibraryPath)</DotNetWebAssemblyBCLPath>
+    <DotNetWebAssemblyBCLFacadesPath Condition="'$(DotNetWebAssemblyBCLFacadesPath)' == '' AND '$(MonoBaseClassLibraryFacadesPath)' != ''">$(MonoBaseClassLibraryFacadesPath)</DotNetWebAssemblyBCLFacadesPath>
+    <DotNetWebAssemblyRuntimePath Condition="'$(DotNetWebAssemblyRuntimePath)' == '' AND '$(MonoWasmRuntimePath)' != ''">$(MonoWasmRuntimePath)</DotNetWebAssemblyRuntimePath>
+    <DotNetWebAssemblyFrameworkPath Condition="'$(DotNetWebAssemblyFrameworkPath)' == '' AND '$(MonoWasmFrameworkPath)' != ''">$(MonoWasmFrameworkPath)</DotNetWebAssemblyFrameworkPath>
+  </PropertyGroup>
+
+  <PropertyGroup Condition="'$(DotNetWebAssemblyArtifactsRoot)' != ''">
+    <!-- Compute paths given a path to DotNet WASM artifacts. This is meant to make it easy to test WASM builds -->
+    <DotNetWebAssemblyBCLPath>$(DotNetWebAssemblyArtifactsRoot)\wasm-bcl\wasm\</DotNetWebAssemblyBCLPath>
+    <DotNetWebAssemblyBCLFacadesPath>$(DotNetWebAssemblyBCLPath)\Facades\</DotNetWebAssemblyBCLFacadesPath>
+    <DotNetWebAssemblyRuntimePath>$(DotNetWebAssemblyArtifactsRoot)\builds\debug\</DotNetWebAssemblyRuntimePath>
+    <DotNetWebAssemblyFrameworkPath>$(DotNetWebAssemblyArtifactsRoot)\framework\</DotNetWebAssemblyFrameworkPath>
+  </PropertyGroup>
 
   <Target
     Name="_BlazorCopyFilesToOutputDirectory"
     DependsOnTargets="PrepareBlazorOutputs"
-    Inputs="@(BlazorItemOutput)"
-    Outputs="@(BlazorItemOutput->'%(TargetOutputPath)')"
     AfterTargets="CopyFilesToOutputDirectory"
     Condition="'$(OutputType.ToLowerInvariant())'=='exe'">
 
     <!-- Copy the blazor output files  -->
     <Copy
-      SourceFiles="@(BlazorItemOutput)"
-      DestinationFiles="@(BlazorItemOutput->'%(TargetOutputPath)')"
+      SourceFiles="@(BlazorOutputWithTargetPath)"
+      DestinationFiles="@(BlazorOutputWithTargetPath->'$(TargetDir)%(TargetOutputPath)')"
       SkipUnchangedFiles="$(SkipCopyUnchangedFiles)"
       OverwriteReadOnlyFiles="$(OverwriteReadOnlyFiles)"
       Retries="$(CopyRetryCount)"
       RetryDelayMilliseconds="$(CopyRetryDelayMilliseconds)"
       UseHardlinksIfPossible="$(CreateHardLinksForCopyFilesToOutputDirectoryIfPossible)"
       UseSymboliclinksIfPossible="$(CreateSymbolicLinksForCopyFilesToOutputDirectoryIfPossible)"
-      Condition="'@(BlazorItemOutput)' != '' and '$(CopyBuildOutputToOutputDirectory)' == 'true' and '$(SkipCopyBuildProduct)' != 'true'">
+      Condition="'@(BlazorOutputWithTargetPath)' != '' and '$(CopyBuildOutputToOutputDirectory)' == 'true' and '$(SkipCopyBuildProduct)' != 'true'">
     </Copy>
 
     <ItemGroup>
-      <FileWrites Include="@(BlazorItemOutput->'%(TargetOutputPath)')" />
+      <FileWrites Include="@(BlazorOutputWithTargetPath->'$(TargetDir)%(TargetOutputPath)')" />
     </ItemGroup>
-  </Target>
-
-  <Target Name="_BlazorTrackResolveReferencesDidRun" AfterTargets="ResolveReferences">
-    <PropertyGroup>
-      <!-- So we know we can trust @(ReferenceCopyLocalPaths) later -->
-      <_BlazorResolveReferencesDidRun>true</_BlazorResolveReferencesDidRun>
-    </PropertyGroup>
-  </Target>
-
-  <Target Name="_BlazorBuildReport"
-          AfterTargets="_BlazorCopyFilesToOutputDirectory">
 
     <ItemGroup>
-      <_BlazorStatisticsOutput Include="@(BlazorItemOutput->'%(TargetOutputPath)')" />
+      <_BlazorStatisticsOutput Include="@(BlazorOutputWithTargetPath->'%(TargetOutputPath)')" />
     </ItemGroup>
-    <PropertyGroup>
-      <_BlazorStatisticsReportImportance Condition="'$(BlazorOutputStatistics)' == ''">normal</_BlazorStatisticsReportImportance>
-      <_BlazorStatisticsReportImportance Condition="'$(BlazorOutputStatistics)' != ''">high</_BlazorStatisticsReportImportance>
-    </PropertyGroup>
 
-    <Message Importance="high" Text="Blazor Build result -> @(_BlazorStatisticsOutput->Distinct()->Count()) files in $(TargetDir)dist" />
-    <Message Importance="$(_BlazorStatisticsReportImportance)" Text="%(_BlazorStatisticsOutput.Identity)" />
+    <Message Importance="high" Text="$(TargetName) (Blazor output) -> $(TargetDir)dist" />
   </Target>
 
-  <!-- Preparing blazor files for output:
-    PrepareBlazorOutputs
-      _PrepareBlazorOutputConfiguration
-      _DefineBlazorCommonInputs
-      _BlazorResolveOutputBinaries
-        When link on build:
-          _GenerateLinkerDescriptor
-          _CollectBlazorLinkerDescriptors
-          _LinkBlazorApplication
-          _CollectLinkerOutputs
-        When don't link on build:
-          _CollectResolvedAssemblies
-            _ResolveBlazorApplicationAssemblies
-            _ReadResolvedBlazorApplicationAssemblies
-            _IntermediateCopyBlazorApplicationAssemblies
-            _TouchBlazorApplicationAssemblies
-      _GenerateBlazorBootJson
-    _BlazorCopyFilesToOutputDirectory
-
-    The process for doing builds goes as follows:
-    Produce a hash file with the Hash SDK task and write that hash to a marker file.
-    Produce a marker file that saves whether we are linking or not in this build so that we can take that as
-    input in future builds and do the correct thing for incremental builds.
-    We only produce marker files when the input changes, if the input doesn't change the marker stays the
-    same.
-
-    If we are linking on this build the process is as follows:
-    1) We determine if there are linker descriptors available, if not generate one.
-    2) Collect the list of linker descriptors and create a marker for the linker if it doesn't exist or changed
-       from a previous build.
-    3) Run the linker in case the linker inputs marker is newer than the linker result file.
-    4) Collect the outputs from the linker.
-
-    If we are not linking in this build the process is as follows:
-    1) Resolve the assemblies for the application only if the inputs marker is newer than the resolved assemblies
-       result file.
-    2) Read the result file with the resolved assemblies.
-    3) Copy the resolved assemblies to an intermediate folder.
-    4) In case we are switching from linking to not linking, touch the files in the intermediate folder to ensure
-       that updated versions of the files get copied to the output folder.
-
-    Once the binary outputs are resolved:
-    1) Create a marker file with the resolved assemblies and the boot json data as inputs.
-    2) If the marker file is newer than the boot json in the output folder, regenerate the
-       boot json
-
-    Once all the outputs are resolved (static content + binary outputs + boot json)
-    Copy all the files to the output folder.
-    -->
-
-  <PropertyGroup>
-    <PrepareBlazorOutputs>
-      _PrepareBlazorOutputConfiguration;
-      _DefineBlazorCommonInputs;
-      _BlazorResolveOutputBinaries;
-      _GenerateBlazorBootJson;
-    </PrepareBlazorOutputs>
-  </PropertyGroup>
-
-  <Target Name="PrepareBlazorOutputs" DependsOnTargets="$(PrepareBlazorOutputs)" />
-
-  <!--
-  Prepare blazor outputs preamble:
-  * Creates updated marker files (if necessary) for incremental builds.
-  * Computes intermediate and final output paths.
-  * Computes the list of static items to copy to the output folder.
-  -->
-
-  <Target Name="_PrepareBlazorOutputConfiguration">
-    <!--
-    This task produces all the "final" paths for all the files we need to produce the final output.
-
-    The final folder is something like bin/<<Configuration>>/<<TargetFramework>>/dist
-    /_framework/_bin <- This will contain either the BCL + app assemblies or the result of linking the app.
-    /_framework/wasm <- This will contain the wsm runtime copied from the nuget package.
-    /_framework/blazor.js <- This is the blazor.js file copied from the nuget package.
-    /_framework/blazor.boot.json <- This is the boot json file
-
-    This task also defines some intermediate paths that we will use:
-    /obj/<<configuration>>/<<targetframework>>/blazor/blazor/linker <- This will be used to create the output from the linker.
-    /obj/<<configuration>>/<<targetframework>>/blazor/blazor/linked.assemblies.txt <- This will be used to save the output files from
-    the linker and use that as marker to identify whether or not we need to run the linker.
-    /obj/<<configuration>>/<<targetframework>>/blazor/blazor/linker.descriptor.xml <- This will be used to generate an XML descriptor
-    for the mono linker.
-    /obj/<<configuration>>/<<targetframework>>/blazor/inputs.basic.cache <- This is the marker file to track the inputs common
-    inputs to the output generation process.
-    /obj/<<configuration>>/<<targetframework>>/blazor/inputs.copylocal.txt <- Paths to all the copy-local referenced assemblies found
-    during the build process (i.e., the @(ReferenceCopyLocalPaths) values). We need this because when publishing, the build doesn't
-    necessarily also run so this is the only way we know which assemblies to include in linking/resolveassemblies.
-    /obj/<<configuration>>/<<targetframework>>/blazor/inputs.linkerswitch.cache <- This is the marker file to track the
-    switch from linking to not linking and viceversa.
-    /obj/<<configuration>>/<<targetframework>>/blazor/inputs.linker.cache <- This is the marker file to track the inputs
-    to the linker.
-    /obj/<<configuration>>/<<targetframework>>/blazor/resolvedassemblies/ <- This will be used to store the resolved assemblies
-    before copying them to the output when linking is not enabled.
-    /obj/<<configuration>>/<<targetframework>>/blazor/resolved.assemblies.txt <- This keeps track of all the resolved assemblies.
-    /obj/<<configuration>>/<<targetframework>>/blazor/blazor.boot.json <- The generated boot json file
-    /obj/<<configuration>>/<<targetframework>>/blazor/inputs.bootjson.cache <- The marker file that track whether boot json needs to
-    be regenerated.
-    -->
-
-    <PropertyGroup Label="Build properties">
-      <_BlazorShouldLinkApplicationAssemblies Condition="$(BlazorLinkOnBuild) == 'false'"></_BlazorShouldLinkApplicationAssemblies>
-      <_BlazorShouldLinkApplicationAssemblies Condition="$(BlazorLinkOnBuild) == 'true'">true</_BlazorShouldLinkApplicationAssemblies>
-      <_BlazorBuiltInBclLinkerDescriptor>$(MSBuildThisFileDirectory)BuiltInBclLinkerDescriptor.xml</_BlazorBuiltInBclLinkerDescriptor>
-    </PropertyGroup>
+  <Target
+    Name="PrepareBlazorOutputs"
+    DependsOnTargets="_ResolveBlazorInputs;_ResolveBlazorOutputs;_GenerateBlazorBootJson">
 
-    <ItemGroup Label="Static content to copy to the output folder">
-      <MonoWasmFile Include="$(MonoWasmRuntimePath)**/*.*" />
-      <BlazorJsFile Include="$(BlazorJsPath)" />
-      <BlazorItemOutput Include="@(MonoWasmFile)">
-        <TargetOutputPath>$(TargetDir)$(BaseBlazorRuntimeWasmOutputPath)%(FileName)%(Extension)</TargetOutputPath>
-        <Type>WebAssembly</Type>
-        <IsStatic>true</IsStatic>
-      </BlazorItemOutput>
-      <BlazorItemOutput Include="@(BlazorJsFile)">
-        <TargetOutputPath>$(TargetDir)$(BaseBlazorJsOutputPath)%(FileName)%(Extension)</TargetOutputPath>
-        <Type>BlazorRuntime</Type>
-        <IsStatic>true</IsStatic>
-      </BlazorItemOutput>
+    <ItemGroup>
+      <MonoWasmFile Include="$(DotNetWebAssemblyRuntimePath)*" />
+      <BlazorJSFile Include="$(BlazorJSPath)" />
+      <BlazorJSFile Include="$(BlazorJSMapPath)" Condition="Exists('$(BlazorJSMapPath)')" />
+
+      <BlazorOutputWithTargetPath Include="@(MonoWasmFile)">
+        <TargetOutputPath>$(BlazorRuntimeWasmOutputPath)%(FileName)%(Extension)</TargetOutputPath>
+      </BlazorOutputWithTargetPath>
+      <BlazorOutputWithTargetPath Include="@(BlazorJSFile)">
+        <TargetOutputPath>$(BaseBlazorRuntimeOutputPath)%(FileName)%(Extension)</TargetOutputPath>
+      </BlazorOutputWithTargetPath>
     </ItemGroup>
 
-    <Error Condition="'@(BlazorJsFile->Count())' == '0'" Text="No JS files found in '$(BlazorJsPath)'" />
-
     <ItemGroup Label="Static content supplied by NuGet packages">
       <_BlazorPackageContentOutput Include="@(BlazorPackageContentFile)" Condition="%(SourcePackage) != ''">
-        <TargetOutputPath>$(TargetDir)$(BaseBlazorPackageContentOutputPath)%(SourcePackage)\%(RecursiveDir)\%(Filename)%(Extension)</TargetOutputPath>
-        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        <TargetOutputPath>$(BaseBlazorPackageContentOutputPath)%(SourcePackage)\%(RecursiveDir)\%(Filename)%(Extension)</TargetOutputPath>
       </_BlazorPackageContentOutput>
-      <BlazorItemOutput Include="@(_BlazorPackageContentOutput)" />
+      <BlazorOutputWithTargetPath Include="@(_BlazorPackageContentOutput)" />
     </ItemGroup>
+  </Target>
 
-    <PropertyGroup Label="Intermediate output paths">
-
+  <Target Name="_ResolveBlazorInputs" DependsOnTargets="ResolveReferences;ResolveRuntimePackAssets">
+    <PropertyGroup>
       <!-- /obj/<<configuration>>/<<targetframework>>/blazor -->
-      <BlazorIntermediateOutputPath>$(IntermediateOutputPath)$(BaseBlazorIntermediateOutputPath)</BlazorIntermediateOutputPath>
-      <BlazorIntermediateOutputPath Condition="! $([System.IO.Path]::IsPathRooted($(BlazorIntermediateOutputPath)))">$([MSBuild]::Escape($([System.IO.Path]::GetFullPath('$([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(BlazorIntermediateOutputPath)'))'))))</BlazorIntermediateOutputPath>
-
-      <!-- Common marker files paths -->
-
-      <!-- /obj/<<configuration>>/<<targetframework>>/blazor/inputs.basic.cache -->
-      <BlazorBuildCommonInputsCache>$(BlazorIntermediateOutputPath)inputs.basic.cache</BlazorBuildCommonInputsCache>
-
-      <!-- /obj/<<configuration>>/<<targetframework>>/blazor/inputs.copylocal.txt -->
-      <BlazorLocalReferencesOutputPath>$(BlazorIntermediateOutputPath)inputs.copylocal.txt</BlazorLocalReferencesOutputPath>
-
-      <!-- /obj/<<configuration>>/<<targetframework>>/blazor/inputs.linkerswitch.cache -->
-      <BlazorBuildLinkerSwitchInputsCache>$(BlazorIntermediateOutputPath)inputs.linkerswitch.cache</BlazorBuildLinkerSwitchInputsCache>
-
-      <!-- Linker paths and marker files -->
-
-      <!-- /obj/<<configuration>>/<<targetframework>>/blazor/inputs.linker.cache -->
-      <BlazorBuildLinkerInputsCache>$(BlazorIntermediateOutputPath)inputs.linker.cache</BlazorBuildLinkerInputsCache>
+      <BlazorIntermediateOutputPath>$(IntermediateOutputPath)blazor\</BlazorIntermediateOutputPath>
 
       <!-- /obj/<<configuration>>/<<targetframework>>/blazor/linker.descriptor.xml -->
       <GeneratedBlazorLinkerDescriptor>$(BlazorIntermediateOutputPath)linker.descriptor.xml</GeneratedBlazorLinkerDescriptor>
 
+      <_TypeGranularityLinkerDescriptor>$(BlazorIntermediateOutputPath)linker.typegranularityconfig.xml</_TypeGranularityLinkerDescriptor>
+
       <!-- /obj/<<configuration>>/<<targetframework>>/blazor/linker/ -->
       <BlazorIntermediateLinkerOutputPath>$(BlazorIntermediateOutputPath)linker/</BlazorIntermediateLinkerOutputPath>
 
-      <!-- /obj/<<configuration>>/<<targetframework>>/blazor/linked.assemblies.txt -->
-      <BlazorIntermediateLinkerResultFilePath>$(BlazorIntermediateOutputPath)linked.assemblies.txt</BlazorIntermediateLinkerResultFilePath>
-
-      <!-- Resolved assemblies paths and marker files -->
-
-      <!-- /obj/<<configuration>>/<<targetframework>>/blazor/resolvedassemblies/ -->
-      <BlazorIntermediateResolvedApplicationAssembliesOutputPath>$(BlazorIntermediateOutputPath)resolvedassemblies/</BlazorIntermediateResolvedApplicationAssembliesOutputPath>
-
-      <!-- /obj/<<configuration>>/<<targetframework>>/blazor/resolved.assemblies.txt -->
-      <BlazorResolvedAssembliesOutputPath>$(BlazorIntermediateOutputPath)resolved.assemblies.txt</BlazorResolvedAssembliesOutputPath>
-
-      <!-- boot json related paths and markers -->
-
-      <!-- /obj/<<configuration>>/<<targetframework>>/blazor/ -->
-      <BlazorBootJsonIntermediateOutputDir>$(BlazorIntermediateOutputPath)</BlazorBootJsonIntermediateOutputDir>
-
       <!-- /obj/<<configuration>>/<<targetframework>>/blazor/blazor.boot.json -->
-      <BlazorBootJsonIntermediateOutputPath>$(BlazorBootJsonIntermediateOutputDir)$(BlazorBootJsonName)</BlazorBootJsonIntermediateOutputPath>
+      <BlazorBootJsonIntermediateOutputPath>$(BlazorIntermediateOutputPath)$(BlazorBootJsonName)</BlazorBootJsonIntermediateOutputPath>
 
-      <!-- /obj/<<configuration>>/<<targetframework>>/blazor/inputs.bootjson.cache -->
-      <BlazorBuildBootJsonInputsCache>$(BlazorIntermediateOutputPath)inputs.bootjson.cache</BlazorBuildBootJsonInputsCache>
-
-      <!-- /obj/<<configuration>>/<<targetframework>>/blazor/resolve-dependencies.txt -->
-      <BlazorResolveDependenciesFilePath>$(BlazorIntermediateOutputPath)resolve-dependencies.txt</BlazorResolveDependenciesFilePath>
-
-      <!-- /obj/<<configuration>>/<<targetframework>>/blazor/bootjson-references.txt -->
-      <BlazorBootJsonReferencesFilePath>$(BlazorIntermediateOutputPath)bootjson-references.txt</BlazorBootJsonReferencesFilePath>
-
-      <!-- /obj/<<configuration>>/<<targetframework>>/blazor/embedded.resources.txt -->
-      <BlazorEmbeddedResourcesConfigFilePath>$(BlazorIntermediateOutputPath)embedded.resources.txt</BlazorEmbeddedResourcesConfigFilePath>
-
-    </PropertyGroup>
+      <_BlazorLinkerOutputCache>$(BlazorIntermediateOutputPath)linker.output</_BlazorLinkerOutputCache>
 
-    <PropertyGroup Label="Final output paths">
-      <BlazorRuntimeBinOutputPath>$(TargetDir)$(BaseBlazorRuntimeBinOutputPath)</BlazorRuntimeBinOutputPath>
+      <_BlazorApplicationAssembliesCacheFile>$(BlazorIntermediateOutputPath)unlinked.output</_BlazorApplicationAssembliesCacheFile>
     </PropertyGroup>
 
-    <MakeDir Directories="$(BlazorIntermediateOutputPath)" />
-
-  </Target>
+    <ItemGroup>
+      <_WebAssemblyBCLFolder Include="
+        $(DotNetWebAssemblyBCLPath);
+        $(DotNetWebAssemblyBCLFacadesPath);
+        $(DotNetWebAssemblyFrameworkPath)" />
 
-  <Target Name="_DefineBlazorCommonInputs">
-    <!-- If ResolveReferences hasn't yet run, we must be inside a VS publish process
-         that doesn't also do a build, so use the stored information. -->
-    <ReadLinesFromFile
-      Condition="'$(_BlazorResolveReferencesDidRun)'!='true'"
-      File="$(BlazorLocalReferencesOutputPath)">
-      <Output TaskParameter="Lines" ItemName="_BlazorDependencyInput"/>
-    </ReadLinesFromFile>
-    <ItemGroup Condition="'$(_BlazorResolveReferencesDidRun)'=='true'">
-      <!-- ... otherwise we can get the fresh info from @(ReferenceCopyLocalPaths) -->
-      <_BlazorDependencyInput Include="@(ReferenceCopyLocalPaths->WithMetadataValue('Extension','.dll')->'%(FullPath)')" />
+      <_WebAssemblyBCLAssembly Include="%(_WebAssemblyBCLFolder.Identity)*.dll" />
     </ItemGroup>
 
+    <!--
+      Calculate the assemblies that act as inputs to calculate assembly closure. Based on _ComputeAssembliesToPostprocessOnPublish which is used as input to SDK's linker
+      https://github.com/dotnet/sdk/blob/d597e7b09d7657ba4e326d6734e14fcbf8473564/src/Tasks/Microsoft.NET.Build.Tasks/targets/Microsoft.NET.Publish.targets#L864-L873
+    -->
     <ItemGroup>
-      <_BlazorCommonInput Include="@(IntermediateAssembly)" />
-      <_BlazorCommonInput Include="@(_BlazorDependencyInput)" />
-      <_BlazorCommonInput Include="$(_BlazorShouldLinkApplicationAssemblies)" />
-      <_BlazorCommonInput Include="$(BlazorEnableDebugging)" />
-      <_BlazorLinkingOption Condition="'$(_BlazorShouldLinkApplicationAssemblies)' == ''" Include="false" />
-      <_BlazorLinkingOption Condition="'$(_BlazorShouldLinkApplicationAssemblies)' != ''" Include="true" />
-    </ItemGroup>
+      <!-- Assemblies from packages -->
+      <_BlazorManagedRuntimeAssemby Include="@(RuntimeCopyLocalItems)" />
 
-    <Hash ItemsToHash="@(_BlazorCommonInput)">
-      <Output TaskParameter="HashResult" PropertyName="_BlazorBuildBasicInputHash" />
-    </Hash>
-
-    <WriteLinesToFile
-      Lines="$(_BlazorBuildBasicInputHash)"
-      File="$(BlazorBuildCommonInputsCache)"
-      Overwrite="True"
-      WriteOnlyWhenDifferent="True" />
-
-    <WriteLinesToFile
-      Lines="@(_BlazorDependencyInput)"
-      File="$(BlazorLocalReferencesOutputPath)"
-      Overwrite="True"
-      WriteOnlyWhenDifferent="True" />
-
-    <!-- Switch to detect when we switch from linking to not linking and viceversa -->
-    <WriteLinesToFile
-      Lines="@(_BlazorLinkingOption)"
-      File="$(BlazorBuildLinkerSwitchInputsCache)"
-      Overwrite="True"
-      WriteOnlyWhenDifferent="True" />
+      <!-- Assemblies from other references -->
+      <_BlazorUserRuntimeAssembly Include="@(ReferencePath->WithMetadataValue('CopyLocal', 'true'))"  />
+      <_BlazorUserRuntimeAssembly Include="@(ReferenceDependencyPaths->WithMetadataValue('CopyLocal', 'true'))" />
 
-    <ItemGroup>
-      <FileWrites Include="$(BlazorBuildLinkerSwitchInputsCache)" />
-      <FileWrites Include="$(BlazorBuildCommonInputsCache)" />
-      <FileWrites Include="$(BlazorLocalReferencesOutputPath)" />
+      <_BlazorManagedRuntimeAssemby Include="@(_BlazorUserRuntimeAssembly)" />
+      <_BlazorManagedRuntimeAssemby Include="@(IntermediateAssembly)" />
     </ItemGroup>
 
+    <MakeDir Directories="$(BlazorIntermediateOutputPath)" />
   </Target>
 
-  <Target Name="_BlazorResolveOutputBinaries" DependsOnTargets="_CollectLinkerOutputs;_CollectResolvedAssemblies" />
+  <Target Name="_ResolveBlazorOutputs" DependsOnTargets="_ResolveBlazorOutputsWhenLinked;_ResolveBlazorOutputsWhenNotLinked">
+    <Error
+      Message="Unrecongnized value for BlazorLinkOnBuild: '$(BlazorLinkOnBuild)'. Valid values are 'true' or 'false'."
+      Condition="'$(BlazorLinkOnBuild)' != 'true' AND '$(BlazorLinkOnBuild)' != 'false'" />
+
+    <ItemGroup>
+      <!--
+        ReferenceCopyLocalPaths includes all files that are part of the build out with CopyLocalLockFileAssemblies on.
+        Remove assemblies that are inputs to calculating the assembly closure. Instead use the resolved outputs, since it is the minimal set.
+       -->
+      <_BlazorCopyLocalPaths Include="@(ReferenceCopyLocalPaths)" />
+      <_BlazorCopyLocalPaths Remove="@(_BlazorManagedRuntimeAssemby)" />
+
+      <BlazorOutputWithTargetPath Include="@(_BlazorCopyLocalPaths)">
+        <BlazorRuntimeFile>true</BlazorRuntimeFile>
+        <TargetOutputPath>$(BlazorRuntimeBinOutputPath)%(_BlazorCopyLocalPaths.DestinationSubDirectory)%(FileName)%(Extension)</TargetOutputPath>
+        <RelativeOutputPath>%(_BlazorCopyLocalPaths.DestinationSubDirectory)%(FileName)%(Extension)</RelativeOutputPath>
+      </BlazorOutputWithTargetPath>
+
+      <BlazorOutputWithTargetPath Include="@(_BlazorResolvedAssembly)">
+        <BlazorRuntimeFile>true</BlazorRuntimeFile>
+        <TargetOutputPath>$(BlazorRuntimeBinOutputPath)%(FileName)%(Extension)</TargetOutputPath>
+        <RelativeOutputPath>%(FileName)%(Extension)</RelativeOutputPath>
+      </BlazorOutputWithTargetPath>
+    </ItemGroup>
+  </Target>
 
   <!--
   Linker enabled part of the pipeline:
 
   * If there are no descriptors defined, generate a new linker descriptor.
-  * Collect the list of descriptors and produce a marker file to determine when the
-    inputs to the linker change in future builds.
-  * Invoke the linker if the linker inputs marker file is newer than the linker outputs.
-  * Read the outputs from the linker and add them to the list of blazor outputs.
+  * Invoke the linker and write linked files to a well-known directory.
+  * Collect the outputs of the linker.
   -->
 
-  <PropertyGroup>
-    <_CollectLinkerOutputsDependsOn>
-      _GenerateLinkerDescriptor;
-      _CollectBlazorLinkerDescriptors;
-      _LinkBlazorApplication
-    </_CollectLinkerOutputsDependsOn>
-  </PropertyGroup>
-
   <Target
-    Name="_CollectLinkerOutputs"
-    Condition="'$(_BlazorShouldLinkApplicationAssemblies)' != ''"
-    DependsOnTargets="$(_CollectLinkerOutputsDependsOn)">
-    <!--
-    Read the outputs from the linker (from this run or a previous run) and set them in an item group for
-    later use.
-    -->
-    <ReadLinesFromFile File="$(BlazorIntermediateLinkerResultFilePath)">
-      <Output TaskParameter="Lines" ItemName="_OptimizedFiles"/>
+    Name="_ResolveBlazorOutputsWhenLinked"
+    Condition="'$(BlazorLinkOnBuild)' == 'true'"
+    DependsOnTargets="_PrepareBlazorLinkerInputs;_GenerateBlazorLinkerDescriptor;_GenerateTypeGranularLinkerDescriptor;_LinkBlazorApplication">
+
+    <!-- _BlazorLinkerOutputCache records files linked during the last incremental build of the target. Read the contents and assign linked files to be copied to the output. -->
+    <ReadLinesFromFile File="$(_BlazorLinkerOutputCache)">
+      <Output TaskParameter="Lines" ItemName="_BlazorResolvedAssembly"/>
     </ReadLinesFromFile>
+  </Target>
 
+  <Target Name="_PrepareBlazorLinkerInputs">
     <ItemGroup>
-      <BlazorItemOutput Include="@(_OptimizedFiles->WithMetadataValue('Extension','.dll'))">
-        <TargetOutputPath>$(BlazorRuntimeBinOutputPath)%(FileName)%(Extension)</TargetOutputPath>
-        <Type>Assembly</Type>
-        <PrimaryOutput Condition="'%(FileName)' == @(IntermediateAssembly->'%(FileName)')">true</PrimaryOutput>
-      </BlazorItemOutput>
-      <BlazorItemOutput Include="@(_OptimizedFiles->WithMetadataValue('Extension','.pdb'))">
-        <TargetOutputPath>$(BlazorRuntimeBinOutputPath)%(FileName)%(Extension)</TargetOutputPath>
-        <Type>Pdb</Type>
-      </BlazorItemOutput>
-      <FileWrites Include="@(BlazorItemOutput->WithMetadataValue('Type','Assembly')->'%(TargetOutputPath)')" />
+      <_BlazorRuntimeCopyLocalItems Include="@(RuntimeCopyLocalItems)" />
+
+      <!--
+        Any assembly from a package reference that starts with System. file name is allowed to be linked.
+        Assemblies from Microsoft.AspNetCore and Microsoft.Extensions, are also linked but with TypeGranularity.
+      -->
+      <_BlazorRuntimeCopyLocalItems IsLinkable="true" Condition="$([System.String]::Copy('%(Filename)').StartsWith('System.'))" />
+      <_BlazorRuntimeCopyLocalItems IsLinkable="true" TypeGranularity="true" Condition="$([System.String]::Copy('%(Filename)').StartsWith('Microsoft.AspNetCore.'))" />
+      <_BlazorRuntimeCopyLocalItems IsLinkable="true" TypeGranularity="true" Condition="$([System.String]::Copy('%(Filename)').StartsWith('Microsoft.Extensions.'))" />
+
+      <_BlazorAssemblyToLink Include="@(_WebAssemblyBCLAssembly)" />
+      <_BlazorAssemblyToLink Include="@(_BlazorRuntimeCopyLocalItems)" Condition="'%(_BlazorRuntimeCopyLocalItems.IsLinkable)' == 'true'" />
+
+      <_BlazorLinkerRoot Include="@(IntermediateAssembly)" />
+      <_BlazorLinkerRoot Include="@(_BlazorUserRuntimeAssembly)" />
+      <_BlazorLinkerRoot Include="@(_BlazorRuntimeCopyLocalItems)" Condition="'%(_BlazorRuntimeCopyLocalItems.IsLinkable)' != 'true'" />
     </ItemGroup>
 
   </Target>
 
-  <Target Name="_GenerateLinkerDescriptor"
-          Inputs="$(BlazorBuildCommonInputsCache)"
+  <UsingTask TaskName="BlazorCreateRootDescriptorFile" AssemblyFile="$(BlazorTasksPath)" />
+  <Target Name="_GenerateBlazorLinkerDescriptor"
+          Inputs="@(IntermediateAssembly)"
           Outputs="$(GeneratedBlazorLinkerDescriptor)"
-          Condition="$(_BlazorShouldLinkApplicationAssemblies) != '' and '@(BlazorLinkerDescriptor)' == ''">
-
-    <ItemGroup>
-      <_PrepareLinkerDescriptorAssemblyLine Include="@(IntermediateAssembly->'%(FileName)')" />
-      <_GeneratedLinkerDescriptorLine Include="&lt;linker&gt;" />
-      <_GeneratedLinkerDescriptorLine Include="@(_PrepareLinkerDescriptorAssemblyLine->'&lt;assembly fullname=&quot;%(Identity)&quot; /&gt;')" />
-      <_GeneratedLinkerDescriptorLine Include="&lt;/linker&gt;" />
-    </ItemGroup>
+          Condition="'@(BlazorLinkerDescriptor)' == ''">
 
-    <WriteLinesToFile
-      Lines="@(_GeneratedLinkerDescriptorLine)"
-      File="$(GeneratedBlazorLinkerDescriptor)"
-      Overwrite="true"
-      WriteOnlyWhenDifferent="True" />
+    <!-- Generate linker descriptors if the project doesn't explicitly provide one. -->
 
-  </Target>
-
-  <Target Name="_CollectBlazorLinkerDescriptors">
-
-    <ItemGroup Condition="@(BlazorLinkerDescriptor) == ''">
-      <BlazorLinkerDescriptor Include="$(_BlazorBuiltInBclLinkerDescriptor)" />
-      <BlazorLinkerDescriptor Include="$(GeneratedBlazorLinkerDescriptor)" />
-      <FileWrites Include="$(GeneratedBlazorLinkerDescriptor)" />
-    </ItemGroup>
+    <BlazorCreateRootDescriptorFile
+      AssemblyNames="@(IntermediateAssembly->'%(Filename)')"
+      RootDescriptorFilePath="$(GeneratedBlazorLinkerDescriptor)" />
 
     <ItemGroup>
-      <_BlazorLinkerInput Include="@(IntermediateAssembly)" />
-      <_BlazorLinkerInput Include="@(_BlazorDependencyInput)" />
-      <_BlazorLinkerInput Include="@(BlazorLinkerDescriptor)" />
-      <_BlazorLinkerInput Include="$(AdditionalMonoLinkerOptions)" />
+      <FileWrites Include="$(GeneratedBlazorLinkerDescriptor)" />
+      <BlazorLinkerDescriptor Include="$(GeneratedBlazorLinkerDescriptor)" />
+      <BlazorLinkerDescriptor Include="$(_BlazorBuiltInBclLinkerDescriptor)" />
     </ItemGroup>
+  </Target>
 
-    <Hash ItemsToHash="@(_BlazorLinkerInput)">
-      <Output TaskParameter="HashResult" PropertyName="_BlazorLinkerInputHash" />
-    </Hash>
+  <UsingTask TaskName="GenerateTypeGranularityLinkingConfig" AssemblyFile="$(BlazorTasksPath)" />
+  <Target Name="_GenerateTypeGranularLinkerDescriptor"
+          Inputs="@(_BlazorAssemblyToLink->WithMetadataValue('TypeGranularity', 'true'))"
+          Outputs="$(_TypeGranularityLinkerDescriptor)">
 
-    <WriteLinesToFile
-      Lines="$(_BlazorLinkerInputHash)"
-      File="$(BlazorBuildLinkerInputsCache)"
-      Overwrite="True"
-      WriteOnlyWhenDifferent="True" />
+    <GenerateTypeGranularityLinkingConfig
+      Assemblies="@(_BlazorAssemblyToLink->WithMetadataValue('TypeGranularity', 'true'))"
+      OutputPath="$(_TypeGranularityLinkerDescriptor)" />
 
     <ItemGroup>
-      <FileWrites Include="$(BlazorBuildLinkerInputsCache)" />
+      <BlazorLinkerDescriptor Include="$(_TypeGranularityLinkerDescriptor)" />
+      <FileWrites Include="$(_TypeGranularityLinkerDescriptor)" />
     </ItemGroup>
-
   </Target>
 
+  <UsingTask TaskName="BlazorILLink" AssemblyFile="$(BlazorTasksPath)" />
   <Target
       Name="_LinkBlazorApplication"
-      Condition="$(_BlazorShouldLinkApplicationAssemblies) != ''"
-      Inputs="$(BlazorBuildLinkerInputsCache);
-              @(IntermediateAssembly);
-              @(_BlazorDependencyInput);
-              @(BlazorLinkerDescriptor)"
-      Outputs="$(BlazorIntermediateLinkerResultFilePath)"
-    >
-    <!--
-    At this point we have decided to run the mono linker on the Blazor assembly and its dependencies.
-    The steps to run the mono linker are the following:
-    1) Clear the linker output directory if not clean before hand, as we don't know what the outputs of
-    the linker will be.
-    2) Run the linker on the main assembly, its dependencies and pass in the BCL folders to do the lookup
-    for framework assemblies.
-    3) Once we've run the linker we need to capture the produced output and generate a marker file containing
-    the list of produced files. This file will act as a marker to skip running the linker if none of the inputs
-    has changed.
-    4) Add the file we just created to the list of file writes, to support incremental builds.
-    -->
-    <ItemGroup>
-      <_MonoBaseClassLibraryFolder Include="$(MonoBaseClassLibraryPath);$(MonoBaseClassLibraryFacadesPath);$(MonoWasmFrameworkPath)" />
-      <_BlazorAssembliesToLink Include="@(_BlazorDependencyInput->'-a &quot;%(Identity)&quot;')" />
-      <_BlazorAssembliesToLink Include="@(IntermediateAssembly->'-a &quot;%(FullPath)&quot;')" />
-      <_BlazorFolderLookupPaths Include="@(_MonoBaseClassLibraryFolder->'-d &quot;%(Identity)&quot;')" />
-      <_BlazorAssemblyDescriptorFiles
-        Include="@(BlazorLinkerDescriptor->'-x &quot;%(FullPath)&quot;')" Condition="'@(BlazorLinkerDescriptor)' != ''" />
-    </ItemGroup>
+      Inputs="$(ProjectAssetsFile);
+              @(_BlazorManagedRuntimeAssemby);
+              @(BlazorLinkerDescriptor);
+              $(MSBuildAllProjects)"
+      Outputs="$(_BlazorLinkerOutputCache)">
 
     <PropertyGroup>
       <_BlazorLinkerAdditionalOptions>-l $(MonoLinkerI18NAssemblies) $(AdditionalMonoLinkerOptions)</_BlazorLinkerAdditionalOptions>
     </PropertyGroup>
 
-    <!-- Clear the contents of /obj/<<configuration>>/<<targetframework>>/blazor/blazor/linker -->
-    <Delete Files="$(BlazorIntermediateLinkerOutputPath)*.dll" />
-
-    <!-- Run the linker and put the results in /obj/<<configuration>>/<<targetframework>>/blazor/blazor/linker -->
-    <Exec Command="dotnet &quot;$(MonoLinkerPath)&quot; $(_BlazorLinkerAdditionalOptions) @(_BlazorFolderLookupPaths, ' ') -o &quot;$(BlazorIntermediateLinkerOutputPath)&quot; @(_BlazorAssemblyDescriptorFiles, ' ') @(_BlazorAssembliesToLink, ' ')"  />
-
-    <!-- Collect the contents of /obj/<<configuration>>/<<targetframework>>/blazor/blazor/linker/ -->
     <ItemGroup>
-      <_BlazorLinkerOutput Include="$(BlazorIntermediateLinkerOutputPath)*.dll" />
-      <_BlazorLinkerOutput Include="$(BlazorIntermediateLinkerOutputPath)*.pdb" />
+      <_OldLinkedFile Include="$(BlazorIntermediateLinkerOutputPath)*.dll" />
+      <_OldLinkedFile Include="$(BlazorIntermediateLinkerOutputPath)*.pdb" />
     </ItemGroup>
 
-    <!--
-    Write the list of files in /obj/<<configuration>>/<<targetframework>>/blazor/blazor/linker/ into
-    /obj/<<configuration>>/<<targetframework>>/blazor/blazor/linked.assemblies.txt
-    -->
-    <WriteLinesToFile
-      File="$(BlazorIntermediateLinkerResultFilePath)"
-      Lines="@(_BlazorLinkerOutput)"
-      Overwrite="true" />
-
-    <!-- Add /obj/<<configuration>>/<<targetframework>>/blazor/blazor/linked.assemblies.txt to the list of written files. -->
-    <!-- Add /obj/<<configuration>>/<<targetframework>>/blazor/blazor/linker/*.dll to the list of written files. -->
-    <ItemGroup>
-      <FileWrites Include="$(BlazorIntermediateLinkerResultFilePath)" />
-      <FileWrites Include="@(_BlazorLinkerOutput)" />
-    </ItemGroup>
-  </Target>
-
-  <!--
-  Linker disabled part of the pipeline:
-
-  * Run a CLI tool to produce the transitive closure of application references using the main application
-    as entry point.
-  * Read the list of resolved application references from the file produced by the previous step.
-  * Copy the resolved application references into an intermediate folder.
-  * If we are switching from linking to not linking
-    Touch the files in the intermediate folder to ensure they are copied to the output and replace
-    the linked versions with the same name.
-  * Collect the list of resolved assemblies in the intermediate output folder and prepare them to be
-    copied to their final destination in the output folder.
-  -->
-
-  <PropertyGroup>
-    <_CollectResolvedAssembliesDependsOn>
-      _ResolveBlazorApplicationAssemblies;
-      _ReadResolvedBlazorApplicationAssemblies;
-      _IntermediateCopyBlazorApplicationAssemblies;
-      _TouchBlazorApplicationAssemblies
-    </_CollectResolvedAssembliesDependsOn>
-  </PropertyGroup>
-
-  <Target
-    Name="_CollectResolvedAssemblies"
-    DependsOnTargets="$(_CollectResolvedAssembliesDependsOn)"
-    Condition="'$(_BlazorShouldLinkApplicationAssemblies)' == ''">
+    <Delete Files="@(_OldLinkedFile)" />
 
     <!--
-    At this point we have decided not to run the linker and instead to just copy the assemblies
-    from the BCL referenced by the app the nuget package into the _framework/_bin folder.
-    The only thing we need to do here is collect the list of items that will go into _framework/_bin.
+      When running from Desktop MSBuild, DOTNET_HOST_PATH is not set.
+      In this case, explicitly specify the path to the dotnet host.
     -->
-
-    <ItemGroup>
-      <BlazorItemOutput Include="@(_IntermediateResolvedRuntimeDependencies->WithMetadataValue('Extension','.dll'))">
-        <TargetOutputPath>$(BlazorRuntimeBinOutputPath)%(FileName)%(Extension)</TargetOutputPath>
-        <Type>Assembly</Type>
-        <PrimaryOutput Condition="'%(FileName)' == @(IntermediateAssembly->'%(FileName)')">true</PrimaryOutput>
-      </BlazorItemOutput>
-      <BlazorItemOutput Include="@(_IntermediateResolvedRuntimeDependencies->WithMetadataValue('Extension','.pdb'))">
-        <TargetOutputPath>$(BlazorRuntimeBinOutputPath)%(FileName)%(Extension)</TargetOutputPath>
-        <Type>Pdb</Type>
-      </BlazorItemOutput>
-      <FileWrites Include="@(BlazorItemOutput->WithMetadataValue('Type','Assembly')->'%(TargetOutputPath)')" />
-    </ItemGroup>
-  </Target>
-
-  <Target
-    Name="_ResolveBlazorApplicationAssemblies"
-    Condition="'$(_BlazorShouldLinkApplicationAssemblies)' == ''"
-    Inputs="$(BlazorBuildCommonInputsCache);
-            @(IntermediateAssembly);
-            @(_BlazorDependencyInput)"
-    Outputs="$(BlazorResolvedAssembliesOutputPath)"
-  >
-
-    <PropertyGroup>
-      <_ReferencesArg Condition="'@(_BlazorDependencyInput)' != ''">--references &quot;$(BlazorResolveDependenciesFilePath)&quot;</_ReferencesArg>
-      <_BclParameter>--base-class-library &quot;$(MonoBaseClassLibraryPath)&quot; --base-class-library &quot;$(MonoBaseClassLibraryFacadesPath)&quot; --base-class-library &quot;$(MonoWasmFrameworkPath)&quot;</_BclParameter>
+    <PropertyGroup Condition=" '$(DOTNET_HOST_PATH)' == '' ">
+      <_DotNetHostDirectory>$(NetCoreRoot)</_DotNetHostDirectory>
+      <_DotNetHostFileName>dotnet</_DotNetHostFileName>
+      <_DotNetHostFileName Condition=" '$(OS)' == 'Windows_NT' ">dotnet.exe</_DotNetHostFileName>
     </PropertyGroup>
 
-    <WriteLinesToFile
-          File="$(BlazorResolveDependenciesFilePath)"
-          Lines="@(_BlazorDependencyInput)"
-          Overwrite="true" />
-
-    <Exec Command="$(BlazorBuildExe) resolve-dependencies &quot;@(IntermediateAssembly->'%(FullPath)')&quot; $(_ReferencesArg) $(_BclParameter) --output &quot;$(BlazorResolvedAssembliesOutputPath)&quot;" />
-
-  </Target>
-
-  <Target Name="_ReadResolvedBlazorApplicationAssemblies">
-
-    <ReadLinesFromFile File="$(BlazorResolvedAssembliesOutputPath)">
-      <Output TaskParameter="Lines" ItemName="_BlazorResolvedRuntimeDependencies"/>
-    </ReadLinesFromFile>
-
-    <ItemGroup>
-      <_IntermediateResolvedRuntimeDependencies Include="@(_BlazorResolvedRuntimeDependencies->'$(BlazorIntermediateResolvedApplicationAssembliesOutputPath)%(FileName)%(Extension)')" />
-    </ItemGroup>
+    <BlazorILLink
+        ILLinkPath="$(MonoLinkerPath)"
+        AssemblyPaths="@(_BlazorAssemblyToLink)"
+        RootAssemblyNames="@(_BlazorLinkerRoot)"
+        RootDescriptorFiles="@(BlazorLinkerDescriptor)"
+        OutputDirectory="$(BlazorIntermediateLinkerOutputPath)"
+        ExtraArgs="$(_BlazorLinkerAdditionalOptions)"
+        ToolExe="$(_DotNetHostFileName)"
+        ToolPath="$(_DotNetHostDirectory)" />
 
     <ItemGroup>
-      <FileWrites Include="$(BlazorResolvedAssembliesOutputPath)" />
-      <FileWrites Include="@(_IntermediateResolvedRuntimeDependencies)" />
+      <_LinkerResult Include="$(BlazorIntermediateLinkerOutputPath)*.dll" />
+      <_LinkerResult Include="$(BlazorIntermediateLinkerOutputPath)*.pdb" Condition="'$(BlazorEnableDebugging)' == 'true'" />
     </ItemGroup>
 
+    <WriteLinesToFile File="$(_BlazorLinkerOutputCache)" Lines="@(_LinkerResult)" Overwrite="true" />
   </Target>
 
+  <UsingTask TaskName="ResolveBlazorRuntimeDependencies" AssemblyFile="$(BlazorTasksPath)" />
   <Target
-    Name="_IntermediateCopyBlazorApplicationAssemblies"
-    Inputs="@(_BlazorResolvedRuntimeDependencies)"
-    Outputs="@(_BlazorResolvedRuntimeDependencies->'$(BlazorIntermediateResolvedApplicationAssembliesOutputPath)%(FileName)%(Extension)')">
-
-    <Copy
-      SourceFiles="@(_BlazorResolvedRuntimeDependencies)"
-      DestinationFiles="@(_BlazorResolvedRuntimeDependencies->'$(BlazorIntermediateResolvedApplicationAssembliesOutputPath)%(FileName)%(Extension)')"
-      SkipUnchangedFiles="$(SkipCopyUnchangedFiles)"
-      OverwriteReadOnlyFiles="$(OverwriteReadOnlyFiles)"
-      Retries="$(CopyRetryCount)"
-      RetryDelayMilliseconds="$(CopyRetryDelayMilliseconds)"
-      UseHardlinksIfPossible="$(CreateHardLinksForCopyFilesToOutputDirectoryIfPossible)"
-      UseSymboliclinksIfPossible="$(CreateSymbolicLinksForCopyFilesToOutputDirectoryIfPossible)" />
+    Name="_ResolveBlazorOutputsWhenNotLinked"
+    DependsOnTargets="_ResolveBlazorRuntimeDependencies"
+    Condition="'$(BlazorLinkOnBuild)' != 'true'">
 
+    <ReadLinesFromFile File="$(_BlazorApplicationAssembliesCacheFile)" Condition="'@(_BlazorResolvedAssembly->Count())' == '0'">
+      <Output TaskParameter="Lines" ItemName="_BlazorResolvedAssembly"/>
+    </ReadLinesFromFile>
   </Target>
 
   <Target
-    Name="_TouchBlazorApplicationAssemblies"
-    Inputs="$(BlazorBuildLinkerSwitchInputsCache)"
-    Outputs="@(_IntermediateResolvedRuntimeDependencies)">
-
-    <Touch Files="@(_IntermediateResolvedRuntimeDependencies)" ForceTouch="true" />
-
-  </Target>
-
-  <!--
-  Final part of the build pipeline:
+    Name="_ResolveBlazorRuntimeDependencies"
+    Inputs="$(ProjectAssetsFile);
+            @(IntermediateAssembly);
+            @(_BlazorManagedRuntimeAssemby)"
+    Outputs="$(_BlazorApplicationAssembliesCacheFile)">
 
-  * Collect the blazor application assemblies to be copied to the output and create a marker file.
-  * Call our CLI tool to generate the boot json if the list of assemblies has changed.
-  -->
+    <!--
+    At this point we have decided not to run the linker and instead to just copy the assemblies
+    from the BCL referenced by the app the nuget package into the _framework/_bin folder.
+    The only thing we need to do here is collect the list of items that will go into _framework/_bin.
+    -->
+    <ResolveBlazorRuntimeDependencies
+      EntryPoint="@(IntermediateAssembly)"
+      ApplicationDependencies="@(_BlazorManagedRuntimeAssemby)"
+      WebAssemblyBCLAssemblies="@(_WebAssemblyBCLAssembly)">
 
-  <Target Name="_ResolveBlazorBootJsonInputs">
-    <ItemGroup>
-      <BlazorBootJsonInput Include="$(Configuration)" />
-      <BlazorBootJsonInput Include="@(BlazorItemOutput->WithMetadataValue('Type','Assembly')->'%(FullPath)')" />
-      <BlazorBootJsonInput Include="@(BlazorItemOutput->WithMetadataValue('Type','Pdb')->'%(FullPath)')" />
-      <BlazorBootJsonInput Include="@(_BlazorLinkingOption)" />
-      <BlazorBootJsonInput Include="$(BlazorEnableDebugging)" />
-    </ItemGroup>
+      <Output TaskParameter="Dependencies" ItemName="_BlazorResolvedAssembly" />
+    </ResolveBlazorRuntimeDependencies>
 
-    <WriteLinesToFile
-      File="$(BlazorBuildBootJsonInputsCache)"
-      Lines="@(BlazorBootJsonInput)"
-      Overwrite="true"
-      WriteOnlyWhenDifferent="True" />
+    <WriteLinesToFile File="$(_BlazorApplicationAssembliesCacheFile)" Lines="@(_BlazorResolvedRuntimeDependencies)" Overwrite="true" />
 
     <ItemGroup>
-      <FileWrites Include="$(BlazorBuildBootJsonInputsCache)" />
+      <FileWrites Include="$(_BlazorApplicationAssembliesCacheFile)" />
     </ItemGroup>
-
   </Target>
 
+  <UsingTask TaskName="GenerateBlazorBootJson" AssemblyFile="$(BlazorTasksPath)" />
+
   <Target
     Name="_GenerateBlazorBootJson"
-    DependsOnTargets="_ResolveBlazorBootJsonInputs"
-    Inputs="$(BlazorBuildBootJsonInputsCache);@(_BlazorDependencyInput)"
+    Inputs="@(BlazorOutputWithTargetPath)"
     Outputs="$(BlazorBootJsonIntermediateOutputPath)">
     <ItemGroup>
-      <_UnlinkedAppReferencesPaths Include="@(_BlazorDependencyInput)" />
-      <_AppReferences Include="@(BlazorItemOutput->WithMetadataValue('Type','Assembly')->WithMetadataValue('PrimaryOutput','')->'%(FileName)%(Extension)')" />
-      <_AppReferences Include="@(BlazorItemOutput->WithMetadataValue('Type','Pdb')->'%(FileName)%(Extension)')" Condition="'$(BlazorEnableDebugging)' == 'true'" />
+      <_BlazorRuntimeFile Include="@(BlazorOutputWithTargetPath->WithMetadataValue('BlazorRuntimeFile', 'true'))" />
     </ItemGroup>
-    <PropertyGroup>
-      <_LinkerEnabledFlag Condition="'$(_BlazorShouldLinkApplicationAssemblies)' != ''">--linker-enabled</_LinkerEnabledFlag>
-      <_ReferencesArg Condition="'@(_AppReferences)' != ''">--references &quot;$(BlazorBootJsonReferencesFilePath)&quot;</_ReferencesArg>
-      <_EmbeddedResourcesArg Condition="'@(_UnlinkedAppReferencesPaths)' != ''">--embedded-resources &quot;$(BlazorEmbeddedResourcesConfigFilePath)&quot;</_EmbeddedResourcesArg>
-    </PropertyGroup>
 
-    <WriteLinesToFile
-      File="$(BlazorBootJsonReferencesFilePath)"
-      Lines="@(_AppReferences)"
-      Overwrite="true" />
-
-    <WriteLinesToFile
-      Condition="'@(_UnlinkedAppReferencesPaths)' != ''"
-      File="$(BlazorEmbeddedResourcesConfigFilePath)"
-      Lines="@(_UnlinkedAppReferencesPaths)"
-      Overwrite="true" />
-
-    <Exec Command="$(BlazorBuildExe) write-boot-json &quot;@(IntermediateAssembly)&quot; $(_ReferencesArg) $(_EmbeddedResourcesArg) $(_LinkerEnabledFlag) --output &quot;$(BlazorBootJsonIntermediateOutputPath)&quot;" />
-
-    <ItemGroup Condition="Exists('$(BlazorBootJsonIntermediateOutputPath)')">
-      <_BlazorBootJson Include="$(BlazorBootJsonIntermediateOutputPath)" />
-      <_BlazorBootJsonEmbeddedContentFile Include="$(BlazorBootJsonIntermediateOutputDir)_content\**\*.*" />
-      <BlazorItemOutput Include="@(_BlazorBootJson)">
-        <TargetOutputPath>$(TargetDir)$(BlazorBootJsonOutputPath)</TargetOutputPath>
-        <Type>BootJson</Type>
-      </BlazorItemOutput>
-      <BlazorItemOutput Include="@(_BlazorBootJsonEmbeddedContentFile)">
-        <TargetOutputPath>$(TargetDir)dist/_content/%(RecursiveDir)%(FileName)%(Extension)</TargetOutputPath>
-      </BlazorItemOutput>
+    <GenerateBlazorBootJson
+      AssemblyPath="@(IntermediateAssembly)"
+      References="@(_BlazorRuntimeFile)"
+      LinkerEnabled="$(BlazorLinkOnBuild)"
+      OutputPath="$(BlazorBootJsonIntermediateOutputPath)" />
+
+    <ItemGroup>
+      <BlazorOutputWithTargetPath Include="$(BlazorBootJsonIntermediateOutputPath)" TargetOutputPath="$(BaseBlazorRuntimeOutputPath)$(BlazorBootJsonName)" />
       <FileWrites Include="$(BlazorBootJsonIntermediateOutputPath)" />
-      <FileWrites Include="@(_BlazorBootJsonEmbeddedContentFile)" />
     </ItemGroup>
-
   </Target>
 
 </Project>
diff --git a/src/Components/Blazor/Build/src/targets/Publish.targets b/src/Components/Blazor/Build/src/targets/Publish.targets
index e431ad1272cdd2fb9010e08e02a8717e3a1ad816..7cb7e0ad231df448f67a180d595c303aac7c22d8 100644
--- a/src/Components/Blazor/Build/src/targets/Publish.targets
+++ b/src/Components/Blazor/Build/src/targets/Publish.targets
@@ -26,9 +26,8 @@
       </ContentWithTargetPath>
 
       <!-- Publish all the 'dist' files -->
-      <_BlazorGCTPDIDistFiles Include="@(BlazorItemOutput->'%(TargetOutputPath)')" />
-      <_BlazorGCTPDI Include="@(_BlazorGCTPDIDistFiles)">
-        <TargetPath>$(BlazorPublishDistDir)$([MSBuild]::MakeRelative('$(TargetDir)dist\', %(Identity)))</TargetPath>
+      <_BlazorGCTPDI Include="%(BlazorOutputWithTargetPath.Identity)">
+        <TargetPath>$(AssemblyName)\%(TargetOutputPath)</TargetPath>
       </_BlazorGCTPDI>
 
       <ContentWithTargetPath Include="@(_BlazorGCTPDI)">
@@ -41,8 +40,17 @@
     <PropertyGroup>
       <_BlazorConfigPath>$(OutDir)$(AssemblyName).blazor.config</_BlazorConfigPath>
     </PropertyGroup>
-    <WriteLinesToFile File="$(_BlazorConfigPath)" Lines="." Overwrite="true" />
-    <WriteLinesToFile File="$(_BlazorConfigPath)" Lines="$(AssemblyName)/" Overwrite="false" />
+
+    <ItemGroup>
+      <_BlazorPublishConfigContent Include="." />
+      <_BlazorPublishConfigContent Include="$(AssemblyName)/" />
+    </ItemGroup>
+
+    <WriteLinesToFile
+      File="$(_BlazorConfigPath)"
+      Lines="@(_BlazorPublishConfigContent)"
+      Overwrite="true"
+      WriteOnlyWhenDifferent="true" />
   </Target>
 
   <!-- The following target runs only for standalone publishing -->
diff --git a/src/Components/Blazor/Build/src/targets/StaticWebAssets.targets b/src/Components/Blazor/Build/src/targets/StaticWebAssets.targets
new file mode 100644
index 0000000000000000000000000000000000000000..d547f500b13ffbe796890547fe283571dd764b27
--- /dev/null
+++ b/src/Components/Blazor/Build/src/targets/StaticWebAssets.targets
@@ -0,0 +1,37 @@
+<Project>
+
+  <PropertyGroup>
+    <ResolveStaticWebAssetsInputsDependsOn>
+      $(ResolveStaticWebAssetsInputsDependsOn);
+      _RemoveBlazorCurrentProjectAssetsFromStaticWebAssets;
+    </ResolveStaticWebAssetsInputsDependsOn>
+
+    <GetCurrentProjectStaticWebAssetsDependsOn>
+      $(GetCurrentProjectStaticWebAssetsDependsOn);
+      _RemoveBlazorCurrentProjectAssetsFromStaticWebAssets;
+    </GetCurrentProjectStaticWebAssetsDependsOn>
+  </PropertyGroup>
+
+
+  <Target Name="_RemoveBlazorCurrentProjectAssetsFromStaticWebAssets">
+    <ItemGroup>
+      <StaticWebAsset Remove="@(StaticWebAsset)" Condition="'%(SourceType)' == ''" />
+    </ItemGroup>
+  </Target>
+
+  <Target Name="BlazorStaticWebAssetsComputeFilesToPublish"
+    AfterTargets="_StaticWebAssetsComputeFilesToPublish">
+
+    <ItemGroup>
+      <!-- We need to update the external static web assets to follow the blazor publish output convention that puts them inside $(TargetName)/dist instead of wwwroot -->
+      <_StandaloneExternalPublishStaticWebAsset Include="@(_ExternalPublishStaticWebAsset)" Condition="'%(RelativePath)' != ''">
+        <RelativePath>$([MSBuild]::MakeRelative('$(MSBuildProjectDirectory)', '$([MSBuild]::NormalizePath('$([System.Text.RegularExpressions.Regex]::Replace('%(RelativePath)','^wwwroot\\?\/?(.*)','$(BlazorPublishDistDir)$1'))'))'))</RelativePath>
+      </_StandaloneExternalPublishStaticWebAsset>
+
+      <!-- Update doesn't work inside targets so we need to remove the items and re-add them. See https://github.com/microsoft/msbuild/issues/2835 for details -->
+      <ResolvedFileToPublish Remove="@(_StandaloneExternalPublishStaticWebAsset)" />
+      <ResolvedFileToPublish Include="@(_StandaloneExternalPublishStaticWebAsset)" />
+
+    </ItemGroup>
+  </Target>
+</Project>
diff --git a/src/Components/Blazor/Build/test/BlazorCreateRootDescriptorFileTest.cs b/src/Components/Blazor/Build/test/BlazorCreateRootDescriptorFileTest.cs
new file mode 100644
index 0000000000000000000000000000000000000000..4470546cf07156cc1cf248b7f953c334b03f0a1a
--- /dev/null
+++ b/src/Components/Blazor/Build/test/BlazorCreateRootDescriptorFileTest.cs
@@ -0,0 +1,38 @@
+// 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.IO;
+using System.Xml.Linq;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    public class BlazorCreateRootDescriptorFileTest
+    {
+        [Fact]
+        public void ProducesRootDescriptor()
+        {
+            // Arrange/Act
+            using var stream = new MemoryStream();
+
+            // Act
+            BlazorCreateRootDescriptorFile.WriteRootDescriptor(
+                stream,
+                new[] { "MyApp.dll" });
+
+            // Assert
+            stream.Position = 0;
+            var document = XDocument.Load(stream);
+            var rootElement = document.Root;
+
+            var assemblyElement = Assert.Single(rootElement.Elements());
+            Assert.Equal("assembly", assemblyElement.Name.ToString());
+            Assert.Equal("MyApp.dll", assemblyElement.Attribute("fullname").Value);
+
+            var typeElement = Assert.Single(assemblyElement.Elements());
+            Assert.Equal("type", typeElement.Name.ToString());
+            Assert.Equal("*", typeElement.Attribute("fullname").Value);
+            Assert.Equal("true", typeElement.Attribute("required").Value);
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/test/BootJsonWriterTest.cs b/src/Components/Blazor/Build/test/BootJsonWriterTest.cs
index 3632a082b780607c35b176e40a3b8cea72c30e6b..1e2d89b573bba6b7c68959aaebec563104246524 100644
--- a/src/Components/Blazor/Build/test/BootJsonWriterTest.cs
+++ b/src/Components/Blazor/Build/test/BootJsonWriterTest.cs
@@ -1,62 +1,41 @@
 // 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 Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-using System;
-using System.Linq;
+using System.IO;
+using System.Text.Json;
+using System.Threading.Tasks;
 using Xunit;
 
-namespace Microsoft.AspNetCore.Blazor.Build.Test
+namespace Microsoft.AspNetCore.Blazor.Build
 {
     public class BootJsonWriterTest
     {
         [Fact]
-        public void ProducesJsonReferencingAssemblyAndDependencies()
+        public async Task ProducesJsonReferencingAssemblyAndDependencies()
         {
             // Arrange/Act
-            var assemblyReferences = new string[] { "System.Abc.dll", "MyApp.ClassLib.dll", };
-            var content = BootJsonWriter.GetBootJsonContent(
+            var assemblyReferences = new string[] { "MyApp.EntryPoint.dll", "System.Abc.dll", "MyApp.ClassLib.dll", };
+            using var stream = new MemoryStream();
+
+            // Act
+            GenerateBlazorBootJson.WriteBootJson(
+                stream,
                 "MyApp.Entrypoint.dll",
-                "MyNamespace.MyType::MyMethod",
                 assemblyReferences,
-                Enumerable.Empty<EmbeddedResourceInfo>(),
                 linkerEnabled: true);
 
             // Assert
-            var parsedContent = JsonConvert.DeserializeObject<JObject>(content);
-            Assert.Equal("MyApp.Entrypoint.dll", parsedContent["main"].Value<string>());
-            Assert.Equal("MyNamespace.MyType::MyMethod", parsedContent["entryPoint"].Value<string>());
-            Assert.Equal(assemblyReferences, parsedContent["assemblyReferences"].Values<string>());
-        }
-
-        [Fact]
-        public void IncludesReferencesToEmbeddedContent()
-        {
-            // Arrange/Act
-            var embeddedContent = new[]
+            stream.Position = 0;
+            using var parsedContent = await JsonDocument.ParseAsync(stream);
+            var rootElement = parsedContent.RootElement;
+            Assert.Equal("MyApp.Entrypoint.dll", rootElement.GetProperty("entryAssembly").GetString());
+            var assembliesElement = rootElement.GetProperty("assemblies");
+            Assert.Equal(assemblyReferences.Length, assembliesElement.GetArrayLength());
+            for (var i = 0; i < assemblyReferences.Length; i++)
             {
-                new EmbeddedResourceInfo(EmbeddedResourceKind.Static, "my/static/file"),
-                new EmbeddedResourceInfo(EmbeddedResourceKind.Css, "css/first.css"),
-                new EmbeddedResourceInfo(EmbeddedResourceKind.JavaScript, "javascript/first.js"),
-                new EmbeddedResourceInfo(EmbeddedResourceKind.Css, "css/second.css"),
-                new EmbeddedResourceInfo(EmbeddedResourceKind.JavaScript, "javascript/second.js"),
-            };
-            var content = BootJsonWriter.GetBootJsonContent(
-                "MyApp.Entrypoint",
-                "MyNamespace.MyType::MyMethod",
-                assemblyReferences: new[] { "Something.dll" },
-                embeddedContent: embeddedContent,
-                linkerEnabled: true);
-
-            // Assert
-            var parsedContent = JsonConvert.DeserializeObject<JObject>(content);
-            Assert.Equal(
-                new[] { "css/first.css", "css/second.css" },
-                parsedContent["cssReferences"].Values<string>());
-            Assert.Equal(
-                new[] { "javascript/first.js", "javascript/second.js" },
-                parsedContent["jsReferences"].Values<string>());
+                Assert.Equal(assemblyReferences[i], assembliesElement[i].GetString());
+            }
+            Assert.True(rootElement.GetProperty("linkerEnabled").GetBoolean());
         }
     }
 }
diff --git a/src/Components/Blazor/Build/test/BuildIntegrationTests/Assert.cs b/src/Components/Blazor/Build/test/BuildIntegrationTests/Assert.cs
new file mode 100644
index 0000000000000000000000000000000000000000..8d0aa4b6dab19de7f385c020ccaa74c40325adb1
--- /dev/null
+++ b/src/Components/Blazor/Build/test/BuildIntegrationTests/Assert.cs
@@ -0,0 +1,950 @@
+// 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.Diagnostics;
+using System.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Reflection.Metadata;
+using System.Reflection.PortableExecutable;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    internal class Assert : Xunit.Assert
+    {
+        // Matches `{filename}: error {code}: {message} [{project}]
+        // See https://stackoverflow.com/questions/3441452/msbuild-and-ignorestandarderrorwarningformat/5180353#5180353
+        private static readonly Regex ErrorRegex = new Regex(@"^(?'location'.+): error (?'errorcode'[A-Z0-9]+): (?'message'.+) \[(?'project'.+)\]$");
+        private static readonly Regex WarningRegex = new Regex(@"^(?'location'.+): warning (?'errorcode'[A-Z0-9]+): (?'message'.+) \[(?'project'.+)\]$");
+        private static readonly string[] AllowedBuildWarnings = new[]
+        {
+            "MSB3491" , // The process cannot access the file. As long as the build succeeds, we're ok.
+            "NETSDK1071", // "A PackageReference to 'Microsoft.NETCore.App' specified a Version ..."
+        };
+
+        public static void BuildPassed(MSBuildResult result, bool allowWarnings = false)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            if (result.ExitCode != 0)
+            {
+                throw new BuildFailedException(result);
+            }
+
+            var buildWarnings = GetBuildWarnings(result)
+                .Where(m => !AllowedBuildWarnings.Contains(m.match.Groups["errorcode"].Value))
+                .Select(m => m.line);
+
+            if (!allowWarnings && buildWarnings.Any())
+            {
+                throw new BuildWarningsException(result, buildWarnings);
+            }
+        }
+
+        public static void BuildError(MSBuildResult result, string errorCode, string location = null)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            // We don't really need to search line by line, I'm doing this so that it's possible/easy to debug.
+            var lines = result.Output.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
+            for (var i = 0; i < lines.Length; i++)
+            {
+                var line = lines[i];
+                var match = ErrorRegex.Match(line);
+                if (match.Success)
+                {
+                    if (match.Groups["errorcode"].Value != errorCode)
+                    {
+                        continue;
+                    }
+
+                    if (location != null && match.Groups["location"].Value.Trim() != location)
+                    {
+                        continue;
+                    }
+
+                    // This is a match
+                    return;
+                }
+            }
+
+            throw new BuildErrorMissingException(result, errorCode, location);
+        }
+
+        public static void BuildWarning(MSBuildResult result, string errorCode, string location = null)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            // We don't really need to search line by line, I'm doing this so that it's possible/easy to debug.
+            foreach (var (_, match) in GetBuildWarnings(result))
+            {
+                if (match.Groups["errorcode"].Value != errorCode)
+                {
+                    continue;
+                }
+
+                if (location != null && match.Groups["location"].Value.Trim() != location)
+                {
+                    continue;
+                }
+
+                // This is a match
+                return;
+            }
+
+            throw new BuildErrorMissingException(result, errorCode, location);
+        }
+
+        private static IEnumerable<(string line, Match match)> GetBuildWarnings(MSBuildResult result)
+        {
+            var lines = result.Output.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
+            for (var i = 0; i < lines.Length; i++)
+            {
+                var line = lines[i];
+                var match = WarningRegex.Match(line);
+                if (match.Success)
+                {
+                    yield return (line, match);
+                }
+            }
+        }
+
+        public static void BuildFailed(MSBuildResult result)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            };
+
+            if (result.ExitCode == 0)
+            {
+                throw new BuildPassedException(result);
+            }
+        }
+
+        public static void BuildOutputContainsLine(MSBuildResult result, string match)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            if (match == null)
+            {
+                throw new ArgumentNullException(nameof(match));
+            }
+
+            // We don't really need to search line by line, I'm doing this so that it's possible/easy to debug.
+            var lines = result.Output.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
+            for (var i = 0; i < lines.Length; i++)
+            {
+                var line = lines[i].Trim();
+                if (line == match)
+                {
+                    return;
+                }
+            }
+
+            throw new BuildOutputMissingException(result, match);
+        }
+
+        public static void BuildOutputDoesNotContainLine(MSBuildResult result, string match)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            if (match == null)
+            {
+                throw new ArgumentNullException(nameof(match));
+            }
+
+            // We don't really need to search line by line, I'm doing this so that it's possible/easy to debug.
+            var lines = result.Output.Split(new char[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
+            for (var i = 0; i < lines.Length; i++)
+            {
+                var line = lines[i].Trim();
+                if (line == match)
+                {
+                    throw new BuildOutputContainsLineException(result, match);
+                }
+            }
+        }
+
+        public static void FileContains(MSBuildResult result, string filePath, string match)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            filePath = Path.Combine(result.Project.DirectoryPath, filePath);
+            FileExists(result, filePath);
+
+            var text = File.ReadAllText(filePath);
+            if (text.Contains(match))
+            {
+                return;
+            }
+
+            throw new FileContentMissingException(result, filePath, File.ReadAllText(filePath), match);
+        }
+
+        public static void FileDoesNotContain(MSBuildResult result, string filePath, string match)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            filePath = Path.Combine(result.Project.DirectoryPath, filePath);
+            FileExists(result, filePath);
+
+            var text = File.ReadAllText(filePath);
+            if (text.Contains(match))
+            {
+                throw new FileContentFoundException(result, filePath, File.ReadAllText(filePath), match);
+            }
+        }
+
+        public static void FileContentEquals(MSBuildResult result, string filePath, string expected)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            filePath = Path.Combine(result.Project.DirectoryPath, filePath);
+            FileExists(result, filePath);
+
+            var actual = File.ReadAllText(filePath);
+            if (!actual.Equals(expected, StringComparison.Ordinal))
+            {
+                throw new FileContentNotEqualException(result, filePath, expected, actual);
+            }
+        }
+
+        public static void FileEquals(MSBuildResult result, string expected, string actual)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            expected = Path.Combine(result.Project.DirectoryPath, expected);
+            actual = Path.Combine(result.Project.DirectoryPath, actual);
+            FileExists(result, expected);
+            FileExists(result, actual);
+
+            if (!Enumerable.SequenceEqual(File.ReadAllBytes(expected), File.ReadAllBytes(actual)))
+            {
+                throw new FilesNotEqualException(result, expected, actual);
+            }
+        }
+
+        public static void FileContainsLine(MSBuildResult result, string filePath, string match)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            filePath = Path.Combine(result.Project.DirectoryPath, filePath);
+            FileExists(result, filePath);
+
+            var lines = File.ReadAllLines(filePath);
+            for (var i = 0; i < lines.Length; i++)
+            {
+                var line = lines[i].Trim();
+                if (line == match)
+                {
+                    return;
+                }
+            }
+
+            throw new FileContentMissingException(result, filePath, File.ReadAllText(filePath), match);
+        }
+
+        public static void FileDoesNotContainLine(MSBuildResult result, string filePath, string match)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            filePath = Path.Combine(result.Project.DirectoryPath, filePath);
+            FileExists(result, filePath);
+
+            var lines = File.ReadAllLines(filePath);
+            for (var i = 0; i < lines.Length; i++)
+            {
+                var line = lines[i].Trim();
+                if (line == match)
+                {
+                    throw new FileContentFoundException(result, filePath, File.ReadAllText(filePath), match);
+                }
+            }
+        }
+
+        public static string FileExists(MSBuildResult result, params string[] paths)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            var filePath = Path.Combine(result.Project.DirectoryPath, Path.Combine(paths));
+            if (!File.Exists(filePath))
+            {
+                throw new FileMissingException(result, filePath);
+            }
+
+            return filePath;
+        }
+
+        public static void FileCountEquals(MSBuildResult result, int expected, string directoryPath, string searchPattern)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            if (directoryPath == null)
+            {
+                throw new ArgumentNullException(nameof(directoryPath));
+            }
+
+            if (searchPattern == null)
+            {
+                throw new ArgumentNullException(nameof(searchPattern));
+            }
+
+            directoryPath = Path.Combine(result.Project.DirectoryPath, directoryPath);
+
+            if (Directory.Exists(directoryPath))
+            {
+                var files = Directory.GetFiles(directoryPath, searchPattern, SearchOption.AllDirectories);
+                if (files.Length != expected)
+                {
+                    throw new FileCountException(result, expected, directoryPath, searchPattern, files);
+                }
+            }
+            else if (expected > 0)
+            {
+                // directory doesn't exist, that's OK if we expected to find nothing.
+                throw new FileCountException(result, expected, directoryPath, searchPattern, Array.Empty<string>());
+            }
+        }
+
+        public static void FileDoesNotExist(MSBuildResult result, params string[] paths)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            var filePath = Path.Combine(result.Project.DirectoryPath, Path.Combine(paths));
+            if (File.Exists(filePath))
+            {
+                throw new FileFoundException(result, filePath);
+            }
+        }
+
+        public static void NuspecContains(MSBuildResult result, string nuspecPath, string expected)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            if (nuspecPath == null)
+            {
+                throw new ArgumentNullException(nameof(nuspecPath));
+            }
+
+            if (expected == null)
+            {
+                throw new ArgumentNullException(nameof(expected));
+            }
+
+            nuspecPath = Path.Combine(result.Project.DirectoryPath, nuspecPath);
+            FileExists(result, nuspecPath);
+
+            var content = File.ReadAllText(nuspecPath);
+            if (!content.Contains(expected))
+            {
+                throw new NuspecException(result, nuspecPath, content, expected);
+            }
+        }
+
+        public static void NuspecDoesNotContain(MSBuildResult result, string nuspecPath, string expected)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            if (nuspecPath == null)
+            {
+                throw new ArgumentNullException(nameof(nuspecPath));
+            }
+
+            if (expected == null)
+            {
+                throw new ArgumentNullException(nameof(expected));
+            }
+
+            nuspecPath = Path.Combine(result.Project.DirectoryPath, nuspecPath);
+            FileExists(result, nuspecPath);
+
+            var content = File.ReadAllText(nuspecPath);
+            if (content.Contains(expected))
+            {
+                throw new NuspecFoundException(result, nuspecPath, content, expected);
+            }
+        }
+
+        // This method extracts the nupkg to a fixed directory path. To avoid the extra work of
+        // cleaning up after each invocation, this method accepts multiple files.
+        public static void NupkgContains(MSBuildResult result, string nupkgPath, params string[] filePaths)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            if (nupkgPath == null)
+            {
+                throw new ArgumentNullException(nameof(nupkgPath));
+            }
+
+            if (filePaths == null)
+            {
+                throw new ArgumentNullException(nameof(filePaths));
+            }
+
+            nupkgPath = Path.Combine(result.Project.DirectoryPath, nupkgPath);
+            FileExists(result, nupkgPath);
+
+            var unzipped = Path.Combine(result.Project.DirectoryPath, Path.GetFileNameWithoutExtension(nupkgPath));
+            ZipFile.ExtractToDirectory(nupkgPath, unzipped);
+
+            foreach (var filePath in filePaths)
+            {
+                if (!File.Exists(Path.Combine(unzipped, filePath)))
+                {
+                    throw new NupkgFileMissingException(result, nupkgPath, filePath);
+                }
+            }
+        }
+
+        // This method extracts the nupkg to a fixed directory path. To avoid the extra work of
+        // cleaning up after each invocation, this method accepts multiple files.
+        public static void NupkgDoesNotContain(MSBuildResult result, string nupkgPath, params string[] filePaths)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            if (nupkgPath == null)
+            {
+                throw new ArgumentNullException(nameof(nupkgPath));
+            }
+
+            if (filePaths == null)
+            {
+                throw new ArgumentNullException(nameof(filePaths));
+            }
+
+            nupkgPath = Path.Combine(result.Project.DirectoryPath, nupkgPath);
+            FileExists(result, nupkgPath);
+
+            var unzipped = Path.Combine(result.Project.DirectoryPath, Path.GetFileNameWithoutExtension(nupkgPath));
+            ZipFile.ExtractToDirectory(nupkgPath, unzipped);
+
+            foreach (var filePath in filePaths)
+            {
+                if (File.Exists(Path.Combine(unzipped, filePath)))
+                {
+                    throw new NupkgFileFoundException(result, nupkgPath, filePath);
+                }
+            }
+        }
+
+        public static void AssemblyContainsType(MSBuildResult result, string assemblyPath, string fullTypeName)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            assemblyPath = Path.Combine(result.Project.DirectoryPath, Path.Combine(assemblyPath));
+
+            var typeNames = GetDeclaredTypeNames(assemblyPath);
+            Assert.Contains(fullTypeName, typeNames);
+        }
+
+        public static void AssemblyDoesNotContainType(MSBuildResult result, string assemblyPath, string fullTypeName)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            assemblyPath = Path.Combine(result.Project.DirectoryPath, Path.Combine(assemblyPath));
+
+            var typeNames = GetDeclaredTypeNames(assemblyPath);
+            Assert.DoesNotContain(fullTypeName, typeNames);
+        }
+
+        private static IEnumerable<string> GetDeclaredTypeNames(string assemblyPath)
+        {
+            using (var file = File.OpenRead(assemblyPath))
+            {
+                var peReader = new PEReader(file);
+                var metadataReader = peReader.GetMetadataReader();
+                return metadataReader.TypeDefinitions.Where(t => !t.IsNil).Select(t =>
+                {
+                    var type = metadataReader.GetTypeDefinition(t);
+                    return metadataReader.GetString(type.Namespace) + "." + metadataReader.GetString(type.Name);
+                }).ToArray();
+            }
+        }
+
+        public static void AssemblyHasAttribute(MSBuildResult result, string assemblyPath, string fullTypeName)
+        {
+            if (result == null)
+            {
+                throw new ArgumentNullException(nameof(result));
+            }
+
+            assemblyPath = Path.Combine(result.Project.DirectoryPath, Path.Combine(assemblyPath));
+
+            var typeNames = GetAssemblyAttributes(assemblyPath);
+            Assert.Contains(fullTypeName, typeNames);
+        }
+
+        private static IEnumerable<string> GetAssemblyAttributes(string assemblyPath)
+        {
+            using (var file = File.OpenRead(assemblyPath))
+            {
+                var peReader = new PEReader(file);
+                var metadataReader = peReader.GetMetadataReader();
+                return metadataReader.CustomAttributes.Where(t => !t.IsNil).Select(t =>
+                {
+                    var attribute = metadataReader.GetCustomAttribute(t);
+                    var constructor = metadataReader.GetMemberReference((MemberReferenceHandle)attribute.Constructor);
+                    var type = metadataReader.GetTypeReference((TypeReferenceHandle)constructor.Parent);
+
+                    return metadataReader.GetString(type.Namespace) + "." + metadataReader.GetString(type.Name);
+                }).ToArray();
+            }
+        }
+
+        private abstract class MSBuildXunitException : Xunit.Sdk.XunitException
+        {
+            protected MSBuildXunitException(MSBuildResult result)
+            {
+                Result = result;
+            }
+
+            protected abstract string Heading { get; }
+
+            public MSBuildResult Result { get; }
+
+            public override string Message
+            {
+                get
+                {
+                    var message = new StringBuilder();
+                    message.AppendLine(Heading);
+                    message.Append(Result.FileName);
+                    message.Append(" ");
+                    message.Append(Result.Arguments);
+                    message.AppendLine();
+                    message.AppendLine();
+                    message.Append(Result.Output);
+                    message.AppendLine();
+                    message.Append("Exit Code:");
+                    message.Append(Result.ExitCode);
+                    return message.ToString();
+                }
+            }
+        }
+
+        private class BuildErrorMissingException : MSBuildXunitException
+        {
+            public BuildErrorMissingException(MSBuildResult result, string errorCode, string location)
+                : base(result)
+            {
+                ErrorCode = errorCode;
+                Location = location;
+            }
+
+            public string ErrorCode { get; }
+
+            public string Location { get; }
+
+            protected override string Heading
+            {
+                get
+                {
+                    return
+                        $"Error code '{ErrorCode}' was not found." + Environment.NewLine +
+                        $"Looking for '{Location ?? ".*"}: error {ErrorCode}: .*'";
+                }
+            }
+        }
+
+        private class BuildFailedException : MSBuildXunitException
+        {
+            public BuildFailedException(MSBuildResult result)
+                : base(result)
+            {
+            }
+
+            protected override string Heading => "Build failed.";
+        }
+
+        private class BuildWarningsException : MSBuildXunitException
+        {
+            public BuildWarningsException(MSBuildResult result, IEnumerable<string> warnings)
+                : base(result)
+            {
+                Warnings = warnings.ToList();
+            }
+
+            public List<string> Warnings { get; }
+
+            protected override string Heading => "Build contains unexpected warnings: " + string.Join(Environment.NewLine, Warnings);
+        }
+
+        private class BuildPassedException : MSBuildXunitException
+        {
+            public BuildPassedException(MSBuildResult result)
+                : base(result)
+            {
+            }
+
+            protected override string Heading => "Build should have failed, but it passed.";
+        }
+
+        private class BuildOutputMissingException : MSBuildXunitException
+        {
+            public BuildOutputMissingException(MSBuildResult result, string match)
+                : base(result)
+            {
+                Match = match;
+            }
+
+            public string Match { get; }
+
+            protected override string Heading => $"Build did not contain the line: '{Match}'.";
+        }
+
+        private class BuildOutputContainsLineException : MSBuildXunitException
+        {
+            public BuildOutputContainsLineException(MSBuildResult result, string match)
+                : base(result)
+            {
+                Match = match;
+            }
+
+            public string Match { get; }
+
+            protected override string Heading => $"Build output contains the line: '{Match}'.";
+        }
+
+        private class FileContentFoundException : MSBuildXunitException
+        {
+            public FileContentFoundException(MSBuildResult result, string filePath, string content, string match)
+                : base(result)
+            {
+                FilePath = filePath;
+                Content = content;
+                Match = match;
+            }
+
+            public string Content { get; }
+
+            public string FilePath { get; }
+
+            public string Match { get; }
+
+            protected override string Heading
+            {
+                get
+                {
+                    var builder = new StringBuilder();
+                    builder.AppendFormat("File content of '{0}' should not contain line: '{1}'.", FilePath, Match);
+                    builder.AppendLine();
+                    builder.AppendLine();
+                    builder.AppendLine(Content);
+                    return builder.ToString();
+                }
+            }
+        }
+
+        private class FileContentMissingException : MSBuildXunitException
+        {
+            public FileContentMissingException(MSBuildResult result, string filePath, string content, string match)
+                : base(result)
+            {
+                FilePath = filePath;
+                Content = content;
+                Match = match;
+            }
+
+            public string Content { get; }
+
+            public string FilePath { get; }
+
+            public string Match { get; }
+
+            protected override string Heading
+            {
+                get
+                {
+                    var builder = new StringBuilder();
+                    builder.AppendFormat("File content of '{0}' did not contain the line: '{1}'.", FilePath, Match);
+                    builder.AppendLine();
+                    builder.AppendLine();
+                    builder.AppendLine(Content);
+                    return builder.ToString();
+                }
+            }
+        }
+
+        private class FileContentNotEqualException : MSBuildXunitException
+        {
+            public FileContentNotEqualException(MSBuildResult result, string filePath, string expected, string actual)
+                : base(result)
+            {
+                FilePath = filePath;
+                Expected = expected;
+                Actual = actual;
+            }
+
+            public string Actual { get; }
+
+            public string FilePath { get; }
+
+            public string Expected { get; }
+
+            protected override string Heading
+            {
+                get
+                {
+                    var builder = new StringBuilder();
+                    builder.AppendFormat("File content of '{0}' did not match the expected content: '{1}'.", FilePath, Expected);
+                    builder.AppendLine();
+                    builder.AppendLine();
+                    builder.AppendLine(Actual);
+                    return builder.ToString();
+                }
+            }
+        }
+
+        private class FilesNotEqualException : MSBuildXunitException
+        {
+            public FilesNotEqualException(MSBuildResult result, string expected, string actual)
+                : base(result)
+            {
+                Expected = expected;
+                Actual = actual;
+            }
+
+            public string Actual { get; }
+
+            public string Expected { get; }
+
+            protected override string Heading
+            {
+                get
+                {
+                    var builder = new StringBuilder();
+                    builder.AppendFormat("File content of '{0}' did not match the contents of '{1}'.", Expected, Actual);
+                    builder.AppendLine();
+                    builder.AppendLine();
+                    builder.AppendLine(Actual);
+                    return builder.ToString();
+                }
+            }
+        }
+
+        private class FileMissingException : MSBuildXunitException
+        {
+            public FileMissingException(MSBuildResult result, string filePath)
+                : base(result)
+            {
+                FilePath = filePath;
+            }
+
+            public string FilePath { get; }
+
+            protected override string Heading => $"File: '{FilePath}' was not found.";
+        }
+
+        private class FileCountException : MSBuildXunitException
+        {
+            public FileCountException(MSBuildResult result, int expected, string directoryPath, string searchPattern, string[] files)
+                : base(result)
+            {
+                Expected = expected;
+                DirectoryPath = directoryPath;
+                SearchPattern = searchPattern;
+                Files = files;
+            }
+
+            public string DirectoryPath { get; }
+
+            public int Expected { get; }
+
+            public string[] Files { get; }
+
+            public string SearchPattern { get; }
+
+            protected override string Heading
+            {
+                get
+                {
+                    var heading = new StringBuilder();
+                    heading.AppendLine($"Expected {Expected} files matching {SearchPattern} in {DirectoryPath}, found {Files.Length}.");
+
+                    if (Files.Length > 0)
+                    {
+                        heading.AppendLine("Files:");
+
+                        foreach (var file in Files)
+                        {
+                            heading.Append("\t");
+                            heading.Append(file);
+                        }
+
+                        heading.AppendLine();
+                    }
+
+                    return heading.ToString();
+                }
+            }
+        }
+
+        private class FileFoundException : MSBuildXunitException
+        {
+            public FileFoundException(MSBuildResult result, string filePath)
+                : base(result)
+            {
+                FilePath = filePath;
+            }
+
+            public string FilePath { get; }
+
+            protected override string Heading => $"File: '{FilePath}' was found, but should not exist.";
+        }
+
+        private class NuspecException : MSBuildXunitException
+        {
+            public NuspecException(MSBuildResult result, string filePath, string content, string expected)
+                : base(result)
+            {
+                FilePath = filePath;
+                Content = content;
+                Expected = expected;
+            }
+
+            public string Content { get; }
+
+            public string Expected { get; }
+
+            public string FilePath { get; }
+
+            protected override string Heading
+            {
+                get
+                {
+                    return
+                        $"nuspec: '{FilePath}' did not contain the expected content." + Environment.NewLine +
+                        Environment.NewLine +
+                        $"expected: {Expected}" + Environment.NewLine +
+                        Environment.NewLine +
+                        $"actual: {Content}";
+                }
+            }
+        }
+
+        private class NuspecFoundException : MSBuildXunitException
+        {
+            public NuspecFoundException(MSBuildResult result, string filePath, string content, string expected)
+                : base(result)
+            {
+                FilePath = filePath;
+                Content = content;
+                Expected = expected;
+            }
+
+            public string Content { get; }
+
+            public string Expected { get; }
+
+            public string FilePath { get; }
+
+            protected override string Heading
+            {
+                get
+                {
+                    return
+                        $"nuspec: '{FilePath}' should not contain the content {Expected}." +
+                        Environment.NewLine +
+                        $"actual content: {Content}";
+                }
+            }
+        }
+
+        private class NupkgFileMissingException : MSBuildXunitException
+        {
+            public NupkgFileMissingException(MSBuildResult result, string nupkgPath, string filePath)
+                : base(result)
+            {
+                NupkgPath = nupkgPath;
+                FilePath = filePath;
+            }
+
+            public string FilePath { get; }
+
+            public string NupkgPath { get; }
+
+            protected override string Heading => $"File: '{FilePath}' was not found was not found in {NupkgPath}.";
+        }
+
+        private class NupkgFileFoundException : MSBuildXunitException
+        {
+            public NupkgFileFoundException(MSBuildResult result, string nupkgPath, string filePath)
+                : base(result)
+            {
+                NupkgPath = nupkgPath;
+                FilePath = filePath;
+            }
+
+            public string FilePath { get; }
+
+            public string NupkgPath { get; }
+
+            protected override string Heading => $"File: '{FilePath}' was found in {NupkgPath}.";
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/test/BuildIntegrationTests/BuildIncrementalismTest.cs b/src/Components/Blazor/Build/test/BuildIntegrationTests/BuildIncrementalismTest.cs
new file mode 100644
index 0000000000000000000000000000000000000000..73d864502989f87f6c789045613638dfc103135f
--- /dev/null
+++ b/src/Components/Blazor/Build/test/BuildIntegrationTests/BuildIncrementalismTest.cs
@@ -0,0 +1,40 @@
+// 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.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    public class BuildIncrementalismTest
+    {
+        [Fact]
+        public async Task Build_WithLinker_IsIncremental()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("standalone");
+            var result = await MSBuildProcessManager.DotnetMSBuild(project);
+
+            Assert.BuildPassed(result);
+
+            var buildOutputDirectory = project.BuildOutputDirectory;
+
+            // Act
+            var thumbPrint = FileThumbPrint.CreateFolderThumbprint(project, project.BuildOutputDirectory);
+
+            // Assert
+            for (var i = 0; i < 3; i++)
+            {
+                result = await MSBuildProcessManager.DotnetMSBuild(project);
+                Assert.BuildPassed(result);
+
+                var newThumbPrint = FileThumbPrint.CreateFolderThumbprint(project, project.BuildOutputDirectory);
+                Assert.Equal(thumbPrint.Count, newThumbPrint.Count);
+                for (var j = 0; j < thumbPrint.Count; j++)
+                {
+                    Assert.Equal(thumbPrint[j], newThumbPrint[j]);
+                }
+            }
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/test/BuildIntegrationTests/BuildIntegrationTest.cs b/src/Components/Blazor/Build/test/BuildIntegrationTests/BuildIntegrationTest.cs
new file mode 100644
index 0000000000000000000000000000000000000000..f3148b1a0b981576d46ceb97d9115d4be4681eba
--- /dev/null
+++ b/src/Components/Blazor/Build/test/BuildIntegrationTests/BuildIntegrationTest.cs
@@ -0,0 +1,135 @@
+// 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.IO;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    public class BuildIntegrationTest
+    {
+        [Fact]
+        public async Task Build_WithDefaultSettings_Works()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("standalone");
+            var result = await MSBuildProcessManager.DotnetMSBuild(project);
+
+            Assert.BuildPassed(result);
+
+            var buildOutputDirectory = project.BuildOutputDirectory;
+
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "blazor.boot.json");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "blazor.webassembly.js");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "wasm", "dotnet.wasm");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "wasm", "dotnet.js");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "_bin", "standalone.dll");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "_bin", "Microsoft.Extensions.Logging.Abstractions.dll"); // Verify dependencies are part of the output.
+        }
+
+        [Fact]
+        public async Task Build_Hosted_Works()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("blazorhosted", additionalProjects: new[] { "standalone", "razorclasslibrary", });
+            project.TargetFramework = "netcoreapp3.1";
+            var result = await MSBuildProcessManager.DotnetMSBuild(project);
+
+            Assert.BuildPassed(result);
+
+            var buildOutputDirectory = project.BuildOutputDirectory;
+            var blazorConfig = Path.Combine(buildOutputDirectory, "standalone.blazor.config");
+            Assert.FileExists(result, blazorConfig);
+
+            var path = Path.GetFullPath(Path.Combine(project.SolutionPath, "standalone", "bin", project.Configuration, "netstandard2.1", "standalone.dll"));
+            Assert.FileContains(result, blazorConfig, path);
+            Assert.FileDoesNotExist(result, buildOutputDirectory, "dist", "_framework", "_bin", "standalone.dll");
+        }
+
+        [Fact]
+        public async Task Build_WithLinkOnBuildDisabled_Works()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("standalone");
+            project.AddProjectFileContent(
+@"<PropertyGroup>
+    <BlazorLinkOnBuild>false</BlazorLinkOnBuild>
+</PropertyGroup>");
+
+            var result = await MSBuildProcessManager.DotnetMSBuild(project);
+
+            Assert.BuildPassed(result);
+
+            var buildOutputDirectory = project.BuildOutputDirectory;
+
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "blazor.boot.json");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "blazor.webassembly.js");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "wasm", "dotnet.wasm");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "wasm", "dotnet.js");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "_bin", "standalone.dll");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "_bin", "Microsoft.Extensions.Logging.Abstractions.dll"); // Verify dependencies are part of the output.
+        }
+
+        [Fact]
+        public async Task Build_SatelliteAssembliesAreCopiedToBuildOutput()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("standalone", additionalProjects: new[] { "razorclasslibrary", "classlibrarywithsatelliteassemblies" });
+            project.AddProjectFileContent(
+@"
+<PropertyGroup>
+    <DefineConstants>$(DefineConstants);REFERENCE_classlibrarywithsatelliteassemblies</DefineConstants>
+</PropertyGroup>
+<ItemGroup>
+    <ProjectReference Include=""..\classlibrarywithsatelliteassemblies\classlibrarywithsatelliteassemblies.csproj"" />
+</ItemGroup>");
+
+            var result = await MSBuildProcessManager.DotnetMSBuild(project, args: "/restore");
+
+            Assert.BuildPassed(result);
+
+            var buildOutputDirectory = project.BuildOutputDirectory;
+
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "_bin", "standalone.dll");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "_bin", "classlibrarywithsatelliteassemblies.dll");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "_bin", "Microsoft.CodeAnalysis.CSharp.dll");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "_bin", "fr", "Microsoft.CodeAnalysis.CSharp.resources.dll"); // Verify satellite assemblies are present in the build output.
+
+            var bootJsonPath = Path.Combine(buildOutputDirectory, "dist", "_framework", "blazor.boot.json");
+            Assert.FileContains(result, bootJsonPath, "\"Microsoft.CodeAnalysis.CSharp.dll\"");
+            Assert.FileContains(result, bootJsonPath, "\"fr\\/Microsoft.CodeAnalysis.CSharp.resources.dll\"");
+        }
+
+        [Fact]
+        public async Task Build_WithBlazorLinkOnBuildFalse_SatelliteAssembliesAreCopiedToBuildOutput()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("standalone", additionalProjects: new[] { "razorclasslibrary", "classlibrarywithsatelliteassemblies" });
+            project.AddProjectFileContent(
+@"
+<PropertyGroup>
+    <BlazorLinkOnBuild>false</BlazorLinkOnBuild>
+    <DefineConstants>$(DefineConstants);REFERENCE_classlibrarywithsatelliteassemblies</DefineConstants>
+</PropertyGroup>
+<ItemGroup>
+    <ProjectReference Include=""..\classlibrarywithsatelliteassemblies\classlibrarywithsatelliteassemblies.csproj"" />
+</ItemGroup>");
+
+            var result = await MSBuildProcessManager.DotnetMSBuild(project, args: "/restore");
+
+            Assert.BuildPassed(result);
+
+            var buildOutputDirectory = project.BuildOutputDirectory;
+
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "_bin", "standalone.dll");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "_bin", "classlibrarywithsatelliteassemblies.dll");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "_bin", "Microsoft.CodeAnalysis.CSharp.dll");
+            Assert.FileExists(result, buildOutputDirectory, "dist", "_framework", "_bin", "fr", "Microsoft.CodeAnalysis.CSharp.resources.dll"); // Verify satellite assemblies are present in the build output.
+
+            var bootJsonPath = Path.Combine(buildOutputDirectory, "dist", "_framework", "blazor.boot.json");
+            Assert.FileContains(result, bootJsonPath, "\"Microsoft.CodeAnalysis.CSharp.dll\"");
+            Assert.FileContains(result, bootJsonPath, "\"fr\\/Microsoft.CodeAnalysis.CSharp.resources.dll\"");
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/test/BuildIntegrationTests/FileThumbPrint.cs b/src/Components/Blazor/Build/test/BuildIntegrationTests/FileThumbPrint.cs
new file mode 100644
index 0000000000000000000000000000000000000000..58b5499e8bc64eb6044f2a04c021dd8243e21d19
--- /dev/null
+++ b/src/Components/Blazor/Build/test/BuildIntegrationTests/FileThumbPrint.cs
@@ -0,0 +1,74 @@
+// 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.IO;
+using System.Linq;
+using System.Security.Cryptography;
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    internal class FileThumbPrint : IEquatable<FileThumbPrint>
+    {
+        private FileThumbPrint(string path, DateTime lastWriteTimeUtc, string hash)
+        {
+            FilePath = path;
+            LastWriteTimeUtc = lastWriteTimeUtc;
+            Hash = hash;
+        }
+
+        public string FilePath { get; }
+
+        public DateTime LastWriteTimeUtc { get; }
+
+        public string Hash { get; }
+
+        public override string ToString()
+        {
+            return $"{Path.GetFileName(FilePath)}, {LastWriteTimeUtc.ToString("u")}, {Hash}";
+        }
+
+        /// <summary>
+        /// Returns a list of thumbprints for all files (recursive) in the specified directory, sorted by file paths.
+        /// </summary>
+        public static List<FileThumbPrint> CreateFolderThumbprint(ProjectDirectory project, string directoryPath, params string[] filesToIgnore)
+        {
+            directoryPath = Path.Combine(project.DirectoryPath, directoryPath);
+            var files = Directory.GetFiles(directoryPath).Where(p => !filesToIgnore.Contains(p));
+            var thumbprintLookup = new List<FileThumbPrint>();
+            foreach (var file in files)
+            {
+                var thumbprint = Create(file);
+                thumbprintLookup.Add(thumbprint);
+            }
+
+            thumbprintLookup.Sort(Comparer<FileThumbPrint>.Create((a, b) => StringComparer.Ordinal.Compare(a.FilePath, b.FilePath)));
+            return thumbprintLookup;
+        }
+
+        public static FileThumbPrint Create(string path)
+        {
+            byte[] hashBytes;
+            using (var sha1 = SHA1.Create())
+            using (var fileStream = File.OpenRead(path))
+            {
+                hashBytes = sha1.ComputeHash(fileStream);
+            }
+
+            var hash = Convert.ToBase64String(hashBytes);
+            var lastWriteTimeUtc = File.GetLastWriteTimeUtc(path);
+            return new FileThumbPrint(path, lastWriteTimeUtc, hash);
+        }
+
+        public bool Equals(FileThumbPrint other)
+        {
+            return
+                string.Equals(FilePath, other.FilePath, StringComparison.Ordinal) &&
+                LastWriteTimeUtc == other.LastWriteTimeUtc &&
+                string.Equals(Hash, other.Hash, StringComparison.Ordinal);
+        }
+
+        public override int GetHashCode() => LastWriteTimeUtc.GetHashCode();
+    }
+}
diff --git a/src/Components/Blazor/Build/test/BuildIntegrationTests/MSBuildProcessManager.cs b/src/Components/Blazor/Build/test/BuildIntegrationTests/MSBuildProcessManager.cs
new file mode 100644
index 0000000000000000000000000000000000000000..b7e16ca072917fb1f4a88402c59e537f776dae96
--- /dev/null
+++ b/src/Components/Blazor/Build/test/BuildIntegrationTests/MSBuildProcessManager.cs
@@ -0,0 +1,282 @@
+// 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.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.CommandLineUtils;
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    internal static class MSBuildProcessManager
+    {
+        public static Task<MSBuildResult> DotnetMSBuild(
+            ProjectDirectory project,
+            string target = "Build",
+            string args = null)
+        {
+            var buildArgumentList = new List<string>
+            {
+                // Disable node-reuse. We don't want msbuild processes to stick around
+                // once the test is completed.
+                "/nr:false",
+
+                // Always generate a bin log for debugging purposes
+                "/bl",
+            };
+
+            buildArgumentList.Add($"/t:{target}");
+            buildArgumentList.Add($"/p:Configuration={project.Configuration}");
+            buildArgumentList.Add(args);
+
+            var buildArguments = string.Join(" ", buildArgumentList);
+
+            return MSBuildProcessManager.RunProcessAsync(project, buildArguments);
+        }
+
+        public static async Task<MSBuildResult> RunProcessAsync(
+            ProjectDirectory project,
+            string arguments,
+            TimeSpan? timeout = null)
+        {
+            var processStartInfo = new ProcessStartInfo()
+            {
+                WorkingDirectory = project.DirectoryPath,
+                UseShellExecute = false,
+                RedirectStandardError = true,
+                RedirectStandardOutput = true,
+            };
+
+            processStartInfo.FileName = DotNetMuxer.MuxerPathOrDefault();
+            processStartInfo.Arguments = $"msbuild {arguments}";
+
+            // Suppresses the 'Welcome to .NET Core!' output that times out tests and causes locked file issues.
+            // When using dotnet we're not guarunteed to run in an environment where the dotnet.exe has had its first run experience already invoked.
+            processStartInfo.EnvironmentVariables["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "true";
+
+            var processResult = await RunProcessCoreAsync(processStartInfo, timeout);
+
+            return new MSBuildResult(project, processResult.FileName, processResult.Arguments, processResult.ExitCode, processResult.Output);
+        }
+
+        internal static Task<ProcessResult> RunProcessCoreAsync(
+            ProcessStartInfo processStartInfo,
+            TimeSpan? timeout = null)
+        {
+            timeout = timeout ?? TimeSpan.FromSeconds(5 * 60);
+
+            var process = new Process()
+            {
+                StartInfo = processStartInfo,
+                EnableRaisingEvents = true,
+            };
+
+            var output = new StringBuilder();
+            var outputLock = new object();
+
+            var diagnostics = new StringBuilder();
+            diagnostics.AppendLine("Process execution diagnostics:");
+
+            process.ErrorDataReceived += Process_ErrorDataReceived;
+            process.OutputDataReceived += Process_OutputDataReceived;
+
+            process.Start();
+            process.BeginOutputReadLine();
+            process.BeginErrorReadLine();
+
+            var timeoutTask = GetTimeoutForProcess(process, timeout, diagnostics);
+
+            var waitTask = Task.Run(() =>
+            {
+                // We need to use two WaitForExit calls to ensure that all of the output/events are processed. Previously
+                // this code used Process.Exited, which could result in us missing some output due to the ordering of
+                // events.
+                //
+                // See the remarks here: https://msdn.microsoft.com/en-us/library/ty0d8k56(v=vs.110).aspx
+                if (!process.WaitForExit(Int32.MaxValue))
+                {
+                    // unreachable - the timeoutTask will kill the process before this happens.
+                    throw new TimeoutException();
+                }
+
+                process.WaitForExit();
+
+
+                string outputString;
+                lock (outputLock)
+                {
+                    // This marks the end of the diagnostic info which we collect when something goes wrong.
+                    diagnostics.AppendLine("Process output:");
+
+                    // Expected output
+                    // Process execution diagnostics:
+                    // ...
+                    // Process output:
+                    outputString = diagnostics.ToString();
+                    outputString += output.ToString();
+                }
+
+                var result = new ProcessResult(process.StartInfo.FileName, process.StartInfo.Arguments, process.ExitCode, outputString);
+                return result;
+            });
+
+            return Task.WhenAny<ProcessResult>(waitTask, timeoutTask).Unwrap();
+
+            void Process_ErrorDataReceived(object sender, DataReceivedEventArgs e)
+            {
+                lock (outputLock)
+                {
+                    output.AppendLine(e.Data);
+                }
+            }
+
+            void Process_OutputDataReceived(object sender, DataReceivedEventArgs e)
+            {
+                lock (outputLock)
+                {
+                    output.AppendLine(e.Data);
+                }
+            }
+
+            async Task<ProcessResult> GetTimeoutForProcess(Process process, TimeSpan? timeout, StringBuilder diagnostics)
+            {
+                await Task.Delay(timeout.Value);
+
+                // Don't timeout during debug sessions
+                while (Debugger.IsAttached)
+                {
+                    Thread.Sleep(TimeSpan.FromSeconds(1));
+                }
+                if (!process.HasExited)
+                {
+                    await CollectDumps(process, timeout, diagnostics);
+
+                    // This is a timeout.
+                    process.Kill();
+                }
+
+                throw new TimeoutException($"command '${process.StartInfo.FileName} {process.StartInfo.Arguments}' timed out after {timeout}. Output: {output.ToString()}");
+            }
+
+            static async Task CollectDumps(Process process, TimeSpan? timeout, StringBuilder diagnostics)
+            {
+                var procDumpProcess = await CaptureDump(process, timeout, diagnostics);
+                var allDotNetProcesses = Process.GetProcessesByName("dotnet");
+
+                var allDotNetChildProcessCandidates = allDotNetProcesses
+                    .Where(p => p.StartTime >= process.StartTime && p.Id != process.Id);
+
+                if (!allDotNetChildProcessCandidates.Any())
+                {
+                    diagnostics.AppendLine("Couldn't find any candidate child process.");
+                    foreach (var dotnetProcess in allDotNetProcesses)
+                    {
+                        diagnostics.AppendLine($"Found dotnet process with PID {dotnetProcess.Id} and start time {dotnetProcess.StartTime}.");
+                    }
+                }
+
+                foreach (var childProcess in allDotNetChildProcessCandidates)
+                {
+                    diagnostics.AppendLine($"Found child process candidate '{childProcess.Id}'.");
+                }
+
+                var childrenProcessDumpProcesses = await Task.WhenAll(allDotNetChildProcessCandidates.Select(d => CaptureDump(d, timeout, diagnostics)));
+                foreach (var childProcess in childrenProcessDumpProcesses)
+                {
+                    if (childProcess != null && childProcess.HasExited)
+                    {
+                        diagnostics.AppendLine($"ProcDump failed to run for child dotnet process candidate '{process.Id}'.");
+                        childProcess.Kill();
+                    }
+                }
+
+                if (procDumpProcess != null && procDumpProcess.HasExited)
+                {
+                    diagnostics.AppendLine($"ProcDump failed to run for '{process.Id}'.");
+                    procDumpProcess.Kill();
+                }
+            }
+
+            static async Task<Process> CaptureDump(Process process, TimeSpan? timeout, StringBuilder diagnostics)
+            {
+                var metadataAttributes = Assembly.GetExecutingAssembly()
+                    .GetCustomAttributes<AssemblyMetadataAttribute>();
+
+                var procDumpPath = metadataAttributes
+                    .SingleOrDefault(ama => ama.Key == "ProcDumpToolPath")?.Value;
+
+                if (string.IsNullOrEmpty(procDumpPath))
+                {
+                    diagnostics.AppendLine("ProcDumpPath not defined.");
+                    return null;
+                }
+                var procDumpExePath = Path.Combine(procDumpPath, "procdump.exe");
+                if (!File.Exists(procDumpExePath))
+                {
+                    diagnostics.AppendLine($"Can't find procdump.exe in '{procDumpPath}'.");
+                    return null;
+                }
+
+                var dumpDirectory = metadataAttributes
+                    .SingleOrDefault(ama => ama.Key == "ArtifactsLogDir")?.Value;
+
+                if (string.IsNullOrEmpty(dumpDirectory))
+                {
+                    diagnostics.AppendLine("ArtifactsLogDir not defined.");
+                    return null;
+                }
+
+                if (!Directory.Exists(dumpDirectory))
+                {
+                    diagnostics.AppendLine($"'{dumpDirectory}' does not exist.");
+                    return null;
+                }
+
+                var procDumpPattern = Path.Combine(dumpDirectory, "HangingProcess_PROCESSNAME_PID_YYMMDD_HHMMSS.dmp");
+                var procDumpStartInfo = new ProcessStartInfo(
+                    procDumpExePath,
+                    $"-accepteula -ma {process.Id} {procDumpPattern}")
+                {
+                    CreateNoWindow = true,
+                    UseShellExecute = false,
+                    RedirectStandardOutput = true,
+                    RedirectStandardError = true
+                };
+
+                var procDumpProcess = Process.Start(procDumpStartInfo);
+                var tcs = new TaskCompletionSource<object>();
+
+                procDumpProcess.Exited += (s, a) => tcs.TrySetResult(null);
+                procDumpProcess.EnableRaisingEvents = true;
+
+                await Task.WhenAny(tcs.Task, Task.Delay(timeout ?? TimeSpan.FromSeconds(30)));
+                return procDumpProcess;
+            }
+        }
+
+        internal class ProcessResult
+        {
+            public ProcessResult(string fileName, string arguments, int exitCode, string output)
+            {
+                FileName = fileName;
+                Arguments = arguments;
+                ExitCode = exitCode;
+                Output = output;
+            }
+
+            public string Arguments { get; }
+
+            public string FileName { get; }
+
+            public int ExitCode { get; }
+
+            public string Output { get; }
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/test/BuildIntegrationTests/MSBuildResult.cs b/src/Components/Blazor/Build/test/BuildIntegrationTests/MSBuildResult.cs
new file mode 100644
index 0000000000000000000000000000000000000000..9a83df922b01cc8a36b7c65c58726ba926cf38a7
--- /dev/null
+++ b/src/Components/Blazor/Build/test/BuildIntegrationTests/MSBuildResult.cs
@@ -0,0 +1,28 @@
+// 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.
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    internal class MSBuildResult
+    {
+        public MSBuildResult(ProjectDirectory project, string fileName, string arguments, int exitCode, string output)
+        {
+            Project = project;
+            FileName = fileName;
+            Arguments = arguments;
+            ExitCode = exitCode;
+            Output = output;
+        }
+
+        public ProjectDirectory Project { get; }
+
+        public string Arguments { get; }
+
+        public string FileName { get; }
+
+        public int ExitCode { get; }
+
+        public string Output { get; }
+    }
+
+}
diff --git a/src/Components/Blazor/Build/test/BuildIntegrationTests/ProjectDirectory.cs b/src/Components/Blazor/Build/test/BuildIntegrationTests/ProjectDirectory.cs
new file mode 100644
index 0000000000000000000000000000000000000000..e50b750ae491917fff833b4fbc7099b63c598515
--- /dev/null
+++ b/src/Components/Blazor/Build/test/BuildIntegrationTests/ProjectDirectory.cs
@@ -0,0 +1,211 @@
+// 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.IO;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    internal class ProjectDirectory : IDisposable
+    {
+        public bool PreserveWorkingDirectory { get; set; } = false;
+
+        private static readonly string RepoRoot = GetTestAttribute("Testing.RepoRoot");
+
+        public static ProjectDirectory Create(string projectName, string baseDirectory = "", string[] additionalProjects = null)
+        {
+            var destinationPath = Path.Combine(Path.GetTempPath(), "BlazorBuild", baseDirectory, Path.GetRandomFileName());
+            Directory.CreateDirectory(destinationPath);
+
+            try
+            {
+                if (Directory.EnumerateFiles(destinationPath).Any())
+                {
+                    throw new InvalidOperationException($"{destinationPath} should be empty");
+                }
+
+                if (string.IsNullOrEmpty(RepoRoot))
+                {
+                    throw new InvalidOperationException("RepoRoot was not specified.");
+                }
+
+                var testAppsRoot = Path.Combine(RepoRoot, "src", "Components", "Blazor", "Build", "testassets");
+                foreach (var project in new string[] { projectName, }.Concat(additionalProjects ?? Array.Empty<string>()))
+                {
+                    var projectRoot = Path.Combine(testAppsRoot, project);
+                    if (!Directory.Exists(projectRoot))
+                    {
+                        throw new InvalidOperationException($"Could not find project at '{projectRoot}'");
+                    }
+
+                    var projectDestination = Path.Combine(destinationPath, project);
+                    var projectDestinationDir = Directory.CreateDirectory(projectDestination);
+                    CopyDirectory(new DirectoryInfo(projectRoot), projectDestinationDir);
+                    SetupDirectoryBuildFiles(RepoRoot, testAppsRoot, projectDestination);
+                }
+
+                var directoryPath = Path.Combine(destinationPath, projectName);
+                var projectPath = Path.Combine(directoryPath, projectName + ".csproj");
+
+                CopyRepositoryAssets(destinationPath);
+
+                return new ProjectDirectory(
+                    destinationPath,
+                    directoryPath,
+                    projectPath);
+            }
+            catch
+            {
+                CleanupDirectory(destinationPath);
+                throw;
+            }
+
+            static void CopyDirectory(DirectoryInfo source, DirectoryInfo destination, bool recursive = true)
+            {
+                foreach (var file in source.EnumerateFiles())
+                {
+                    file.CopyTo(Path.Combine(destination.FullName, file.Name));
+                }
+
+                if (!recursive)
+                {
+                    return;
+                }
+
+                foreach (var directory in source.EnumerateDirectories())
+                {
+                    if (directory.Name == "bin")
+                    {
+                        // Just in case someone has opened the project in an IDE or built it. We don't want to copy
+                        // these folders.
+                        continue;
+                    }
+
+                    var created = destination.CreateSubdirectory(directory.Name);
+                    if (directory.Name == "obj")
+                    {
+                        // Copy NuGet restore assets (viz all the files at the root of the obj directory, but stop there.)
+                        CopyDirectory(directory, created, recursive: false);
+                    }
+                    else
+                    {
+                        CopyDirectory(directory, created);
+                    }
+                }
+            }
+
+            static void SetupDirectoryBuildFiles(string repoRoot, string testAppsRoot, string projectDestination)
+            {
+                var beforeDirectoryPropsContent =
+$@"<Project>
+  <PropertyGroup>
+    <RepoRoot>{repoRoot}</RepoRoot>
+  </PropertyGroup>
+</Project>";
+                File.WriteAllText(Path.Combine(projectDestination, "Before.Directory.Build.props"), beforeDirectoryPropsContent);
+
+                new List<string> { "Directory.Build.props", "Directory.Build.targets", }
+                    .ForEach(file =>
+                    {
+                        var source = Path.Combine(testAppsRoot, file);
+                        var destination = Path.Combine(projectDestination, file);
+                        File.Copy(source, destination);
+                    });
+            }
+
+            static void CopyRepositoryAssets(string projectRoot)
+            {
+                const string GlobalJsonFileName = "global.json";
+                var globalJsonPath = Path.Combine(RepoRoot, GlobalJsonFileName);
+
+                var destinationFile = Path.Combine(projectRoot, GlobalJsonFileName);
+                File.Copy(globalJsonPath, destinationFile);
+            }
+        }
+
+        protected ProjectDirectory(string solutionPath, string directoryPath, string projectFilePath)
+        {
+            SolutionPath = solutionPath;
+            DirectoryPath = directoryPath;
+            ProjectFilePath = projectFilePath;
+        }
+
+        public string DirectoryPath { get; }
+
+        public string ProjectFilePath { get; }
+
+        public string SolutionPath { get; }
+
+        public string TargetFramework { get; set; } = "netstandard2.1";
+
+#if DEBUG
+        public string Configuration => "Debug";
+#elif RELEASE
+        public string Configuration => "Release";
+#else
+#error Configuration not supported
+#endif
+
+        public string IntermediateOutputDirectory => Path.Combine("obj", Configuration, TargetFramework);
+
+        public string BuildOutputDirectory => Path.Combine("bin", Configuration, TargetFramework);
+
+        public string PublishOutputDirectory => Path.Combine(BuildOutputDirectory, "publish");
+
+        internal void AddProjectFileContent(string content)
+        {
+            if (content == null)
+            {
+                throw new ArgumentNullException(nameof(content));
+            }
+
+            var existing = File.ReadAllText(ProjectFilePath);
+            var updated = existing.Replace("<!-- Test Placeholder -->", content);
+            File.WriteAllText(ProjectFilePath, updated);
+        }
+
+        public void Dispose()
+        {
+            if (PreserveWorkingDirectory)
+            {
+                Console.WriteLine($"Skipping deletion of working directory {SolutionPath}");
+            }
+            else
+            {
+                CleanupDirectory(SolutionPath);
+            }
+        }
+
+        internal static void CleanupDirectory(string filePath)
+        {
+            var tries = 5;
+            var sleep = TimeSpan.FromSeconds(3);
+
+            for (var i = 0; i < tries; i++)
+            {
+                try
+                {
+                    Directory.Delete(filePath, recursive: true);
+                    return;
+                }
+                catch when (i < tries - 1)
+                {
+                    Console.WriteLine($"Failed to delete directory {filePath}, trying again.");
+                    Thread.Sleep(sleep);
+                }
+            }
+        }
+
+        private static string GetTestAttribute(string key)
+        {
+            return typeof(ProjectDirectory).Assembly
+                .GetCustomAttributes<AssemblyMetadataAttribute>()
+                .FirstOrDefault(f => f.Key == key)
+                ?.Value;
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/test/BuildIntegrationTests/ProjectDirectoryTest.cs b/src/Components/Blazor/Build/test/BuildIntegrationTests/ProjectDirectoryTest.cs
new file mode 100644
index 0000000000000000000000000000000000000000..23badb4296f86425cff5559f83be78fcbc1e08b3
--- /dev/null
+++ b/src/Components/Blazor/Build/test/BuildIntegrationTests/ProjectDirectoryTest.cs
@@ -0,0 +1,21 @@
+// 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 Xunit;
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    public class ProjectDirectoryTest
+    {
+        [Fact]
+        public void ProjectDirectory_IsNotSetToBePreserved()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("standalone");
+
+            // Act & Assert
+            // This flag is only meant for local debugging and should not be set when checking in.
+            Assert.False(project.PreserveWorkingDirectory);
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/test/BuildIntegrationTests/PublishIntegrationTest.cs b/src/Components/Blazor/Build/test/BuildIntegrationTests/PublishIntegrationTest.cs
new file mode 100644
index 0000000000000000000000000000000000000000..3556f119f4050cc46b0affab20b115b433b47dbc
--- /dev/null
+++ b/src/Components/Blazor/Build/test/BuildIntegrationTests/PublishIntegrationTest.cs
@@ -0,0 +1,224 @@
+// 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.IO;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Blazor.Build
+{
+    public class PublishIntegrationTest
+    {
+        [Fact]
+        public async Task Publish_WithDefaultSettings_Works()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("standalone", additionalProjects: new [] { "razorclasslibrary" });
+            var result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish");
+
+            Assert.BuildPassed(result);
+
+            var publishDirectory = project.PublishOutputDirectory;
+            var blazorPublishDirectory = Path.Combine(publishDirectory, Path.GetFileNameWithoutExtension(project.ProjectFilePath));
+
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "blazor.boot.json");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "blazor.webassembly.js");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "wasm", "dotnet.wasm");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "wasm", "dotnet.js");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "_bin", "standalone.dll");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "_bin", "Microsoft.Extensions.Logging.Abstractions.dll"); // Verify dependencies are part of the output.
+
+            // Verify referenced static web assets
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_content", "RazorClassLibrary", "wwwroot", "exampleJsInterop.js");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_content", "RazorClassLibrary", "styles.css");
+
+            // Verify static assets are in the publish directory
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "index.html");
+
+            // Verify web.config
+            Assert.FileExists(result, publishDirectory, "web.config");
+        }
+
+        [Fact]
+        public async Task Publish_WithNoBuild_Works()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("standalone", additionalProjects: new[] { "razorclasslibrary" });
+            var result = await MSBuildProcessManager.DotnetMSBuild(project, "Build");
+
+            Assert.BuildPassed(result);
+
+            result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish", "/p:NoBuild=true");
+
+            Assert.BuildPassed(result);
+
+            var publishDirectory = project.PublishOutputDirectory;
+            var blazorPublishDirectory = Path.Combine(publishDirectory, Path.GetFileNameWithoutExtension(project.ProjectFilePath));
+
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "blazor.boot.json");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "blazor.webassembly.js");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "wasm", "dotnet.wasm");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "wasm", "dotnet.js");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "_bin", "standalone.dll");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "_bin", "Microsoft.Extensions.Logging.Abstractions.dll"); // Verify dependencies are part of the output.
+
+            // Verify static assets are in the publish directory
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "index.html");
+
+            // Verify static web assets from referenced projects are copied.
+            // Uncomment once https://github.com/aspnet/AspNetCore/issues/17426 is resolved.
+            // Assert.FileExists(result, blazorPublishDirectory, "dist", "_content", "RazorClassLibrary", "wwwroot", "exampleJsInterop.js");
+            // Assert.FileExists(result, blazorPublishDirectory, "dist", "_content", "RazorClassLibrary", "styles.css");
+
+            // Verify static assets are in the publish directory
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "index.html");
+
+            // Verify web.config
+            Assert.FileExists(result, publishDirectory, "web.config");
+        }
+
+        [Fact]
+        public async Task Publish_WithLinkOnBuildDisabled_Works()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("standalone", additionalProjects: new [] { "razorclasslibrary" });
+            project.AddProjectFileContent(
+@"<PropertyGroup>
+    <BlazorLinkOnBuild>false</BlazorLinkOnBuild>
+</PropertyGroup>");
+
+            var result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish");
+
+            Assert.BuildPassed(result);
+
+            var publishDirectory = project.PublishOutputDirectory;
+            var blazorPublishDirectory = Path.Combine(publishDirectory, Path.GetFileNameWithoutExtension(project.ProjectFilePath));
+
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "blazor.boot.json");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "blazor.webassembly.js");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "wasm", "dotnet.wasm");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "wasm", "dotnet.js");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "_bin", "standalone.dll");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "_bin", "Microsoft.Extensions.Logging.Abstractions.dll"); // Verify dependencies are part of the output.
+
+            // Verify static assets are in the publish directory
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "index.html");
+
+            // Verify referenced static web assets
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_content", "RazorClassLibrary", "wwwroot", "exampleJsInterop.js");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_content", "RazorClassLibrary", "styles.css");
+
+            // Verify web.config
+            Assert.FileExists(result, publishDirectory, "web.config");
+        }
+
+        [Fact]
+        public async Task Publish_SatelliteAssemblies_AreCopiedToBuildOutput()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("standalone", additionalProjects: new[] { "razorclasslibrary", "classlibrarywithsatelliteassemblies" });
+            project.AddProjectFileContent(
+@"
+<PropertyGroup>
+    <DefineConstants>$(DefineConstants);REFERENCE_classlibrarywithsatelliteassemblies</DefineConstants>
+</PropertyGroup>
+<ItemGroup>
+    <ProjectReference Include=""..\classlibrarywithsatelliteassemblies\classlibrarywithsatelliteassemblies.csproj"" />
+</ItemGroup>");
+
+            var result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish", args: "/restore");
+
+            Assert.BuildPassed(result);
+
+            var publishDirectory = project.PublishOutputDirectory;
+            var blazorPublishDirectory = Path.Combine(publishDirectory, Path.GetFileNameWithoutExtension(project.ProjectFilePath));
+
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "_bin", "Microsoft.CodeAnalysis.CSharp.dll");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "_bin", "fr", "Microsoft.CodeAnalysis.CSharp.resources.dll"); // Verify satellite assemblies are present in the build output.
+
+            var bootJsonPath = Path.Combine(blazorPublishDirectory, "dist", "_framework", "blazor.boot.json");
+            Assert.FileContains(result, bootJsonPath, "\"Microsoft.CodeAnalysis.CSharp.dll\"");
+            Assert.FileContains(result, bootJsonPath, "\"fr\\/Microsoft.CodeAnalysis.CSharp.resources.dll\"");
+        }
+
+        [Fact]
+        public async Task Publish_HostedApp_Works()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("blazorhosted", additionalProjects: new[] { "standalone", "razorclasslibrary", });
+            project.TargetFramework = "netcoreapp3.1";
+            var result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish");
+
+            Assert.BuildPassed(result);
+
+            var publishDirectory = project.PublishOutputDirectory;
+            // Make sure the main project exists
+            Assert.FileExists(result, publishDirectory, "blazorhosted.dll");
+
+            var blazorPublishDirectory = Path.Combine(publishDirectory, "standalone");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "blazor.boot.json");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "blazor.webassembly.js");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "wasm", "dotnet.wasm");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "wasm", "dotnet.js");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "_bin", "standalone.dll");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "_bin", "Microsoft.Extensions.Logging.Abstractions.dll"); // Verify dependencies are part of the output.
+
+            // Verify static assets are in the publish directory
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "index.html");
+
+            // Verify static web assets from referenced projects are copied.
+            Assert.FileExists(result, publishDirectory, "wwwroot", "_content", "RazorClassLibrary", "wwwroot", "exampleJsInterop.js");
+            Assert.FileExists(result, publishDirectory, "wwwroot", "_content", "RazorClassLibrary", "styles.css");
+
+            // Verify static assets are in the publish directory
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "index.html");
+
+            // Verify web.config
+            Assert.FileExists(result, publishDirectory, "web.config");
+
+            var blazorConfig = Path.Combine(result.Project.DirectoryPath, publishDirectory, "standalone.blazor.config");
+            var blazorConfigLines = File.ReadAllLines(blazorConfig);
+            Assert.Equal(".", blazorConfigLines[0]);
+            Assert.Equal("standalone/", blazorConfigLines[1]);
+        }
+
+        [Fact]
+        public async Task Publish_HostedApp_WithNoBuild_Works()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("blazorhosted", additionalProjects: new[] { "standalone", "razorclasslibrary", });
+            project.TargetFramework = "netcoreapp3.1";
+            var result = await MSBuildProcessManager.DotnetMSBuild(project, "Build");
+
+            Assert.BuildPassed(result);
+
+            result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish", "/p:NoBuild=true");
+
+            var publishDirectory = project.PublishOutputDirectory;
+            // Make sure the main project exists
+            Assert.FileExists(result, publishDirectory, "blazorhosted.dll");
+
+            var blazorPublishDirectory = Path.Combine(publishDirectory, "standalone");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "blazor.boot.json");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "blazor.webassembly.js");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "wasm", "dotnet.wasm");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "wasm", "dotnet.js");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "_bin", "standalone.dll");
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "_framework", "_bin", "Microsoft.Extensions.Logging.Abstractions.dll"); // Verify dependencies are part of the output.
+
+            // Verify static assets are in the publish directory
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "index.html");
+
+            // Verify static web assets from referenced projects are copied.
+            // Uncomment once https://github.com/aspnet/AspNetCore/issues/17426 is resolved.
+            // Assert.FileExists(result, publishDirectory, "wwwroot", "_content", "RazorClassLibrary", "wwwroot", "exampleJsInterop.js");
+            // Assert.FileExists(result, publishDirectory, "wwwroot", "_content", "RazorClassLibrary", "styles.css");
+
+            // Verify static assets are in the publish directory
+            Assert.FileExists(result, blazorPublishDirectory, "dist", "index.html");
+
+            // Verify web.config
+            Assert.FileExists(result, publishDirectory, "web.config");
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs b/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs
index 5838d419d7aa27dbad5919a946d0694887e2f9fe..951b1a61df6cd2ec23502b656177138130f403d7 100644
--- a/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs
+++ b/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs
@@ -9,7 +9,7 @@ using System.Text;
 using Microsoft.AspNetCore.Testing;
 using Xunit;
 
-namespace Microsoft.AspNetCore.Blazor.Build.Test
+namespace Microsoft.AspNetCore.Blazor.Build
 {
     public class RuntimeDependenciesResolverTest
     {
@@ -109,7 +109,7 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test
 
             // Act
 
-            var paths = RuntimeDependenciesResolver
+            var paths = ResolveBlazorRuntimeDependencies
                 .ResolveRuntimeDependenciesCore(
                     mainAssemblyLocation,
                     references,
diff --git a/src/Components/Blazor/Build/testassets/Directory.Build.props b/src/Components/Blazor/Build/testassets/Directory.Build.props
new file mode 100644
index 0000000000000000000000000000000000000000..cf4b11d0cf4500e3c4e4aa57ed73ce2e648e05f2
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/Directory.Build.props
@@ -0,0 +1,31 @@
+<Project>
+    <Import Project="Before.Directory.Build.props" Condition="Exists('Before.Directory.Build.props')" />
+
+    <PropertyGroup>
+      <RepoRoot Condition="'$(RepoRoot)' ==''">$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory), global.json))\</RepoRoot>
+      <ComponentsRoot>$(RepoRoot)src\Components\</ComponentsRoot>
+      <BlazorBuildRoot>$(ComponentsRoot)Blazor\Build\src\</BlazorBuildRoot>
+      <ReferenceBlazorBuildFromSourceProps>$(BlazorBuildRoot)ReferenceBlazorBuildFromSource.props</ReferenceBlazorBuildFromSourceProps>
+
+      <!-- Workaround for https://github.com/aspnet/AspNetCore/issues/17308 -->
+      <DefaultNetCoreTargetFramework>netcoreapp3.1</DefaultNetCoreTargetFramework>
+
+      <EnableSourceLink>false</EnableSourceLink>
+      <DeterministicSourcePaths>false</DeterministicSourcePaths>
+    </PropertyGroup>
+
+    <Import Project="$(RepoRoot)eng\Versions.props" />
+
+    <ItemGroup>
+      <!-- Use the sample compiler \ SDK that the rest of our build uses-->
+      <PackageReference Include="Microsoft.Net.Compilers.Toolset"
+        Version="$(MicrosoftNetCompilersToolsetPackageVersion)"
+        PrivateAssets="all"
+        IsImplicitlyDefined="true" />
+
+      <PackageReference Include="Microsoft.NET.Sdk.Razor"
+        Version="$(MicrosoftNETSdkRazorPackageVersion)"
+        PrivateAssets="All"
+        IsImplicitlyDefined="true" />
+    </ItemGroup>
+</Project>
diff --git a/src/Components/benchmarkapps/Directory.Build.props b/src/Components/Blazor/Build/testassets/Directory.Build.targets
similarity index 100%
rename from src/Components/benchmarkapps/Directory.Build.props
rename to src/Components/Blazor/Build/testassets/Directory.Build.targets
diff --git a/src/Components/Blazor/Build/testassets/blazorhosted/Program.cs b/src/Components/Blazor/Build/testassets/blazorhosted/Program.cs
new file mode 100644
index 0000000000000000000000000000000000000000..e2efcc0c7428cf4ed97b371c73c2809004833d50
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/blazorhosted/Program.cs
@@ -0,0 +1,15 @@
+using System;
+using Microsoft.AspNetCore;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.Configuration;
+
+namespace blazorhosted.Server
+{
+    public class Program
+    {
+        public static void Main(string[] args)
+        {
+            Console.WriteLine(typeof(IWebHost));
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/testassets/blazorhosted/blazorhosted.csproj b/src/Components/Blazor/Build/testassets/blazorhosted/blazorhosted.csproj
new file mode 100644
index 0000000000000000000000000000000000000000..5a89588d8c15a7ac889691d62ff35601f7b34ab7
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/blazorhosted/blazorhosted.csproj
@@ -0,0 +1,12 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+  <PropertyGroup>
+    <TargetFramework>netcoreapp3.1</TargetFramework>
+    <DisableImplicitComponentsAnalyzers>true</DisableImplicitComponentsAnalyzers>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\standalone\standalone.csproj" />
+  </ItemGroup>
+
+</Project>
diff --git a/src/Components/Blazor/Build/testassets/classlibrarywithsatelliteassemblies/Class1.cs b/src/Components/Blazor/Build/testassets/classlibrarywithsatelliteassemblies/Class1.cs
new file mode 100644
index 0000000000000000000000000000000000000000..944699cdb3271f0b0bf50bbdc878b433cd94e8ad
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/classlibrarywithsatelliteassemblies/Class1.cs
@@ -0,0 +1,12 @@
+using System;
+
+namespace classlibrarywithsatelliteassemblies
+{
+    public class Class1
+    {
+        public static void Test()
+        {
+            GC.KeepAlive(typeof(Microsoft.CodeAnalysis.CSharp.CSharpCompilation));
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Components/Blazor/Build/testassets/classlibrarywithsatelliteassemblies/classlibrarywithsatelliteassemblies.csproj b/src/Components/Blazor/Build/testassets/classlibrarywithsatelliteassemblies/classlibrarywithsatelliteassemblies.csproj
new file mode 100644
index 0000000000000000000000000000000000000000..7081842748da4260d24457f7eb78fee0811f5c83
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/classlibrarywithsatelliteassemblies/classlibrarywithsatelliteassemblies.csproj
@@ -0,0 +1,13 @@
+<Project Sdk="Microsoft.NET.Sdk.Razor">
+
+  <PropertyGroup>
+    <TargetFramework>netstandard2.1</TargetFramework>
+    <RazorLangVersion>3.0</RazorLangVersion>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <!-- The compiler package contains quite a few satellite assemblies -->
+    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.4.0" />
+  </ItemGroup>
+
+</Project>
diff --git a/src/Components/Blazor/Build/testassets/razorclasslibrary/RazorClassLibrary.csproj b/src/Components/Blazor/Build/testassets/razorclasslibrary/RazorClassLibrary.csproj
new file mode 100644
index 0000000000000000000000000000000000000000..94e866815d236ea9d06cda828462bac24389f484
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/razorclasslibrary/RazorClassLibrary.csproj
@@ -0,0 +1,8 @@
+<Project Sdk="Microsoft.NET.Sdk.Razor">
+
+  <PropertyGroup>
+    <TargetFramework>netstandard2.1</TargetFramework>
+    <RazorLangVersion>3.0</RazorLangVersion>
+  </PropertyGroup>
+
+</Project>
diff --git a/src/Components/Blazor/Build/testassets/razorclasslibrary/wwwroot/styles.css b/src/Components/Blazor/Build/testassets/razorclasslibrary/wwwroot/styles.css
new file mode 100644
index 0000000000000000000000000000000000000000..5f282702bb03ef11d7184d19c80927b47f919764
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/razorclasslibrary/wwwroot/styles.css
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/Components/Blazor/Build/testassets/razorclasslibrary/wwwroot/wwwroot/exampleJsInterop.js b/src/Components/Blazor/Build/testassets/razorclasslibrary/wwwroot/wwwroot/exampleJsInterop.js
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/src/Components/Blazor/Build/testassets/standalone/App.razor b/src/Components/Blazor/Build/testassets/standalone/App.razor
new file mode 100644
index 0000000000000000000000000000000000000000..eba23da9b5ae1236f444d5b5f8b785d5aa62284e
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/standalone/App.razor
@@ -0,0 +1,8 @@
+<Router AppAssembly="@typeof(Program).Assembly">
+    <Found Context="routeData">
+        <RouteView RouteData="@routeData"/>
+    </Found>
+    <NotFound>
+        <p>Sorry, there's nothing at this address.</p>
+    </NotFound>
+</Router>
diff --git a/src/Components/Blazor/Build/testassets/standalone/Pages/Index.razor b/src/Components/Blazor/Build/testassets/standalone/Pages/Index.razor
new file mode 100644
index 0000000000000000000000000000000000000000..16dac31925206033d83028c6bb6116c24074bf0c
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/standalone/Pages/Index.razor
@@ -0,0 +1,5 @@
+@page "/"
+
+<h1>Hello, world!</h1>
+
+Welcome to your new app.
diff --git a/src/Components/Blazor/Build/testassets/standalone/Program.cs b/src/Components/Blazor/Build/testassets/standalone/Program.cs
new file mode 100644
index 0000000000000000000000000000000000000000..3e46e6331662e77fcda07f971253d6bbd6fa8327
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/standalone/Program.cs
@@ -0,0 +1,14 @@
+using System;
+
+namespace standalone
+{
+    public class Program
+    {
+        public static void Main(string[] args)
+        {
+#if REFERENCE_classlibrarywithsatelliteassemblies
+            GC.KeepAlive(typeof(classlibrarywithsatelliteassemblies.Class1));
+#endif
+        }
+    }
+}
diff --git a/src/Components/Blazor/Build/testassets/standalone/_Imports.razor b/src/Components/Blazor/Build/testassets/standalone/_Imports.razor
new file mode 100644
index 0000000000000000000000000000000000000000..129b440e86003e92ec24240999e699c4727c3754
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/standalone/_Imports.razor
@@ -0,0 +1,2 @@
+@using Microsoft.AspNetCore.Components.Routing
+@using standalone
diff --git a/src/Components/Blazor/Build/testassets/standalone/standalone.csproj b/src/Components/Blazor/Build/testassets/standalone/standalone.csproj
new file mode 100644
index 0000000000000000000000000000000000000000..1b13eb3d5357e932e50dad908dbf1d64820537d1
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/standalone/standalone.csproj
@@ -0,0 +1,20 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+  <Import Project="$(ReferenceBlazorBuildFromSourceProps)" />
+
+  <PropertyGroup>
+    <TargetFramework>netstandard2.1</TargetFramework>
+    <RazorLangVersion>3.0</RazorLangVersion>
+  </PropertyGroup>
+
+  <!-- Test Placeholder -->
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.AspNetCore.Components" Version="3.0.0" />
+    <PackageReference Include="Microsoft.AspNetCore.Blazor.Mono" Version="$(MicrosoftAspNetCoreBlazorMonoPackageVersion)" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\razorclasslibrary\RazorClassLibrary.csproj" />
+  </ItemGroup>
+
+</Project>
diff --git a/src/Components/Blazor/Build/testassets/standalone/wwwroot/index.html b/src/Components/Blazor/Build/testassets/standalone/wwwroot/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..85994d6e89fed5cabbc16969c5cb972c8dd3476c
--- /dev/null
+++ b/src/Components/Blazor/Build/testassets/standalone/wwwroot/index.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width" />
+    <title>standalone</title>
+    <base href="/" />
+    <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
+    <link href="css/site.css" rel="stylesheet" />
+</head>
+
+<body>
+    <app>Loading...</app>
+
+    <div id="blazor-error-ui">
+        An unhandled error has occurred.
+        <a href="" class="reload">Reload</a>
+        <a class="dismiss">🗙</a>
+    </div>
+    <script src="_framework/blazor.webassembly.js"></script>
+</body>
+
+</html>
diff --git a/src/Components/Blazor/DevServer/src/Microsoft.AspNetCore.Blazor.DevServer.csproj b/src/Components/Blazor/DevServer/src/Microsoft.AspNetCore.Blazor.DevServer.csproj
index 53514cf36c5d21eccc1da5e6f7a13ff7bf780a82..4ccf78104b9cefbc40543804ee9073d032051dd4 100644
--- a/src/Components/Blazor/DevServer/src/Microsoft.AspNetCore.Blazor.DevServer.csproj
+++ b/src/Components/Blazor/DevServer/src/Microsoft.AspNetCore.Blazor.DevServer.csproj
@@ -11,15 +11,14 @@
     <Description>Development server for use when building Blazor applications.</Description>
     <!-- Set this to false because assemblies should not reference this assembly directly, (except for tests, of course). -->
     <IsProjectReferenceProvider>false</IsProjectReferenceProvider>
+
+    <!-- This is so that we add the FrameworkReference to Microsoft.AspNetCore.App -->
+    <UseLatestAspNetCoreReference>true</UseLatestAspNetCoreReference>
   </PropertyGroup>
 
   <ItemGroup>
     <Reference Include="Microsoft.AspNetCore.Blazor.Server" />
-    <Reference Include="Microsoft.AspNetCore.Components.Server" />
-    <Reference Include="Microsoft.AspNetCore.ResponseCompression" />
-    <Reference Include="Microsoft.AspNetCore" />
     <Reference Include="Microsoft.Extensions.CommandLineUtils.Sources" />
-    <Reference Include="Microsoft.Extensions.Hosting" />
   </ItemGroup>
 
   <!-- Pack settings -->
diff --git a/src/Components/Blazor/Directory.Build.targets b/src/Components/Blazor/Directory.Build.targets
index 178608d3e5effd957031b8a0a014eaa80ceb9f7a..e1a17eb9ca9cb3552c0c2f61dab5c5de1efa12be 100644
--- a/src/Components/Blazor/Directory.Build.targets
+++ b/src/Components/Blazor/Directory.Build.targets
@@ -4,4 +4,5 @@
   <PropertyGroup>
     <ComponentsPackageVersion>$(PackageVersion)</ComponentsPackageVersion>
   </PropertyGroup>
+
 </Project>
diff --git a/src/Components/Blazor/Http/src/Microsoft.AspNetCore.Blazor.HttpClient.csproj b/src/Components/Blazor/Http/src/Microsoft.AspNetCore.Blazor.HttpClient.csproj
index 9c5974d9d6f77380f0f0c813bb3003da3f636660..9d6deb6173bbac14197de55e5a47be527ef2fd0f 100644
--- a/src/Components/Blazor/Http/src/Microsoft.AspNetCore.Blazor.HttpClient.csproj
+++ b/src/Components/Blazor/Http/src/Microsoft.AspNetCore.Blazor.HttpClient.csproj
@@ -4,6 +4,7 @@
     <TargetFramework>netstandard2.0</TargetFramework>
     <Description>Provides experimental support for using System.Text.Json with HttpClient. Intended for use with Blazor running under WebAssembly.</Description>
     <IsShippingPackage>false</IsShippingPackage>
+    <HasReferenceAssembly>false</HasReferenceAssembly>
   </PropertyGroup>
 
   <ItemGroup>
diff --git a/src/Components/Blazor/Mono.WebAssembly.Interop/src/InternalCalls.cs b/src/Components/Blazor/Mono.WebAssembly.Interop/src/InternalCalls.cs
new file mode 100644
index 0000000000000000000000000000000000000000..60c0cdc42913ae2e8f2034afe299cf8f91555372
--- /dev/null
+++ b/src/Components/Blazor/Mono.WebAssembly.Interop/src/InternalCalls.cs
@@ -0,0 +1,25 @@
+// 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.Runtime.CompilerServices;
+
+namespace WebAssembly.JSInterop
+{
+    /// <summary>
+    /// Methods that map to the functions compiled into the Mono WebAssembly runtime,
+    /// as defined by 'mono_add_internal_call' calls in driver.c
+    /// </summary>
+    internal class InternalCalls
+    {
+        // The exact namespace, type, and method names must match the corresponding entries
+        // in driver.c in the Mono distribution
+
+        // We're passing asyncHandle by ref not because we want it to be writable, but so it gets
+        // passed as a pointer (4 bytes). We can pass 4-byte values, but not 8-byte ones.
+        [MethodImpl(MethodImplOptions.InternalCall)]
+        public static extern string InvokeJSMarshalled(out string exception, ref long asyncHandle, string functionIdentifier, string argsJson);
+
+        [MethodImpl(MethodImplOptions.InternalCall)]
+        public static extern TRes InvokeJSUnmarshalled<T0, T1, T2, TRes>(out string exception, string functionIdentifier, T0 arg0, T1 arg1, T2 arg2);
+    }
+}
diff --git a/src/Components/Blazor/Mono.WebAssembly.Interop/src/Mono.WebAssembly.Interop.csproj b/src/Components/Blazor/Mono.WebAssembly.Interop/src/Mono.WebAssembly.Interop.csproj
new file mode 100644
index 0000000000000000000000000000000000000000..ea714b2d92dc9c17f6299c89a4229ee27b138b6b
--- /dev/null
+++ b/src/Components/Blazor/Mono.WebAssembly.Interop/src/Mono.WebAssembly.Interop.csproj
@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>netstandard2.1</TargetFramework>
+    <Description>Abstractions and features for interop between Mono WebAssembly and JavaScript code.</Description>
+    <PackageTags>wasm;javascript;interop</PackageTags>
+    <GenerateDocumentationFile>true</GenerateDocumentationFile>
+    <IsPackable>true</IsPackable>
+    <IsShipping>true</IsShipping>
+    <HasReferenceAssembly>false</HasReferenceAssembly>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.JSInterop" />
+  </ItemGroup>
+
+</Project>
diff --git a/src/Components/Blazor/Mono.WebAssembly.Interop/src/MonoWebAssemblyJSRuntime.cs b/src/Components/Blazor/Mono.WebAssembly.Interop/src/MonoWebAssemblyJSRuntime.cs
new file mode 100644
index 0000000000000000000000000000000000000000..654263a12331545912510d54c4cbf2a9407f2884
--- /dev/null
+++ b/src/Components/Blazor/Mono.WebAssembly.Interop/src/MonoWebAssemblyJSRuntime.cs
@@ -0,0 +1,157 @@
+// 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.Text.Json;
+using Microsoft.JSInterop;
+using Microsoft.JSInterop.Infrastructure;
+using WebAssembly.JSInterop;
+
+namespace Mono.WebAssembly.Interop
+{
+    /// <summary>
+    /// Provides methods for invoking JavaScript functions for applications running
+    /// on the Mono WebAssembly runtime.
+    /// </summary>
+    public class MonoWebAssemblyJSRuntime : JSInProcessRuntime
+    {
+        /// <summary>
+        /// Gets the <see cref="MonoWebAssemblyJSRuntime"/> used to perform operations using <see cref="DotNetDispatcher"/>.
+        /// </summary>
+        private static MonoWebAssemblyJSRuntime Instance { get; set; }
+
+        /// <summary>
+        /// Initializes the <see cref="MonoWebAssemblyJSRuntime"/> to be used to perform operations using <see cref="DotNetDispatcher"/>.
+        /// </summary>
+        /// <param name="jsRuntime">The <see cref="MonoWebAssemblyJSRuntime"/> instance.</param>
+        protected static void Initialize(MonoWebAssemblyJSRuntime jsRuntime)
+        {
+            if (Instance != null)
+            {
+                throw new InvalidOperationException("MonoWebAssemblyJSRuntime has already been initialized.");
+            }
+
+            Instance = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime));
+        }
+
+        /// <inheritdoc />
+        protected override string InvokeJS(string identifier, string argsJson)
+        {
+            var noAsyncHandle = default(long);
+            var result = InternalCalls.InvokeJSMarshalled(out var exception, ref noAsyncHandle, identifier, argsJson);
+            return exception != null
+                ? throw new JSException(exception)
+                : result;
+        }
+
+        /// <inheritdoc />
+        protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson)
+        {
+            InternalCalls.InvokeJSMarshalled(out _, ref asyncHandle, identifier, argsJson);
+        }
+
+        // Invoked via Mono's JS interop mechanism (invoke_method)
+        private static string InvokeDotNet(string assemblyName, string methodIdentifier, string dotNetObjectId, string argsJson)
+        {
+            var callInfo = new DotNetInvocationInfo(assemblyName, methodIdentifier, dotNetObjectId == null ? default : long.Parse(dotNetObjectId), callId: null);
+            return DotNetDispatcher.Invoke(Instance, callInfo, argsJson);
+        }
+
+        // Invoked via Mono's JS interop mechanism (invoke_method)
+        private static void EndInvokeJS(string argsJson)
+            => DotNetDispatcher.EndInvokeJS(Instance, argsJson);
+
+        // Invoked via Mono's JS interop mechanism (invoke_method)
+        private static void BeginInvokeDotNet(string callId, string assemblyNameOrDotNetObjectId, string methodIdentifier, string argsJson)
+        {
+            // Figure out whether 'assemblyNameOrDotNetObjectId' is the assembly name or the instance ID
+            // We only need one for any given call. This helps to work around the limitation that we can
+            // only pass a maximum of 4 args in a call from JS to Mono WebAssembly.
+            string assemblyName;
+            long dotNetObjectId;
+            if (char.IsDigit(assemblyNameOrDotNetObjectId[0]))
+            {
+                dotNetObjectId = long.Parse(assemblyNameOrDotNetObjectId);
+                assemblyName = null;
+            }
+            else
+            {
+                dotNetObjectId = default;
+                assemblyName = assemblyNameOrDotNetObjectId;
+            }
+
+            var callInfo = new DotNetInvocationInfo(assemblyName, methodIdentifier, dotNetObjectId, callId);
+            DotNetDispatcher.BeginInvokeDotNet(Instance, callInfo, argsJson);
+        }
+
+        protected override void EndInvokeDotNet(DotNetInvocationInfo callInfo, in DotNetInvocationResult dispatchResult)
+        {
+            // For failures, the common case is to call EndInvokeDotNet with the Exception object.
+            // For these we'll serialize as something that's useful to receive on the JS side.
+            // If the value is not an Exception, we'll just rely on it being directly JSON-serializable.
+            var resultOrError = dispatchResult.Success ? dispatchResult.Result : dispatchResult.Exception.ToString();
+
+            // We pass 0 as the async handle because we don't want the JS-side code to
+            // send back any notification (we're just providing a result for an existing async call)
+            var args = JsonSerializer.Serialize(new[] { callInfo.CallId, dispatchResult.Success, resultOrError }, JsonSerializerOptions);
+            BeginInvokeJS(0, "DotNet.jsCallDispatcher.endInvokeDotNetFromJS", args);
+        }
+
+        #region Custom MonoWebAssemblyJSRuntime methods
+
+        /// <summary>
+        /// Invokes the JavaScript function registered with the specified identifier.
+        /// </summary>
+        /// <typeparam name="TRes">The .NET type corresponding to the function's return value type.</typeparam>
+        /// <param name="identifier">The identifier used when registering the target function.</param>
+        /// <returns>The result of the function invocation.</returns>
+        public TRes InvokeUnmarshalled<TRes>(string identifier)
+            => InvokeUnmarshalled<object, object, object, TRes>(identifier, null, null, null);
+
+        /// <summary>
+        /// Invokes the JavaScript function registered with the specified identifier.
+        /// </summary>
+        /// <typeparam name="T0">The type of the first argument.</typeparam>
+        /// <typeparam name="TRes">The .NET type corresponding to the function's return value type.</typeparam>
+        /// <param name="identifier">The identifier used when registering the target function.</param>
+        /// <param name="arg0">The first argument.</param>
+        /// <returns>The result of the function invocation.</returns>
+        public TRes InvokeUnmarshalled<T0, TRes>(string identifier, T0 arg0)
+            => InvokeUnmarshalled<T0, object, object, TRes>(identifier, arg0, null, null);
+
+        /// <summary>
+        /// Invokes the JavaScript function registered with the specified identifier.
+        /// </summary>
+        /// <typeparam name="T0">The type of the first argument.</typeparam>
+        /// <typeparam name="T1">The type of the second argument.</typeparam>
+        /// <typeparam name="TRes">The .NET type corresponding to the function's return value type.</typeparam>
+        /// <param name="identifier">The identifier used when registering the target function.</param>
+        /// <param name="arg0">The first argument.</param>
+        /// <param name="arg1">The second argument.</param>
+        /// <returns>The result of the function invocation.</returns>
+        public TRes InvokeUnmarshalled<T0, T1, TRes>(string identifier, T0 arg0, T1 arg1)
+            => InvokeUnmarshalled<T0, T1, object, TRes>(identifier, arg0, arg1, null);
+
+        /// <summary>
+        /// Invokes the JavaScript function registered with the specified identifier.
+        /// </summary>
+        /// <typeparam name="T0">The type of the first argument.</typeparam>
+        /// <typeparam name="T1">The type of the second argument.</typeparam>
+        /// <typeparam name="T2">The type of the third argument.</typeparam>
+        /// <typeparam name="TRes">The .NET type corresponding to the function's return value type.</typeparam>
+        /// <param name="identifier">The identifier used when registering the target function.</param>
+        /// <param name="arg0">The first argument.</param>
+        /// <param name="arg1">The second argument.</param>
+        /// <param name="arg2">The third argument.</param>
+        /// <returns>The result of the function invocation.</returns>
+        public TRes InvokeUnmarshalled<T0, T1, T2, TRes>(string identifier, T0 arg0, T1 arg1, T2 arg2)
+        {
+            var result = InternalCalls.InvokeJSUnmarshalled<T0, T1, T2, TRes>(out var exception, identifier, arg0, arg1, arg2);
+            return exception != null
+                ? throw new JSException(exception)
+                : result;
+        }
+
+        #endregion
+    }
+}
diff --git a/src/Components/Blazor/Server/src/Microsoft.AspNetCore.Blazor.Server.csproj b/src/Components/Blazor/Server/src/Microsoft.AspNetCore.Blazor.Server.csproj
index ab6c3b6ee62dcfac5c092c1484db8c3c6aae1a3e..7596c1a8cb774744a69a6ea7253bf512d9a3db13 100644
--- a/src/Components/Blazor/Server/src/Microsoft.AspNetCore.Blazor.Server.csproj
+++ b/src/Components/Blazor/Server/src/Microsoft.AspNetCore.Blazor.Server.csproj
@@ -4,6 +4,9 @@
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
     <Description>Runtime server features for ASP.NET Core Blazor applications.</Description>
     <IsShippingPackage>false</IsShippingPackage>
+    <HasReferenceAssembly>false</HasReferenceAssembly>
+    <!-- This is so that we add the FrameworkReference to Microsoft.AspNetCore.App -->
+    <UseLatestAspNetCoreReference>true</UseLatestAspNetCoreReference>
   </PropertyGroup>
 
   <ItemGroup>
@@ -11,11 +14,6 @@
   </ItemGroup>
 
   <ItemGroup>
-    <Reference Include="Microsoft.AspNetCore.Hosting.Abstractions" />
-    <Reference Include="Microsoft.AspNetCore.SpaServices.Extensions" />
-    <Reference Include="Microsoft.AspNetCore.StaticFiles" />
-    <Reference Include="Microsoft.AspNetCore.WebSockets" />
-    <Reference Include="Microsoft.Extensions.FileProviders.Embedded" />
     <Reference Include="Newtonsoft.Json" />
 
     <!-- Used by ws-proxy sources only. Remove this once we're able to consume ws-proxy as a NuGet package. -->
diff --git a/src/Components/Blazor/Server/src/MonoDebugProxy/BlazorMonoDebugProxyAppBuilderExtensions.cs b/src/Components/Blazor/Server/src/MonoDebugProxy/BlazorMonoDebugProxyAppBuilderExtensions.cs
index 533cd9939985ad7da8f5a2f3ba90e82f2f1304c1..cbe0fe363a67ce3870dd42c6ba0fc3fb9968b164 100644
--- a/src/Components/Blazor/Server/src/MonoDebugProxy/BlazorMonoDebugProxyAppBuilderExtensions.cs
+++ b/src/Components/Blazor/Server/src/MonoDebugProxy/BlazorMonoDebugProxyAppBuilderExtensions.cs
@@ -9,9 +9,9 @@ using System.Linq;
 using System.Net;
 using System.Net.Http;
 using System.Runtime.InteropServices;
+using System.Text.Json;
 using System.Threading.Tasks;
 using Microsoft.AspNetCore.Http;
-using Newtonsoft.Json;
 using WsProxy;
 
 namespace Microsoft.AspNetCore.Builder
@@ -21,6 +21,15 @@ namespace Microsoft.AspNetCore.Builder
     /// </summary>
     public static class BlazorMonoDebugProxyAppBuilderExtensions
     {
+        private static JsonSerializerOptions JsonOptions = new JsonSerializerOptions
+        {
+            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+            PropertyNameCaseInsensitive = true,
+            IgnoreNullValues = true
+        };
+
+        private static string DefaultDebuggerHost = "http://localhost:9222";
+
         /// <summary>
         /// Adds middleware for needed for debugging Blazor applications
         /// inside Chromium dev tools.
@@ -29,6 +38,8 @@ namespace Microsoft.AspNetCore.Builder
         {
             app.UseWebSockets();
 
+            app.UseVisualStudioDebuggerConnectionRequestHandlers();
+
             app.Use((context, next) =>
             {
                 var requestPath = context.Request.Path;
@@ -52,6 +63,85 @@ namespace Microsoft.AspNetCore.Builder
             });
         }
 
+        private static string GetDebuggerHost()
+        {
+            var envVar = Environment.GetEnvironmentVariable("ASPNETCORE_WEBASSEMBLYDEBUGHOST");
+
+            if (string.IsNullOrEmpty(envVar))
+            {
+                return DefaultDebuggerHost;
+            }
+            else
+            {
+                return envVar;
+            }
+        }
+
+        private static int GetDebuggerPort()
+        {
+            var host = GetDebuggerHost();
+            return new Uri(host).Port;
+        }
+
+        private static void UseVisualStudioDebuggerConnectionRequestHandlers(this IApplicationBuilder app)
+        {
+            // Unfortunately VS doesn't send any deliberately distinguishing information so we know it's
+            // not a regular browser or API client. The closest we can do is look for the *absence* of a
+            // User-Agent header. In the future, we should try to get VS to send a special header to indicate
+            // this is a debugger metadata request.
+            app.Use(async (context, next) =>
+            {
+                var request = context.Request;
+                var requestPath = request.Path;
+                if (requestPath.StartsWithSegments("/json")
+                    && !request.Headers.ContainsKey("User-Agent"))
+                {
+                    if (requestPath.Equals("/json", StringComparison.OrdinalIgnoreCase) || requestPath.Equals("/json/list", StringComparison.OrdinalIgnoreCase))
+                    {
+                        var availableTabs = await GetOpenedBrowserTabs();
+
+                        // Filter the list to only include tabs displaying the requested app,
+                        // but only during the "choose application to debug" phase. We can't apply
+                        // the same filter during the "connecting" phase (/json/list), nor do we need to.
+                        if (requestPath.Equals("/json"))
+                        {
+                            availableTabs = availableTabs.Where(tab => tab.Url.StartsWith($"{request.Scheme}://{request.Host}{request.PathBase}/"));
+                        }
+
+                        var proxiedTabInfos = availableTabs.Select(tab =>
+                        {
+                            var underlyingV8Endpoint = tab.WebSocketDebuggerUrl;
+                            var proxiedV8Endpoint = $"ws://{request.Host}{request.PathBase}/_framework/debug/ws-proxy?browser={WebUtility.UrlEncode(underlyingV8Endpoint)}";
+                            return new
+                            {
+                                description = "",
+                                devtoolsFrontendUrl = "",
+                                id = tab.Id,
+                                title = tab.Title,
+                                type = tab.Type,
+                                url = tab.Url,
+                                webSocketDebuggerUrl = proxiedV8Endpoint
+                            };
+                        });
+
+                        context.Response.ContentType = "application/json";
+                        await context.Response.WriteAsync(JsonSerializer.Serialize(proxiedTabInfos));
+                    }
+                    else if (requestPath.Equals("/json/version", StringComparison.OrdinalIgnoreCase))
+                    {
+                        var browserVersionJson = await GetBrowserVersionInfoAsync();
+
+                        context.Response.ContentType = "application/json";
+                        await context.Response.WriteAsync(browserVersionJson);
+                    }
+                }
+                else
+                {
+                    await next();
+                }
+            });
+        }
+
         private static async Task DebugWebSocketProxyRequest(HttpContext context)
         {
             if (!context.WebSockets.IsWebSocketRequest)
@@ -81,13 +171,13 @@ namespace Microsoft.AspNetCore.Builder
 
             // TODO: Allow overriding port (but not hostname, as we're connecting to the
             // local browser, not to the webserver serving the app)
-            var debuggerHost = "http://localhost:9222";
+            var debuggerHost = GetDebuggerHost();
             var debuggerTabsListUrl = $"{debuggerHost}/json";
             IEnumerable<BrowserTab> availableTabs;
 
             try
             {
-                availableTabs = await GetOpenedBrowserTabs(debuggerHost);
+                availableTabs = await GetOpenedBrowserTabs();
             }
             catch (Exception ex)
             {
@@ -147,28 +237,30 @@ namespace Microsoft.AspNetCore.Builder
             var underlyingV8Endpoint = tabToDebug.WebSocketDebuggerUrl;
             var proxyEndpoint = $"{request.Host}{request.PathBase}/_framework/debug/ws-proxy?browser={WebUtility.UrlEncode(underlyingV8Endpoint)}";
             var devToolsUrlAbsolute = new Uri(debuggerHost + tabToDebug.DevtoolsFrontendUrl);
-            var devToolsUrlWithProxy = $"{devToolsUrlAbsolute.Scheme}://{devToolsUrlAbsolute.Authority}{devToolsUrlAbsolute.AbsolutePath}?ws={proxyEndpoint}";
+            var wsParamName = request.IsHttps ? "wss" : "ws";
+            var devToolsUrlWithProxy = $"{devToolsUrlAbsolute.Scheme}://{devToolsUrlAbsolute.Authority}{devToolsUrlAbsolute.AbsolutePath}?{wsParamName}={proxyEndpoint}";
             context.Response.Redirect(devToolsUrlWithProxy);
         }
 
         private static string GetLaunchChromeInstructions(string appRootUrl)
         {
-            var profilePath = Path.Combine(Path.GetTempPath(), "blazor-edge-debug");
+            var profilePath = Path.Combine(Path.GetTempPath(), "blazor-chrome-debug");
+            var debuggerPort = GetDebuggerPort();
 
             if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
             {
                 return $@"<p>Press Win+R and enter the following:</p>
-                          <p><strong><code>chrome --remote-debugging-port=9222 --user-data-dir=""{profilePath}"" {appRootUrl}</code></strong></p>";
+                          <p><strong><code>chrome --remote-debugging-port={debuggerPort} --user-data-dir=""{profilePath}"" {appRootUrl}</code></strong></p>";
             }
             else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
             {
                 return $@"<p>In a terminal window execute the following:</p>
-                          <p><strong><code>google-chrome --remote-debugging-port=9222 --user-data-dir={profilePath} {appRootUrl}</code></strong></p>";
+                          <p><strong><code>google-chrome --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {appRootUrl}</code></strong></p>";
             }
             else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
             {
                 return $@"<p>Execute the following:</p>
-                          <p><strong><code>open /Applications/Google\ Chrome.app --args --remote-debugging-port=9222 --user-data-dir={profilePath} {appRootUrl}</code></strong></p>";
+                          <p><strong><code>open /Applications/Google\ Chrome.app --args --remote-debugging-port={debuggerPort} --user-data-dir={profilePath} {appRootUrl}</code></strong></p>";
             }
             else
             {
@@ -178,17 +270,18 @@ namespace Microsoft.AspNetCore.Builder
 
         private static string GetLaunchEdgeInstructions(string appRootUrl)
         {
-            var profilePath = Path.Combine(Path.GetTempPath(), "blazor-chrome-debug");
+            var profilePath = Path.Combine(Path.GetTempPath(), "blazor-edge-debug");
+            var debugggerPort = GetDebuggerPort();
 
             if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
             {
                 return $@"<p>Press Win+R and enter the following:</p>
-                          <p><strong><code>msedge --remote-debugging-port=9222 --user-data-dir=""{profilePath}"" {appRootUrl}</code></strong></p>";
+                          <p><strong><code>msedge --remote-debugging-port={debugggerPort} --user-data-dir=""{profilePath}"" --no-first-run {appRootUrl}</code></strong></p>";
             }
             else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
             {
                 return $@"<p>In a terminal window execute the following:</p>
-                          <p><strong><code>open /Applications/Microsoft\ Edge\ Dev.app --args --remote-debugging-port=9222 --user-data-dir={profilePath} {appRootUrl}</code></strong></p>";
+                          <p><strong><code>open /Applications/Microsoft\ Edge\ Dev.app --args --remote-debugging-port={debugggerPort} --user-data-dir={profilePath} {appRootUrl}</code></strong></p>";
             }
             else
             {
@@ -196,17 +289,24 @@ namespace Microsoft.AspNetCore.Builder
             }
         }
 
-        private static async Task<IEnumerable<BrowserTab>> GetOpenedBrowserTabs(string debuggerHost)
+        private static async Task<string> GetBrowserVersionInfoAsync()
         {
-            using (var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) })
-            {
-                var jsonResponse = await httpClient.GetStringAsync($"{debuggerHost}/json");
-                return JsonConvert.DeserializeObject<BrowserTab[]>(jsonResponse);
-            }
+            using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+            var debuggerHost = GetDebuggerHost();
+            return await httpClient.GetStringAsync($"{debuggerHost}/json/version");
+        }
+
+        private static async Task<IEnumerable<BrowserTab>> GetOpenedBrowserTabs()
+        {
+            using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+            var debuggerHost = GetDebuggerHost();
+            var jsonResponse = await httpClient.GetStringAsync($"{debuggerHost}/json");
+            return JsonSerializer.Deserialize<BrowserTab[]>(jsonResponse, JsonOptions);
         }
 
         class BrowserTab
         {
+            public string Id { get; set; }
             public string Type { get; set; }
             public string Url { get; set; }
             public string Title { get; set; }
diff --git a/src/Components/Blazor/Server/src/MonoDebugProxy/ws-proxy/DebugStore.cs b/src/Components/Blazor/Server/src/MonoDebugProxy/ws-proxy/DebugStore.cs
index ee28bd94b76d9248a14f0865c95bdeb27c3f1bc1..e1e9b7392ba854634cce785eb6fe9e7329a5af04 100644
--- a/src/Components/Blazor/Server/src/MonoDebugProxy/ws-proxy/DebugStore.cs
+++ b/src/Components/Blazor/Server/src/MonoDebugProxy/ws-proxy/DebugStore.cs
@@ -545,7 +545,7 @@ namespace WsProxy {
 			return assemblies.FirstOrDefault (a => a.Name.Equals (name, StringComparison.InvariantCultureIgnoreCase));
 		}
 
-		/*
+		/*	
 		V8 uses zero based indexing for both line and column.
 		PPDBs uses one based indexing for both line and column.
 		*/
@@ -598,7 +598,7 @@ namespace WsProxy {
 		PPDBs uses one based indexing for both line and column.
 		*/
 		static bool Match (SequencePoint sp, int line, int column)
-		{
+		{ 
 			var bp = (line: line + 1, column: column + 1);
 
 			if (sp.StartLine > bp.line || sp.EndLine < bp.line)
diff --git a/src/Components/Blazor/Server/src/MonoDebugProxy/ws-proxy/MonoProxy.cs b/src/Components/Blazor/Server/src/MonoDebugProxy/ws-proxy/MonoProxy.cs
index 8c440da1ce2452b0a69fb30a0f54617f11ebe674..eb4cf65b50a41b54e41375e0939b4b104a70ca29 100644
--- a/src/Components/Blazor/Server/src/MonoDebugProxy/ws-proxy/MonoProxy.cs
+++ b/src/Components/Blazor/Server/src/MonoDebugProxy/ws-proxy/MonoProxy.cs
@@ -223,7 +223,7 @@ namespace WsProxy {
 			Info ("RUNTIME READY, PARTY TIME");
 			await RuntimeReady (token);
 			await SendCommand ("Debugger.resume", new JObject (), token);
-			SendEvent ("Mono.runtimeReady", new JObject (), token);
+			SendEvent ("Mono.runtimeReady", new JObject (), token);			
 		}
 
 		async Task OnBreakPointHit (JObject args, CancellationToken token)
@@ -274,12 +274,18 @@ namespace WsProxy {
 					var frames = new List<Frame> ();
 					int frame_id = 0;
 					var the_mono_frames = res.Value? ["result"]? ["value"]? ["frames"]?.Values<JObject> ();
+
 					foreach (var mono_frame in the_mono_frames) {
 						var il_pos = mono_frame ["il_pos"].Value<int> ();
 						var method_token = mono_frame ["method_token"].Value<int> ();
 						var assembly_name = mono_frame ["assembly_name"].Value<string> ();
 
 						var asm = store.GetAssemblyByName (assembly_name);
+						if (asm == null) {
+							Info ($"Unable to find assembly: {assembly_name}");
+							continue;
+						}
+
 						var method = asm.GetMethodByToken (method_token);
 
 						if (method == null) {
@@ -374,7 +380,7 @@ namespace WsProxy {
 			//Debug ($"\t{is_ready}");
 			if (is_ready.HasValue && is_ready.Value == true) {
 				Debug ("RUNTIME LOOK READY. GO TIME!");
-				await RuntimeReady (token);
+				await OnRuntimeReady (token);
 			}
 		}
 
@@ -426,33 +432,38 @@ namespace WsProxy {
 				return;
 			}
 
-			var values = res.Value?["result"]?["value"]?.Values<JObject>().ToArray();
+			try {
+				var values = res.Value?["result"]?["value"]?.Values<JObject>().ToArray() ?? Array.Empty<JObject>();
+				var var_list = new List<JObject>();
+
+				// Trying to inspect the stack frame for DotNetDispatcher::InvokeSynchronously
+				// results in a "Memory access out of bounds", causing 'values' to be null,
+				// so skip returning variable values in that case.
+				for (int i = 0; i < values.Length; i+=2)
+				{
+					string fieldName = (string)values[i]["name"];
+					if (fieldName.Contains("k__BackingField")){
+						fieldName = fieldName.Replace("k__BackingField", "");
+						fieldName = fieldName.Replace("<", "");
+						fieldName = fieldName.Replace(">", "");
+					}
+					var value = values [i + 1]? ["value"];
+					if (((string)value ["description"]) == null)
+						value ["description"] = value ["value"]?.ToString ();
 
-			var var_list = new List<JObject>();
-
-			// Trying to inspect the stack frame for DotNetDispatcher::InvokeSynchronously
-			// results in a "Memory access out of bounds", causing 'values' to be null,
-			// so skip returning variable values in that case.
-			for (int i = 0; i < values.Length; i+=2)
-			{
-				string fieldName = (string)values[i]["name"];
-				if (fieldName.Contains("k__BackingField")){
-				fieldName = fieldName.Replace("k__BackingField", "");
-				fieldName = fieldName.Replace("<", "");
-				fieldName = fieldName.Replace(">", "");
-			}
-			var_list.Add(JObject.FromObject(new
-			{
-				name = fieldName,
-				value = values[i+1]["value"]
-			}));
+					var_list.Add(JObject.FromObject(new {
+						name = fieldName,
+						value
+					}));
 
+				}
+				o = JObject.FromObject(new
+				{
+					result = var_list
+				});
+			} catch (Exception) {
+				Debug ($"failed to parse {res.Value}");
 			}
-			o = JObject.FromObject(new
-			{
-				result = var_list
-			});
-
 			SendResponse(msg_id, Result.Ok(o), token);
 		}
 
@@ -481,41 +492,51 @@ namespace WsProxy {
 				return;
 			}
 
-			var values = res.Value? ["result"]? ["value"]?.Values<JObject> ().ToArray ();
-
-			var var_list = new List<JObject> ();
-			int i = 0;
-			// Trying to inspect the stack frame for DotNetDispatcher::InvokeSynchronously
-			// results in a "Memory access out of bounds", causing 'values' to be null,
-			// so skip returning variable values in that case.
-			while (values != null && i < vars.Length && i < values.Length) {
-				var value = values [i] ["value"];
-				if (((string)value ["description"]) == null)
-					value ["description"] = value ["value"]?.ToString();
-
-				var_list.Add (JObject.FromObject (new {
-					name = vars [i].Name,
-					value = values [i] ["value"]
-				}));
-				i++;
+			try {
+				var values = res.Value? ["result"]? ["value"]?.Values<JObject> ().ToArray ();
+
+				var var_list = new List<JObject> ();
+				int i = 0;
+				// Trying to inspect the stack frame for DotNetDispatcher::InvokeSynchronously
+				// results in a "Memory access out of bounds", causing 'values' to be null,
+				// so skip returning variable values in that case.
+				while (values != null && i < vars.Length && i < values.Length) {
+					var value = values [i] ["value"];
+					if (((string)value ["description"]) == null)
+						value ["description"] = value ["value"]?.ToString ();
+
+					var_list.Add (JObject.FromObject (new {
+						name = vars [i].Name,
+						value
+					}));
+					i++;
+				}
+				//Async methods are special in the way that local variables can be lifted to generated class fields
+				//value of "this" comes here either
+				while (i < values.Length) {
+					String name = values [i] ["name"].ToString ();
+
+					if (name.IndexOf (">", StringComparison.Ordinal) > 0)
+						name = name.Substring (1, name.IndexOf (">", StringComparison.Ordinal) - 1);
+
+					var value = values [i + 1] ["value"];
+					if (((string)value ["description"]) == null)
+						value ["description"] = value ["value"]?.ToString ();
+
+					var_list.Add (JObject.FromObject (new {
+						name,
+						value
+					}));
+					i = i + 2;
+				}
+				o = JObject.FromObject (new {
+					result = var_list
+				});
+				SendResponse (msg_id, Result.Ok (o), token);
 			}
-			//Async methods are special in the way that local variables can be lifted to generated class fields
-			//value of "this" comes here either
-			while (i < values.Length) {
-				String name = values [i] ["name"].ToString ();
-
-				if (name.IndexOf (">", StringComparison.Ordinal) > 0)
-					name = name.Substring (1, name.IndexOf (">", StringComparison.Ordinal) - 1);
-				var_list.Add (JObject.FromObject (new {
-					name =  name,
-					value = values [i+1] ["value"]
-				}));
-				i = i + 2;
+			catch (Exception) {
+				SendResponse (msg_id, res, token);
 			}
-			o = JObject.FromObject (new {
-				result = var_list
-			});
-			SendResponse (msg_id, Result.Ok (o), token);
 		}
 
 		async Task<Result> EnableBreakPoint (Breakpoint bp, CancellationToken token)
diff --git a/src/Components/Blazor/Server/src/MonoDebugProxy/ws-proxy/WsProxy.cs b/src/Components/Blazor/Server/src/MonoDebugProxy/ws-proxy/WsProxy.cs
index 17c72e9ce781ad6fed94d9d1358fcb76c1715a3c..87ef23027e8a0fd86535aacf16bfd5708b696321 100644
--- a/src/Components/Blazor/Server/src/MonoDebugProxy/ws-proxy/WsProxy.cs
+++ b/src/Components/Blazor/Server/src/MonoDebugProxy/ws-proxy/WsProxy.cs
@@ -97,6 +97,7 @@ namespace WsProxy {
 
 	internal class WsProxy {
 		TaskCompletionSource<bool> side_exception = new TaskCompletionSource<bool> ();
+		TaskCompletionSource<bool> client_initiated_close = new TaskCompletionSource<bool> ();
 		List<(int, TaskCompletionSource<Result>)> pending_cmds = new List<(int, TaskCompletionSource<Result>)> ();
 		ClientWebSocket browser;
 		WebSocket ide;
@@ -119,8 +120,16 @@ namespace WsProxy {
 			byte [] buff = new byte [4000];
 			var mem = new MemoryStream ();
 			while (true) {
+
+				if (socket.State != WebSocketState.Open) {
+					Console.WriteLine ($"WSProxy: Socket is no longer open.");
+					client_initiated_close.TrySetResult (true);
+					return null;
+				}
+
 				var result = await socket.ReceiveAsync (new ArraySegment<byte> (buff), token);
 				if (result.MessageType == WebSocketMessageType.Close) {
+					client_initiated_close.TrySetResult (true);
 					return null;
 				}
 
@@ -144,7 +153,7 @@ namespace WsProxy {
 
 		void Send (WebSocket to, JObject o, CancellationToken token)
 		{
-			var bytes = Encoding.UTF8.GetBytes (o.ToString ());
+			var bytes = Encoding.UTF8.GetBytes (o.ToString ());		
 
 			var queue = GetQueueForSocket (to);
 			var task = queue.Send (bytes, token);
@@ -199,9 +208,10 @@ namespace WsProxy {
 
 		void ProcessIdeMessage (string msg, CancellationToken token)
 		{
-			var res = JObject.Parse (msg);
-
-			pending_ops.Add (OnCommand (res ["id"].Value<int> (), res ["method"].Value<string> (), res ["params"] as JObject, token));
+			if (!string.IsNullOrEmpty (msg)) {
+				var res = JObject.Parse (msg);
+				pending_ops.Add (OnCommand (res ["id"].Value<int> (), res ["method"].Value<string> (), res ["params"] as JObject, token));
+			}
 		}
 
 		internal async Task<Result> SendCommand (string method, JObject args, CancellationToken token) {
@@ -255,24 +265,25 @@ namespace WsProxy {
 			Send (this.ide, o, token);
 		}
 
-		 // , HttpContext context)
+		// , HttpContext context)
 		public async Task Run (Uri browserUri, WebSocket ideSocket)
 		{
-			Debug ("wsproxy start");
+			Debug ($"WsProxy Starting on {browserUri}");
 			using (this.ide = ideSocket) {
-				Debug ("ide connected");
+				Debug ($"WsProxy: IDE waiting for connection on {browserUri}");
 				queues.Add (new WsQueue (this.ide));
 				using (this.browser = new ClientWebSocket ()) {
 					this.browser.Options.KeepAliveInterval = Timeout.InfiniteTimeSpan;
 					await this.browser.ConnectAsync (browserUri, CancellationToken.None);
 					queues.Add (new WsQueue (this.browser));
 
-					Debug ("client connected");
+					Debug ($"WsProxy: Client connected on {browserUri}");
 					var x = new CancellationTokenSource ();
 
 					pending_ops.Add (ReadOne (browser, x.Token));
 					pending_ops.Add (ReadOne (ide, x.Token));
 					pending_ops.Add (side_exception.Task);
+					pending_ops.Add (client_initiated_close.Task);
 
 					try {
 						while (!x.IsCancellationRequested) {
@@ -280,15 +291,23 @@ namespace WsProxy {
 							//Console.WriteLine ("pump {0} {1}", task, pending_ops.IndexOf (task));
 							if (task == pending_ops [0]) {
 								var msg = ((Task<string>)task).Result;
-								pending_ops [0] = ReadOne (browser, x.Token); //queue next read
-								ProcessBrowserMessage (msg, x.Token);
+								if (msg != null) {
+									pending_ops [0] = ReadOne (browser, x.Token); //queue next read
+									ProcessBrowserMessage (msg, x.Token);
+								}
 							} else if (task == pending_ops [1]) {
 								var msg = ((Task<string>)task).Result;
-								pending_ops [1] = ReadOne (ide, x.Token); //queue next read
-								ProcessIdeMessage (msg, x.Token);
+								if (msg != null) {
+									pending_ops [1] = ReadOne (ide, x.Token); //queue next read
+									ProcessIdeMessage (msg, x.Token);
+								}
 							} else if (task == pending_ops [2]) {
 								var res = ((Task<bool>)task).Result;
 								throw new Exception ("side task must always complete with an exception, what's going on???");
+							} else if (task == pending_ops [3]) {
+								var res = ((Task<bool>)task).Result;
+								Debug ($"WsProxy: Client initiated close from {browserUri}");
+								x.Cancel ();
 							} else {
 								//must be a background task
 								pending_ops.Remove (task);
@@ -301,10 +320,11 @@ namespace WsProxy {
 							}
 						}
 					} catch (Exception e) {
-						Debug ($"got exception {e}");
+						Debug ($"WsProxy::Run: Exception {e}");
 						//throw;
 					} finally {
-						x.Cancel ();
+						if (!x.IsCancellationRequested)
+							x.Cancel ();
 					}
 				}
 			}
diff --git a/src/Components/Blazor/Templates/src/Directory.Build.props b/src/Components/Blazor/Templates/src/Directory.Build.props
deleted file mode 100644
index ed5e01501483ab6422842c594d247ce15eb90965..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Templates/src/Directory.Build.props
+++ /dev/null
@@ -1,15 +0,0 @@
-<Project>
-  <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory)..\, Directory.Build.props))\Directory.Build.props" />
-
-  <PropertyGroup>
-    <GenerateDocumentationFile>false</GenerateDocumentationFile>
-    <IncludeSymbols>false</IncludeSymbols>
-  </PropertyGroup>
-
-  <PropertyGroup Label="Package Versions">
-    <!-- Used only in development when running the template contents directly from source -->
-    <TemplateBlazorPackageVersion>0.8.0-preview-19064-0339</TemplateBlazorPackageVersion>
-    <TemplateComponentsPackageVersion>3.0.0-preview-19064-0339</TemplateComponentsPackageVersion>
-  </PropertyGroup>
-
-</Project>
diff --git a/src/Components/Blazor/Templates/src/Directory.Build.targets b/src/Components/Blazor/Templates/src/Directory.Build.targets
deleted file mode 100644
index 7c6f423adde20cd91e58b2db52c23a1e012f02e8..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Templates/src/Directory.Build.targets
+++ /dev/null
@@ -1,17 +0,0 @@
-<Project>
-  <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory)..\, Directory.Build.targets))\Directory.Build.targets" />
-
-  <Target Name="SetTemplateJsonSymbolReplacements">
-    <PropertyGroup>
-      <!--
-        Properties here will be injected into the template config *.json files
-        during the build, replacing tokens of the form ${PropertyName}
-      -->
-      <GeneratedContentProperties>
-        TemplateBlazorVersion=$(PackageVersion);
-        TemplateComponentsVersion=$(ComponentsPackageVersion);
-        RepositoryCommit=$(SourceRevisionId);
-      </GeneratedContentProperties>
-    </PropertyGroup>
-  </Target>
-</Project>
diff --git a/src/Components/Blazor/Templates/src/Microsoft.AspNetCore.Blazor.Templates.nuspec b/src/Components/Blazor/Templates/src/Microsoft.AspNetCore.Blazor.Templates.nuspec
deleted file mode 100644
index fd19750231093eeaae7d2b163a0af603ce262a6f..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Templates/src/Microsoft.AspNetCore.Blazor.Templates.nuspec
+++ /dev/null
@@ -1,16 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<package xmlns="http://schemas.microsoft.com/packaging/2010/07/nuspec.xsd">
-  <metadata>
-    $CommonMetadataElements$
-    <packageTypes>
-      <packageType name="Template" />
-    </packageTypes>
-  </metadata>
-  <files>
-    $CommonFileElements$
-    <file
-      src="content/**"
-      exclude="**/bin/**;**/obj/**;**/.template.config.src/**;content/Directory.Build.props;content/Directory.Build.targets;"
-      target="Content" />
-  </files>
-</package>
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Program.cs b/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Program.cs
deleted file mode 100644
index 03d510452d1a2ad3b5cc4772dad2241483ab0795..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Program.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using Microsoft.AspNetCore.Blazor.Hosting;
-
-#if (Hosted)
-namespace BlazorWasm_CSharp.Client
-#else
-namespace BlazorWasm_CSharp
-#endif
-{
-    public class Program
-    {
-        public static void Main(string[] args)
-        {
-            CreateHostBuilder(args).Build().Run();
-        }
-
-        public static IWebAssemblyHostBuilder CreateHostBuilder(string[] args) =>
-            BlazorWebAssemblyHost.CreateDefaultBuilder()
-                .UseBlazorStartup<Startup>();
-    }
-}
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Startup.cs b/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Startup.cs
deleted file mode 100644
index b30f14ae0609472cb793aef95ec2c53671d7a42d..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Startup.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using Microsoft.AspNetCore.Components.Builder;
-using Microsoft.Extensions.DependencyInjection;
-
-#if (Hosted)
-namespace BlazorWasm_CSharp.Client
-#else
-namespace BlazorWasm_CSharp
-#endif
-{
-    public class Startup
-    {
-        public void ConfigureServices(IServiceCollection services)
-        {
-        }
-
-        public void Configure(IComponentsApplicationBuilder app)
-        {
-            app.AddComponent<App>("app");
-        }
-    }
-}
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Shared/BlazorWasm-CSharp.Shared.csproj b/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Shared/BlazorWasm-CSharp.Shared.csproj
deleted file mode 100644
index 2a77f0c7cc913fe3d7db799a31fdcc558364e189..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Shared/BlazorWasm-CSharp.Shared.csproj
+++ /dev/null
@@ -1,8 +0,0 @@
-<Project Sdk="Microsoft.NET.Sdk">
-
-  <PropertyGroup>
-    <TargetFramework>netstandard2.0</TargetFramework>
-    <LangVersion>7.3</LangVersion>
-  </PropertyGroup>
-
-</Project>
diff --git a/src/Components/Blazor/Validation/src/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj b/src/Components/Blazor/Validation/src/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj
index 89023db6b921429b89ca10b9100512a3bbc6d0ab..53cc678edbb0b169038dcc48ad88fb3e42268d4c 100644
--- a/src/Components/Blazor/Validation/src/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj
+++ b/src/Components/Blazor/Validation/src/Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk">
 
   <PropertyGroup>
-    <TargetFramework>netstandard2.0</TargetFramework>
+    <TargetFramework>netstandard2.1</TargetFramework>
     <Description>Provides experimental support for validation using DataAnnotations.</Description>
     <IsShippingPackage>false</IsShippingPackage>
     <HasReferenceAssembly>false</HasReferenceAssembly>
diff --git a/src/Components/Blazor/testassets/HostedInAspNet.Client/HostedInAspNet.Client.csproj b/src/Components/Blazor/testassets/HostedInAspNet.Client/HostedInAspNet.Client.csproj
index ef12ac3c4e83fe16802d187f5dc09e98c8d4b365..e27de695c1b33fc9a9072a112dae9f848ac76674 100644
--- a/src/Components/Blazor/testassets/HostedInAspNet.Client/HostedInAspNet.Client.csproj
+++ b/src/Components/Blazor/testassets/HostedInAspNet.Client/HostedInAspNet.Client.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk.Web">
 
   <PropertyGroup>
-    <TargetFramework>netstandard2.0</TargetFramework>
+    <TargetFramework>netstandard2.1</TargetFramework>
     <OutputType>Exe</OutputType>
     <ReferenceBlazorBuildLocally>true</ReferenceBlazorBuildLocally>
     <RazorLangVersion>3.0</RazorLangVersion>
@@ -11,17 +11,4 @@
     <Reference Include="Microsoft.AspNetCore.Blazor" />
   </ItemGroup>
 
-  <PropertyGroup>
-    <GetCurrentProjectStaticWebAssetsDependsOn>
-      $(GetCurrentProjectStaticWebAssetsDependsOn);
-      _ClearCurrentStaticWebAssetsForReferenceDiscovery
-    </GetCurrentProjectStaticWebAssetsDependsOn>
-  </PropertyGroup>
-
-  <Target Name="_ClearCurrentStaticWebAssetsForReferenceDiscovery">
-    <ItemGroup>
-      <StaticWebAsset Remove="@(StaticWebAsset)" Condition="'%(SourceType)' == ''" />
-    </ItemGroup>
-  </Target>
-
 </Project>
diff --git a/src/Components/Blazor/testassets/HostedInAspNet.Client/Program.cs b/src/Components/Blazor/testassets/HostedInAspNet.Client/Program.cs
index 69ee439533d05a015640487691fc7ac6d8b11508..e922c2996f75b3674f4fa49b26236bb749d00085 100644
--- a/src/Components/Blazor/testassets/HostedInAspNet.Client/Program.cs
+++ b/src/Components/Blazor/testassets/HostedInAspNet.Client/Program.cs
@@ -1,19 +1,19 @@
 // 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.Threading.Tasks;
 using Microsoft.AspNetCore.Blazor.Hosting;
 
 namespace HostedInAspNet.Client
 {
     public class Program
     {
-        public static void Main(string[] args)
+        public static async Task Main(string[] args)
         {
-            CreateHostBuilder(args).Build().Run();
-        }
+            var builder = WebAssemblyHostBuilder.CreateDefault(args);
+            builder.RootComponents.Add<Home>("app");
 
-        public static IWebAssemblyHostBuilder CreateHostBuilder(string[] args) =>
-            BlazorWebAssemblyHost.CreateDefaultBuilder()
-                .UseBlazorStartup<Startup>();
+            await builder.Build().RunAsync();
+        }
     }
 }
diff --git a/src/Components/Blazor/testassets/HostedInAspNet.Client/Startup.cs b/src/Components/Blazor/testassets/HostedInAspNet.Client/Startup.cs
deleted file mode 100644
index a06163b9e05b5de33a1ada36484bdb34d4c52d32..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/testassets/HostedInAspNet.Client/Startup.cs
+++ /dev/null
@@ -1,20 +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 Microsoft.AspNetCore.Components.Builder;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace HostedInAspNet.Client
-{
-    public class Startup
-    {
-        public void ConfigureServices(IServiceCollection services)
-        {
-        }
-
-        public void Configure(IComponentsApplicationBuilder app)
-        {
-            app.AddComponent<Home>("app");
-        }
-    }
-}
diff --git a/src/Components/Blazor/testassets/HostedInAspNet.Server/HostedInAspNet.Server.csproj b/src/Components/Blazor/testassets/HostedInAspNet.Server/HostedInAspNet.Server.csproj
index afc09e4f772e66a6bce206910cc59c6f6c978cef..483ea4e8f48805aa7f0b0c502bf328ec236d54a9 100644
--- a/src/Components/Blazor/testassets/HostedInAspNet.Server/HostedInAspNet.Server.csproj
+++ b/src/Components/Blazor/testassets/HostedInAspNet.Server/HostedInAspNet.Server.csproj
@@ -2,6 +2,9 @@
 
   <PropertyGroup>
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <!-- This is so that we add the FrameworkReference to Microsoft.AspNetCore.App -->
+    <UseLatestAspNetCoreReference>true</UseLatestAspNetCoreReference>
+
   </PropertyGroup>
 
   <ItemGroup>
@@ -10,8 +13,6 @@
 
   <ItemGroup>
     <Reference Include="Microsoft.AspNetCore.Blazor.Server" />
-    <Reference Include="Microsoft.AspNetCore" />
-    <Reference Include="Microsoft.Extensions.Hosting" />
     <!-- Avoid MSB3277 warnings due to dependencies brought in through Microsoft.AspNetCore.Blazor targeting netstandard2.0. -->
     <Reference Include="System.Text.Json" />
   </ItemGroup>
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Program.cs b/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Program.cs
deleted file mode 100644
index f498eb0222b916260e8cfd2af7390373e4d8462b..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Program.cs
+++ /dev/null
@@ -1,19 +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 Microsoft.AspNetCore.Blazor.Hosting;
-
-namespace Microsoft.AspNetCore.Blazor.E2EPerformance
-{
-    public class Program
-    {
-        public static void Main(string[] args)
-        {
-            CreateHostBuilder(args).Build().Run();
-        }
-
-        public static IWebAssemblyHostBuilder CreateHostBuilder(string[] args) =>
-            BlazorWebAssemblyHost.CreateDefaultBuilder()
-                .UseBlazorStartup<Startup>();
-    }
-}
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Startup.cs b/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Startup.cs
deleted file mode 100644
index 7422cd806cffb582ddf4d428515e471264bb7afd..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Startup.cs
+++ /dev/null
@@ -1,20 +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 Microsoft.AspNetCore.Components.Builder;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Microsoft.AspNetCore.Blazor.E2EPerformance
-{
-    public class Startup
-    {
-        public void ConfigureServices(IServiceCollection services)
-        {
-        }
-
-        public void Configure(IComponentsApplicationBuilder app)
-        {
-            app.AddComponent<App>("app");
-        }
-    }
-}
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/index.js b/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/index.js
deleted file mode 100644
index 4600066f3872bb3b877fad246f14c36f3538264b..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/index.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import { HtmlUI } from './lib/minibench/minibench.js';
-import './appStartup.js';
-import './renderList.js';
-import './jsonHandling.js';
-
-new HtmlUI('E2E Performance', '#display');
diff --git a/src/Components/Blazor/testassets/MonoSanity/MonoSanity.csproj b/src/Components/Blazor/testassets/MonoSanity/MonoSanity.csproj
index 5297f5bca69a63fb48fceedddb30d1845f3b0db5..464f63b57c23de94a501aaf7e5840ee186fe8259 100644
--- a/src/Components/Blazor/testassets/MonoSanity/MonoSanity.csproj
+++ b/src/Components/Blazor/testassets/MonoSanity/MonoSanity.csproj
@@ -2,6 +2,8 @@
 
   <PropertyGroup>
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <!-- This is so that we add the FrameworkReference to Microsoft.AspNetCore.App -->
+    <UseLatestAspNetCoreReference>true</UseLatestAspNetCoreReference>
   </PropertyGroup>
 
   <ItemGroup>
@@ -9,9 +11,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <Reference Include="Microsoft.AspNetCore" />
     <Reference Include="Microsoft.AspNetCore.Blazor.Server" />
-    <Reference Include="Microsoft.Extensions.Hosting" />
   </ItemGroup>
 
 </Project>
diff --git a/src/Components/Blazor/testassets/MonoSanity/wwwroot/index.html b/src/Components/Blazor/testassets/MonoSanity/wwwroot/index.html
index c4d1ab60e25cc29323d5055604737a8142f7268d..8a42e8e5d1c41519bcd5cad0a21de9ddba9207e0 100644
--- a/src/Components/Blazor/testassets/MonoSanity/wwwroot/index.html
+++ b/src/Components/Blazor/testassets/MonoSanity/wwwroot/index.html
@@ -36,14 +36,6 @@
     </form>
   </fieldset>
 
-  <fieldset>
-    <legend>Invoke wiped method</legend>
-    <form id="invokeWipedMethod">
-      <button type="submit" disabled>Go</button>
-      <div><textarea rows="5" cols="80" readonly id="invokeWipedMethodStackTrace"></textarea></div>
-    </form>
-  </fieldset>
-
   <fieldset>
     <legend>Call JS from .NET</legend>
     <form id="callJs">
@@ -111,16 +103,6 @@
       }
     };
 
-    el('invokeWipedMethod').onsubmit = function (evt) {
-      evt.preventDefault();
-      try {
-        invokeMonoMethod('MonoSanityClient', 'MonoSanityClient', 'Examples', 'InvokeWipedMethod', []);
-        el('invokeWipedMethodStackTrace').value = 'WARNING: No exception occurred';
-      } catch (ex) {
-        el('invokeWipedMethodStackTrace').value = ex.toString();
-      }
-    };
-
     el('callJs').onsubmit = function (evt) {
       evt.preventDefault();
       var expression = el('callJsEvalExpression').value;
diff --git a/src/Components/Blazor/testassets/MonoSanity/wwwroot/loader.js b/src/Components/Blazor/testassets/MonoSanity/wwwroot/loader.js
index 48d4530d3e63bf0ddc7562558ff1e67f5012bc32..328acacdff975cad47c7c2891fbb002a39117872 100644
--- a/src/Components/Blazor/testassets/MonoSanity/wwwroot/loader.js
+++ b/src/Components/Blazor/testassets/MonoSanity/wwwroot/loader.js
@@ -12,7 +12,7 @@
   window.initMono = function initMono(loadAssemblyUrls, onReadyCallback) {
     window.Module = {
       locateFile: function (fileName) {
-        return fileName === 'mono.wasm' ? '/_framework/wasm/mono.wasm' : fileName;
+        return fileName === 'dotnet.wasm' ? '/_framework/wasm/dotnet.wasm' : fileName;
       },
       onRuntimeInitialized: function () {
         var allAssemblyUrls = loadAssemblyUrls.concat([
@@ -117,7 +117,7 @@
     }
 
     var scriptElem = document.createElement('script');
-    scriptElem.src = '/_framework/wasm/mono.js';
+    scriptElem.src = '/_framework/wasm/dotnet.js';
     document.body.appendChild(scriptElem);
   }
 
diff --git a/src/Components/Blazor/testassets/MonoSanityClient/Examples.cs b/src/Components/Blazor/testassets/MonoSanityClient/Examples.cs
index 8023ded4d9d30b3bb6006ed20d4215c132e81e9c..1d56128e354687a32b201385a34a1a076019786f 100644
--- a/src/Components/Blazor/testassets/MonoSanityClient/Examples.cs
+++ b/src/Components/Blazor/testassets/MonoSanityClient/Examples.cs
@@ -31,11 +31,6 @@ namespace MonoSanityClient
             throw new InvalidOperationException(message);
         }
 
-        public static void InvokeWipedMethod()
-        {
-            new HttpClientHandler().Dispose();
-        }
-
         public static string EvaluateJavaScript(string expression)
         {
             var result = InternalCalls.InvokeJSUnmarshalled<string, string, object, object>(out var exceptionMessage, "evaluateJsExpression", expression, null, null);
diff --git a/src/Components/Blazor/testassets/MonoSanityClient/MonoSanityClient.csproj b/src/Components/Blazor/testassets/MonoSanityClient/MonoSanityClient.csproj
index b186c391941bdef3ca602dcfcaaafd8773c1c7e5..e01c60084327118c093e6374f6d898e16f7297b3 100644
--- a/src/Components/Blazor/testassets/MonoSanityClient/MonoSanityClient.csproj
+++ b/src/Components/Blazor/testassets/MonoSanityClient/MonoSanityClient.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk.Razor" TreatAsLocalProperty="BlazorLinkOnBuild">
 
   <PropertyGroup>
-    <TargetFramework>netstandard2.0</TargetFramework>
+    <TargetFramework>netstandard2.1</TargetFramework>
     <IsPackable>false</IsPackable>
     <BlazorLinkOnBuild>false</BlazorLinkOnBuild>
     <OutputType>exe</OutputType>
diff --git a/src/Components/Blazor/testassets/StandaloneApp/Program.cs b/src/Components/Blazor/testassets/StandaloneApp/Program.cs
index 530de72870dc0c7d79a327dc996f072bb27bce02..8da14834b65956cbfdd433e539f3f16add78f4fb 100644
--- a/src/Components/Blazor/testassets/StandaloneApp/Program.cs
+++ b/src/Components/Blazor/testassets/StandaloneApp/Program.cs
@@ -1,19 +1,19 @@
 // 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.Threading.Tasks;
 using Microsoft.AspNetCore.Blazor.Hosting;
 
 namespace StandaloneApp
 {
     public class Program
     {
-        public static void Main(string[] args)
+        public static async Task Main(string[] args)
         {
-            CreateHostBuilder(args).Build().Run();
-        }
+            var builder = WebAssemblyHostBuilder.CreateDefault(args);
+            builder.RootComponents.Add<App>("app");
 
-        public static IWebAssemblyHostBuilder CreateHostBuilder(string[] args) =>
-            BlazorWebAssemblyHost.CreateDefaultBuilder()
-                .UseBlazorStartup<Startup>();
+            await builder.Build().RunAsync();
+        }
     }
 }
diff --git a/src/Components/Blazor/testassets/StandaloneApp/StandaloneApp.csproj b/src/Components/Blazor/testassets/StandaloneApp/StandaloneApp.csproj
index cddd429b6a40acb218995c509712f6ef10372257..32156c56b85475920dbc7be86f1132726412f90c 100644
--- a/src/Components/Blazor/testassets/StandaloneApp/StandaloneApp.csproj
+++ b/src/Components/Blazor/testassets/StandaloneApp/StandaloneApp.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk.Web">
 
   <PropertyGroup>
-    <TargetFramework>netstandard2.0</TargetFramework>
+    <TargetFramework>netstandard2.1</TargetFramework>
     <ReferenceBlazorBuildLocally>true</ReferenceBlazorBuildLocally>
     <RazorLangVersion>3.0</RazorLangVersion>
   </PropertyGroup>
diff --git a/src/Components/Blazor/testassets/StandaloneApp/Startup.cs b/src/Components/Blazor/testassets/StandaloneApp/Startup.cs
deleted file mode 100644
index 79564e8d9d6c998659e328e2df8ad98bbc3ee5b7..0000000000000000000000000000000000000000
--- a/src/Components/Blazor/testassets/StandaloneApp/Startup.cs
+++ /dev/null
@@ -1,20 +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 Microsoft.AspNetCore.Components.Builder;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace StandaloneApp
-{
-    public class Startup
-    {
-        public void ConfigureServices(IServiceCollection services)
-        {
-        }
-
-        public void Configure(IComponentsApplicationBuilder app)
-        {
-            app.AddComponent<App>("app");
-        }
-    }
-}
diff --git a/src/Components/Blazor/testassets/StandaloneApp/wwwroot/index.html b/src/Components/Blazor/testassets/StandaloneApp/wwwroot/index.html
index 5da6ba26b31b04e165af89b3ab9f195cd4fbbd72..646ea2f3da09cbc604445ddf8ff27bba71fa8469 100644
--- a/src/Components/Blazor/testassets/StandaloneApp/wwwroot/index.html
+++ b/src/Components/Blazor/testassets/StandaloneApp/wwwroot/index.html
@@ -1,8 +1,9 @@
-<!DOCTYPE html>
+<!DOCTYPE html>
 <html>
 <head>
     <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width">
+    <!-- Forcing the device width here so that our automated tests work consistently on mobile browsers. -->
+    <meta name="viewport" content="width=1024">
     <title>Blazor standalone</title>
     <base href="/" />
     <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
@@ -11,6 +12,12 @@
 <body>
     <app>Loading...</app>
 
+    <div id="blazor-error-ui">
+        An unhandled exception has occurred. See browser dev tools for details.
+        <a href="" class="reload">Reload</a>
+        <a class="dismiss">🗙</a>
+    </div>
+
     <script src="_framework/blazor.webassembly.js"></script>
 </body>
 </html>
diff --git a/src/Components/Components.sln b/src/Components/Components.sln
index ba0b2476ffb317082c63b1e3b4a9532931059a0d..c88695cf66e314ee6e03637f3c6b55d642a8004a 100644
--- a/src/Components/Components.sln
+++ b/src/Components/Components.sln
@@ -21,12 +21,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Blazor
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Blazor.DevServer", "Blazor\DevServer\src\Microsoft.AspNetCore.Blazor.DevServer.csproj", "{A6C8050D-7C18-4585-ADCF-833AC1765847}"
 EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Blazor.E2EPerformance", "Blazor\testassets\Microsoft.AspNetCore.Blazor.E2EPerformance\Microsoft.AspNetCore.Blazor.E2EPerformance.csproj", "{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}"
-EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Blazor.Server", "Blazor\Server\src\Microsoft.AspNetCore.Blazor.Server.csproj", "{A4859630-F9F7-4F5C-9FF3-6C013D7C58FA}"
 EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Blazor.Templates", "Blazor\Templates\src\Microsoft.AspNetCore.Blazor.Templates.csproj", "{66036B70-6C93-4E45-A1A1-819F15CA757A}"
-EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "testassets", "testassets", "{A7ABAC29-F73F-456D-AE54-46842CFC2E10}"
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HostedInAspNet.Client", "Blazor\testassets\HostedInAspNet.Client\HostedInAspNet.Client.csproj", "{FD37F740-A654-4117-BFB6-9112CE4C1D3B}"
@@ -240,12 +236,24 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ignitor", "Ignitor\src\Igni
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ignitor.Test", "Ignitor\test\Ignitor.Test.csproj", "{F31E8118-014E-4CCE-8A48-5282F7B9BB3E}"
 EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Validation", "Validation", "{FD9BD646-9D50-42ED-A3E1-90558BA0C6B2}"
-EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Blazor.DataAnnotations.Validation", "Blazor\Validation\src\Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj", "{B70F90C7-2696-4050-B24E-BF0308F4E059}"
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests", "Blazor\Validation\test\Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests.csproj", "{A5617A9D-C71E-44DE-936C-27611EB40A02}"
 EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Mono.WebAssembly.Interop", "Mono.WebAssembly.Interop", "{21BB9C13-20C1-4F2B-80E4-D7C64AA3BD05}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Mono.WebAssembly.Interop", "Blazor\Mono.WebAssembly.Interop\src\Mono.WebAssembly.Interop.csproj", "{D141CFEE-D10A-406B-8963-F86FA13732E3}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ComponentsApp.Server", "test\testassets\ComponentsApp.Server\ComponentsApp.Server.csproj", "{F2E27E1C-2E47-42C1-9AC7-36265A381717}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarkapps", "benchmarkapps", "{CCC82E97-7B58-43E2-BBBD-23D82F926367}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Wasm.Performance", "Wasm.Performance", "{F65EFF0F-ACF3-46BD-9A8F-CDA94AF1885A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wasm.Performance.Driver", "benchmarkapps\Wasm.Performance\Driver\Wasm.Performance.Driver.csproj", "{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Wasm.Performance.TestApp", "benchmarkapps\Wasm.Performance\TestApp\Wasm.Performance.TestApp.csproj", "{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -340,18 +348,6 @@ Global
 		{A6C8050D-7C18-4585-ADCF-833AC1765847}.Release|x64.Build.0 = Release|Any CPU
 		{A6C8050D-7C18-4585-ADCF-833AC1765847}.Release|x86.ActiveCfg = Release|Any CPU
 		{A6C8050D-7C18-4585-ADCF-833AC1765847}.Release|x86.Build.0 = Release|Any CPU
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}.Debug|x64.ActiveCfg = Debug|Any CPU
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}.Debug|x64.Build.0 = Debug|Any CPU
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}.Debug|x86.ActiveCfg = Debug|Any CPU
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}.Debug|x86.Build.0 = Debug|Any CPU
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}.Release|Any CPU.Build.0 = Release|Any CPU
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}.Release|x64.ActiveCfg = Release|Any CPU
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}.Release|x64.Build.0 = Release|Any CPU
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}.Release|x86.ActiveCfg = Release|Any CPU
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB}.Release|x86.Build.0 = Release|Any CPU
 		{A4859630-F9F7-4F5C-9FF3-6C013D7C58FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{A4859630-F9F7-4F5C-9FF3-6C013D7C58FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{A4859630-F9F7-4F5C-9FF3-6C013D7C58FA}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -364,18 +360,6 @@ Global
 		{A4859630-F9F7-4F5C-9FF3-6C013D7C58FA}.Release|x64.Build.0 = Release|Any CPU
 		{A4859630-F9F7-4F5C-9FF3-6C013D7C58FA}.Release|x86.ActiveCfg = Release|Any CPU
 		{A4859630-F9F7-4F5C-9FF3-6C013D7C58FA}.Release|x86.Build.0 = Release|Any CPU
-		{66036B70-6C93-4E45-A1A1-819F15CA757A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
-		{66036B70-6C93-4E45-A1A1-819F15CA757A}.Debug|Any CPU.Build.0 = Debug|Any CPU
-		{66036B70-6C93-4E45-A1A1-819F15CA757A}.Debug|x64.ActiveCfg = Debug|Any CPU
-		{66036B70-6C93-4E45-A1A1-819F15CA757A}.Debug|x64.Build.0 = Debug|Any CPU
-		{66036B70-6C93-4E45-A1A1-819F15CA757A}.Debug|x86.ActiveCfg = Debug|Any CPU
-		{66036B70-6C93-4E45-A1A1-819F15CA757A}.Debug|x86.Build.0 = Debug|Any CPU
-		{66036B70-6C93-4E45-A1A1-819F15CA757A}.Release|Any CPU.ActiveCfg = Release|Any CPU
-		{66036B70-6C93-4E45-A1A1-819F15CA757A}.Release|Any CPU.Build.0 = Release|Any CPU
-		{66036B70-6C93-4E45-A1A1-819F15CA757A}.Release|x64.ActiveCfg = Release|Any CPU
-		{66036B70-6C93-4E45-A1A1-819F15CA757A}.Release|x64.Build.0 = Release|Any CPU
-		{66036B70-6C93-4E45-A1A1-819F15CA757A}.Release|x86.ActiveCfg = Release|Any CPU
-		{66036B70-6C93-4E45-A1A1-819F15CA757A}.Release|x86.Build.0 = Release|Any CPU
 		{FD37F740-A654-4117-BFB6-9112CE4C1D3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
 		{FD37F740-A654-4117-BFB6-9112CE4C1D3B}.Debug|Any CPU.Build.0 = Debug|Any CPU
 		{FD37F740-A654-4117-BFB6-9112CE4C1D3B}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -1516,6 +1500,54 @@ Global
 		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Release|x64.Build.0 = Release|Any CPU
 		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Release|x86.ActiveCfg = Release|Any CPU
 		{A5617A9D-C71E-44DE-936C-27611EB40A02}.Release|x86.Build.0 = Release|Any CPU
+		{D141CFEE-D10A-406B-8963-F86FA13732E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D141CFEE-D10A-406B-8963-F86FA13732E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{D141CFEE-D10A-406B-8963-F86FA13732E3}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{D141CFEE-D10A-406B-8963-F86FA13732E3}.Debug|x64.Build.0 = Debug|Any CPU
+		{D141CFEE-D10A-406B-8963-F86FA13732E3}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{D141CFEE-D10A-406B-8963-F86FA13732E3}.Debug|x86.Build.0 = Debug|Any CPU
+		{D141CFEE-D10A-406B-8963-F86FA13732E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{D141CFEE-D10A-406B-8963-F86FA13732E3}.Release|Any CPU.Build.0 = Release|Any CPU
+		{D141CFEE-D10A-406B-8963-F86FA13732E3}.Release|x64.ActiveCfg = Release|Any CPU
+		{D141CFEE-D10A-406B-8963-F86FA13732E3}.Release|x64.Build.0 = Release|Any CPU
+		{D141CFEE-D10A-406B-8963-F86FA13732E3}.Release|x86.ActiveCfg = Release|Any CPU
+		{D141CFEE-D10A-406B-8963-F86FA13732E3}.Release|x86.Build.0 = Release|Any CPU
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|x64.Build.0 = Debug|Any CPU
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Debug|x86.Build.0 = Debug|Any CPU
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|Any CPU.Build.0 = Release|Any CPU
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|x64.ActiveCfg = Release|Any CPU
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|x64.Build.0 = Release|Any CPU
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|x86.ActiveCfg = Release|Any CPU
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717}.Release|x86.Build.0 = Release|Any CPU
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}.Debug|x64.Build.0 = Debug|Any CPU
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}.Debug|x86.Build.0 = Debug|Any CPU
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}.Release|Any CPU.Build.0 = Release|Any CPU
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}.Release|x64.ActiveCfg = Release|Any CPU
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}.Release|x64.Build.0 = Release|Any CPU
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}.Release|x86.ActiveCfg = Release|Any CPU
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457}.Release|x86.Build.0 = Release|Any CPU
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}.Debug|x64.Build.0 = Debug|Any CPU
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}.Debug|x86.Build.0 = Debug|Any CPU
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}.Release|Any CPU.Build.0 = Release|Any CPU
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}.Release|x64.ActiveCfg = Release|Any CPU
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}.Release|x64.Build.0 = Release|Any CPU
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}.Release|x86.ActiveCfg = Release|Any CPU
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -1528,9 +1560,7 @@ Global
 		{E8AD67A4-77D3-4B85-AE19-4711388B62B1} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
 		{E38FDBB0-08C1-444E-A449-69C8A59D721B} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
 		{A6C8050D-7C18-4585-ADCF-833AC1765847} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
-		{08773DD6-6FED-4BF2-BD9F-C19D2CF919BB} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
 		{A4859630-F9F7-4F5C-9FF3-6C013D7C58FA} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
-		{66036B70-6C93-4E45-A1A1-819F15CA757A} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
 		{A7ABAC29-F73F-456D-AE54-46842CFC2E10} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
 		{FD37F740-A654-4117-BFB6-9112CE4C1D3B} = {A7ABAC29-F73F-456D-AE54-46842CFC2E10}
 		{C1E2C117-BE47-4E29-94B3-753262D97A5C} = {A7ABAC29-F73F-456D-AE54-46842CFC2E10}
@@ -1626,9 +1656,14 @@ Global
 		{BBF37AF9-8290-4B70-8BA8-0F6017B3B620} = {46E4300C-5726-4108-B9A2-18BB94EB26ED}
 		{CD0EF85C-4187-4515-A355-E5A0D4485F40} = {BDE2397D-C53A-4783-8B3A-1F54F48A6926}
 		{F31E8118-014E-4CCE-8A48-5282F7B9BB3E} = {BDE2397D-C53A-4783-8B3A-1F54F48A6926}
-		{FD9BD646-9D50-42ED-A3E1-90558BA0C6B2} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
 		{B70F90C7-2696-4050-B24E-BF0308F4E059} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
 		{A5617A9D-C71E-44DE-936C-27611EB40A02} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
+		{21BB9C13-20C1-4F2B-80E4-D7C64AA3BD05} = {7260DED9-22A9-4E9D-92F4-5E8A4404DEAF}
+		{D141CFEE-D10A-406B-8963-F86FA13732E3} = {21BB9C13-20C1-4F2B-80E4-D7C64AA3BD05}
+		{F2E27E1C-2E47-42C1-9AC7-36265A381717} = {44E0D4F3-4430-4175-B482-0D1AEE4BB699}
+		{F65EFF0F-ACF3-46BD-9A8F-CDA94AF1885A} = {CCC82E97-7B58-43E2-BBBD-23D82F926367}
+		{CA9948CA-B3FA-4C2E-A726-5E47BAD19457} = {F65EFF0F-ACF3-46BD-9A8F-CDA94AF1885A}
+		{97EA0A7D-FE5E-47D1-ADDC-4BFD702F55AB} = {F65EFF0F-ACF3-46BD-9A8F-CDA94AF1885A}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {CC3C47E1-AD1A-4619-9CD3-E08A0148E5CE}
diff --git a/src/Components/ComponentsNoDeps.slnf b/src/Components/ComponentsNoDeps.slnf
index 09f6a0859f95f5c3d17f465d61a4177fb008d303..7e09eeea25ce2b1d9244ceae73a642223b192f83 100644
--- a/src/Components/ComponentsNoDeps.slnf
+++ b/src/Components/ComponentsNoDeps.slnf
@@ -13,13 +13,12 @@
       "Blazor\\DevServer\\src\\Microsoft.AspNetCore.Blazor.DevServer.csproj",
       "Blazor\\Http\\src\\Microsoft.AspNetCore.Blazor.HttpClient.csproj",
       "Blazor\\Http\\test\\Microsoft.AspNetCore.Blazor.HttpClient.Tests.csproj",
+      "Blazor\\Mono.WebAssembly.Interop\\src\\Mono.WebAssembly.Interop.csproj",
       "Blazor\\Server\\src\\Microsoft.AspNetCore.Blazor.Server.csproj",
-      "Blazor\\Templates\\src\\Microsoft.AspNetCore.Blazor.Templates.csproj",
       "Blazor\\Validation\\src\\Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.csproj",
       "Blazor\\Validation\\test\\Microsoft.AspNetCore.Blazor.DataAnnotations.Validation.Tests.csproj",
       "Blazor\\testassets\\HostedInAspNet.Client\\HostedInAspNet.Client.csproj",
       "Blazor\\testassets\\HostedInAspNet.Server\\HostedInAspNet.Server.csproj",
-      "Blazor\\testassets\\Microsoft.AspNetCore.Blazor.E2EPerformance\\Microsoft.AspNetCore.Blazor.E2EPerformance.csproj",
       "Blazor\\testassets\\MonoSanityClient\\MonoSanityClient.csproj",
       "Blazor\\testassets\\MonoSanity\\MonoSanity.csproj",
       "Blazor\\testassets\\StandaloneApp\\StandaloneApp.csproj",
@@ -35,6 +34,8 @@
       "Server\\test\\Microsoft.AspNetCore.Components.Server.Tests.csproj",
       "Web\\src\\Microsoft.AspNetCore.Components.Web.csproj",
       "Web\\test\\Microsoft.AspNetCore.Components.Web.Tests.csproj",
+      "benchmarkapps\\Wasm.Performance\\Driver\\Wasm.Performance.Driver.csproj",
+      "benchmarkapps\\Wasm.Performance\\TestApp\\Wasm.Performance.TestApp.csproj",
       "test\\E2ETest\\Microsoft.AspNetCore.Components.E2ETests.csproj",
       "test\\testassets\\BasicTestApp\\BasicTestApp.csproj",
       "test\\testassets\\TestContentPackage\\TestContentPackage.csproj",
diff --git a/src/Components/Directory.Build.props b/src/Components/Directory.Build.props
index b017bb81ea54540d7aa86558623f6cfeba45461a..fb709cba972d4373a5b61f0b6581992f55a3a5d7 100644
--- a/src/Components/Directory.Build.props
+++ b/src/Components/Directory.Build.props
@@ -12,6 +12,10 @@
   <PropertyGroup>
     <PackageTags>aspnetcore;components</PackageTags>
 
+    <!-- This property points to the latest released Microsoft.AspNetCore.App version it needs to be updated to
+         target the latest patch before a preview release. -->
+    <LatestAspNetCoreReferenceVersion>3.1.0</LatestAspNetCoreReferenceVersion>
+
     <ComponentsSharedSourceRoot>$(MSBuildThisFileDirectory)Shared\</ComponentsSharedSourceRoot>
 
     <!-- So we can use the tool from source within the repo without having to pack -->
diff --git a/src/Components/Directory.Build.targets b/src/Components/Directory.Build.targets
index a3eb9736290fdf8857e4e7b41d6e314a72a9707d..d6569c4088f4d196c4b4e3e44f61a2003ac1501c 100644
--- a/src/Components/Directory.Build.targets
+++ b/src/Components/Directory.Build.targets
@@ -3,6 +3,26 @@
     <GenerateDocumentationFile Condition="'$(GenerateDocumentationFile)' == ''">true</GenerateDocumentationFile>
   </PropertyGroup>
 
+  <!-- We need to do this because our build config interferes with the FrameworkReference definition.
+       This is a way to add the framework defition to the projects that need it (like Blazor Server and
+       Blazor Dev Server) -->
+  <Target Name="_AddAspNetCoreFrameworkReference" BeforeTargets="ProcessFrameworkReferences" Condition="'$(UseLatestAspNetCoreReference)' == 'true' ">
+    <ItemGroup>
+      <FrameworkReference Include="Microsoft.AspNetCore.App" Version="$(LatestAspNetCoreReferenceVersion)" />
+      <KnownFrameworkReference Include="Microsoft.AspNetCore.App">
+        <TargetFramework>netcoreapp3.1</TargetFramework>
+        <RuntimeFrameworkName>Microsoft.AspNetCore.App</RuntimeFrameworkName>
+        <DefaultRuntimeFrameworkVersion>$(LatestAspNetCoreReferenceVersion)</DefaultRuntimeFrameworkVersion>
+        <LatestRuntimeFrameworkVersion>$(LatestAspNetCoreReferenceVersion)</LatestRuntimeFrameworkVersion>
+        <TargetingPackName>Microsoft.AspNetCore.App.Ref</TargetingPackName>
+        <TargetingPackVersion>$(LatestAspNetCoreReferenceVersion)</TargetingPackVersion>
+        <RuntimePackNamePatterns>Microsoft.AspNetCore.App.Runtime.**RID**</RuntimePackNamePatterns>
+        <RuntimePackRuntimeIdentifiers>linux-arm;linux-arm64;linux-musl-arm64;linux-musl-x64;linux-x64;osx-x64;rhel.6-x64;tizen.4.0.0-armel;tizen.5.0.0-armel;win-arm;win-arm64;win-x64;win-x86</RuntimePackRuntimeIdentifiers>
+        <IsTrimmable>true</IsTrimmable>
+      </KnownFrameworkReference>
+    </ItemGroup>
+  </Target>
+
   <ItemGroup>
     <!-- Add a project dependency without reference output assemblies to enforce build order -->
     <!-- Applying workaround for https://github.com/microsoft/msbuild/issues/2661 and https://github.com/dotnet/sdk/issues/952 -->
diff --git a/src/Components/Web.JS/dist/Release/blazor.webassembly.js b/src/Components/Web.JS/dist/Release/blazor.webassembly.js
index 5279765b23d843f313d3a3431cbacb20cf128e1b..16dd578625ff37c883bf7afba118a1adb6e67981 100644
Binary files a/src/Components/Web.JS/dist/Release/blazor.webassembly.js and b/src/Components/Web.JS/dist/Release/blazor.webassembly.js differ
diff --git a/src/Components/Web.JS/src/Boot.WebAssembly.ts b/src/Components/Web.JS/src/Boot.WebAssembly.ts
index 74bd496b0285ff27640baa2cec3e916b6960ffb8..1a800983017f45a98e2f03e09a6bcb7901d22dcd 100644
--- a/src/Components/Web.JS/src/Boot.WebAssembly.ts
+++ b/src/Components/Web.JS/src/Boot.WebAssembly.ts
@@ -2,7 +2,6 @@ import '@dotnet/jsinterop';
 import './GlobalExports';
 import * as Environment from './Environment';
 import { monoPlatform } from './Platform/Mono/MonoPlatform';
-import { getAssemblyNameFromUrl } from './Platform/Url';
 import { renderBatch } from './Rendering/Renderer';
 import { SharedMemoryRenderBatch } from './Rendering/RenderBatch/SharedMemoryRenderBatch';
 import { Pointer } from './Platform/Platform';
@@ -39,15 +38,13 @@ async function boot(options?: any): Promise<void> {
 
   // Fetch the boot JSON file
   const bootConfig = await fetchBootConfigAsync();
-  const embeddedResourcesPromise = loadEmbeddedResourcesAsync(bootConfig);
 
   if (!bootConfig.linkerEnabled) {
     console.info('Blazor is running in dev mode without IL stripping. To make the bundle size significantly smaller, publish the application or see https://go.microsoft.com/fwlink/?linkid=870414');
   }
 
   // Determine the URLs of the assemblies we want to load, then begin fetching them all
-  const loadAssemblyUrls = [bootConfig.main]
-    .concat(bootConfig.assemblyReferences)
+  const loadAssemblyUrls = bootConfig.assemblies
     .map(filename => `_framework/_bin/${filename}`);
 
   try {
@@ -56,12 +53,8 @@ async function boot(options?: any): Promise<void> {
     throw new Error(`Failed to start platform. Reason: ${ex}`);
   }
 
-  // Before we start running .NET code, be sure embedded content resources are all loaded
-  await embeddedResourcesPromise;
-
   // Start up the application
-  const mainAssemblyName = getAssemblyNameFromUrl(bootConfig.main);
-  platform.callEntryPoint(mainAssemblyName, bootConfig.entryPoint, []);
+  platform.callEntryPoint(bootConfig.entryAssembly);
 }
 
 async function fetchBootConfigAsync() {
@@ -71,40 +64,16 @@ async function fetchBootConfigAsync() {
   return bootConfigResponse.json() as Promise<BootJsonData>;
 }
 
-function loadEmbeddedResourcesAsync(bootConfig: BootJsonData): Promise<any> {
-  const cssLoadingPromises = bootConfig.cssReferences.map(cssReference => {
-    const linkElement = document.createElement('link');
-    linkElement.rel = 'stylesheet';
-    linkElement.href = cssReference;
-    return loadResourceFromElement(linkElement);
-  });
-  const jsLoadingPromises = bootConfig.jsReferences.map(jsReference => {
-    const scriptElement = document.createElement('script');
-    scriptElement.src = jsReference;
-    return loadResourceFromElement(scriptElement);
-  });
-  return Promise.all(cssLoadingPromises.concat(jsLoadingPromises));
-}
-
-function loadResourceFromElement(element: HTMLElement) {
-  return new Promise((resolve, reject) => {
-    element.onload = resolve;
-    element.onerror = reject;
-    document.head!.appendChild(element);
-  });
-}
-
 // Keep in sync with BootJsonData in Microsoft.AspNetCore.Blazor.Build
 interface BootJsonData {
-  main: string;
-  entryPoint: string;
-  assemblyReferences: string[];
-  cssReferences: string[];
-  jsReferences: string[];
+  entryAssembly: string;
+  assemblies: string[];
   linkerEnabled: boolean;
 }
 
 window['Blazor'].start = boot;
 if (shouldAutoStart()) {
-  boot();
+  boot().catch(error => {
+    Module.printErr(error); // Logs it, and causes the error UI to appear
+  });
 }
diff --git a/src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts b/src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts
index 997b3d7bca6e1ddaf71a14ce40af3499aa1a4c20..321a708f579d4d653588e01b3d5fafb6ade344b4 100644
--- a/src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts
+++ b/src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts
@@ -1,18 +1,9 @@
-import { MethodHandle, System_Object, System_String, System_Array, Pointer, Platform } from '../Platform';
+import { System_Object, System_String, System_Array, Pointer, Platform } from '../Platform';
 import { getFileNameFromUrl } from '../Url';
 import { attachDebuggerHotkey, hasDebuggingEnabled } from './MonoDebugger';
 import { showErrorNotification } from '../../BootErrors';
 
-const assemblyHandleCache: { [assemblyName: string]: number } = {};
-const typeHandleCache: { [fullyQualifiedTypeName: string]: number } = {};
-const methodHandleCache: { [fullyQualifiedMethodName: string]: MethodHandle } = {};
-
-let assembly_load: (assemblyName: string) => number;
-let find_class: (assemblyHandle: number, namespace: string, className: string) => number;
-let find_method: (typeHandle: number, methodName: string, unknownArg: number) => MethodHandle;
-let invoke_method: (method: MethodHandle, target: System_Object, argsArrayPtr: number, exceptionFlagIntPtr: number) => System_Object;
 let mono_string_get_utf8: (managedString: System_String) => Mono.Utf8Ptr;
-let mono_string: (jsString: string) => System_String;
 const appBinDirName = 'appBinDir';
 const uint64HighOrderShift = Math.pow(2, 32);
 const maxSafeNumberHighPart = Math.pow(2, 21) - 1; // The high-order int32 from Number.MAX_SAFE_INTEGER
@@ -22,7 +13,7 @@ export const monoPlatform: Platform = {
     return new Promise<void>((resolve, reject) => {
       attachDebuggerHotkey(loadAssemblyUrls);
 
-      // mono.js assumes the existence of this
+      // dotnet.js assumes the existence of this
       window['Browser'] = {
         init: () => { },
       };
@@ -37,52 +28,16 @@ export const monoPlatform: Platform = {
     });
   },
 
-  findMethod: findMethod,
-
-  callEntryPoint: function callEntryPoint(assemblyName: string, entrypointMethod: string, args: System_Object[]): void {
-    // Parse the entrypointMethod, which is of the form MyApp.MyNamespace.MyTypeName::MyMethodName
-    // Note that we don't support specifying a method overload, so it has to be unique
-    const entrypointSegments = entrypointMethod.split('::');
-    if (entrypointSegments.length != 2) {
-      throw new Error('Malformed entry point method name; could not resolve class name and method name.');
-    }
-    const typeFullName = entrypointSegments[0];
-    const methodName = entrypointSegments[1];
-    const lastDot = typeFullName.lastIndexOf('.');
-    const namespace = lastDot > -1 ? typeFullName.substring(0, lastDot) : '';
-    const typeShortName = lastDot > -1 ? typeFullName.substring(lastDot + 1) : typeFullName;
-
-    const entryPointMethodHandle = monoPlatform.findMethod(assemblyName, namespace, typeShortName, methodName);
-    monoPlatform.callMethod(entryPointMethodHandle, null, args);
-  },
-
-  callMethod: function callMethod(method: MethodHandle, target: System_Object, args: System_Object[]): System_Object {
-    if (args.length > 4) {
-      // Hopefully this restriction can be eased soon, but for now make it clear what's going on
-      throw new Error(`Currently, MonoPlatform supports passing a maximum of 4 arguments from JS to .NET. You tried to pass ${args.length}.`);
-    }
-
-    const stack = Module.stackSave();
-
-    try {
-      const argsBuffer = Module.stackAlloc(args.length);
-      const exceptionFlagManagedInt = Module.stackAlloc(4);
-      for (let i = 0; i < args.length; ++i) {
-        Module.setValue(argsBuffer + i * 4, args[i], 'i32');
-      }
-      Module.setValue(exceptionFlagManagedInt, 0, 'i32');
-
-      const res = invoke_method(method, target, argsBuffer, exceptionFlagManagedInt);
-
-      if (Module.getValue(exceptionFlagManagedInt, 'i32') !== 0) {
-        // If the exception flag is set, the returned value is exception.ToString()
-        throw new Error(monoPlatform.toJavaScriptString(<System_String>res));
-      }
-
-      return res;
-    } finally {
-      Module.stackRestore(stack);
-    }
+  callEntryPoint: function callEntryPoint(assemblyName: string) {
+    // Instead of using Module.mono_call_assembly_entry_point, we have our own logic for invoking
+    // the entrypoint which adds support for async main.
+    // Currently we disregard the return value from the entrypoint, whether it's sync or async.
+    // In the future, we might want Blazor.start to return a Promise<Promise<value>>, where the
+    // outer promise reflects the startup process, and the inner one reflects the possibly-async
+    // .NET entrypoint method.
+    const invokeEntrypoint = bindStaticMethod('Microsoft.AspNetCore.Blazor', 'Microsoft.AspNetCore.Blazor.Hosting.EntrypointInvoker', 'InvokeEntrypoint');
+    // Note we're passing in null because passing arrays is problematic until https://github.com/mono/mono/issues/18245 is resolved.
+    invokeEntrypoint(assemblyName, null);
   },
 
   toJavaScriptString: function toJavaScriptString(managedString: System_String) {
@@ -96,10 +51,6 @@ export const monoPlatform: Platform = {
     return res;
   },
 
-  toDotNetString: function toDotNetString(jsString: string): System_String {
-    return mono_string(jsString);
-  },
-
   toUint8Array: function toUint8Array(array: System_Array<any>): Uint8Array {
     const dataPtr = getArrayDataPointer(array);
     const length = Module.getValue(dataPtr, 'i32');
@@ -160,44 +111,6 @@ export const monoPlatform: Platform = {
   },
 };
 
-function findAssembly(assemblyName: string): number {
-  let assemblyHandle = assemblyHandleCache[assemblyName];
-  if (!assemblyHandle) {
-    assemblyHandle = assembly_load(assemblyName);
-    if (!assemblyHandle) {
-      throw new Error(`Could not find assembly "${assemblyName}"`);
-    }
-    assemblyHandleCache[assemblyName] = assemblyHandle;
-  }
-  return assemblyHandle;
-}
-
-function findType(assemblyName: string, namespace: string, className: string): number {
-  const fullyQualifiedTypeName = `[${assemblyName}]${namespace}.${className}`;
-  let typeHandle = typeHandleCache[fullyQualifiedTypeName];
-  if (!typeHandle) {
-    typeHandle = find_class(findAssembly(assemblyName), namespace, className);
-    if (!typeHandle) {
-      throw new Error(`Could not find type "${className}" in namespace "${namespace}" in assembly "${assemblyName}"`);
-    }
-    typeHandleCache[fullyQualifiedTypeName] = typeHandle;
-  }
-  return typeHandle;
-}
-
-function findMethod(assemblyName: string, namespace: string, className: string, methodName: string): MethodHandle {
-  const fullyQualifiedMethodName = `[${assemblyName}]${namespace}.${className}::${methodName}`;
-  let methodHandle = methodHandleCache[fullyQualifiedMethodName];
-  if (!methodHandle) {
-    methodHandle = find_method(findType(assemblyName, namespace, className), methodName, -1);
-    if (!methodHandle) {
-      throw new Error(`Could not find method "${methodName}" on type "${namespace}.${className}"`);
-    }
-    methodHandleCache[fullyQualifiedMethodName] = methodHandle;
-  }
-  return methodHandle;
-}
-
 function addScriptTagsToDocument() {
   const browserSupportsNativeWebAssembly = typeof WebAssembly !== 'undefined' && WebAssembly.validate;
   if (!browserSupportsNativeWebAssembly) {
@@ -205,7 +118,7 @@ function addScriptTagsToDocument() {
   }
 
   const scriptElem = document.createElement('script');
-  scriptElem.src = '_framework/wasm/mono.js';
+  scriptElem.src = '_framework/wasm/dotnet.js';
   scriptElem.defer = true;
   document.body.appendChild(scriptElem);
 }
@@ -229,7 +142,7 @@ function addGlobalModuleScriptTagsToDocument(callback: () => void) {
 
 function createEmscriptenModuleInstance(loadAssemblyUrls: string[], onReady: () => void, onError: (reason?: any) => void) {
   const module = {} as typeof Module;
-  const wasmBinaryFile = '_framework/wasm/mono.wasm';
+  const wasmBinaryFile = '_framework/wasm/dotnet.wasm';
   const suppressMessages = ['DEBUGGING ENABLED'];
 
   module.print = line => (suppressMessages.indexOf(line) < 0 && console.log(`WASM: ${line}`));
@@ -244,7 +157,7 @@ function createEmscriptenModuleInstance(loadAssemblyUrls: string[], onReady: ()
 
   module.locateFile = fileName => {
     switch (fileName) {
-      case 'mono.wasm': return wasmBinaryFile;
+      case 'dotnet.wasm': return wasmBinaryFile;
       default: return fileName;
     }
   };
@@ -256,24 +169,8 @@ function createEmscriptenModuleInstance(loadAssemblyUrls: string[], onReady: ()
       'number',
       'number',
     ]);
-    assembly_load = Module.cwrap('mono_wasm_assembly_load', 'number', ['string']);
-    find_class = Module.cwrap('mono_wasm_assembly_find_class', 'number', [
-      'number',
-      'string',
-      'string',
-    ]);
-    find_method = Module.cwrap('mono_wasm_assembly_find_method', 'number', [
-      'number',
-      'string',
-      'number',
-    ]);
-    invoke_method = Module.cwrap('mono_wasm_invoke_method', 'number', [
-      'number',
-      'number',
-      'number',
-    ]);
+
     mono_string_get_utf8 = Module.cwrap('mono_wasm_string_get_utf8', 'number', ['number']);
-    mono_string = Module.cwrap('mono_wasm_string_from_js', 'number', ['string']);
 
     MONO.loaded_files = [];
 
@@ -346,10 +243,16 @@ function getArrayDataPointer<T>(array: System_Array<T>): number {
   return <number><any>array + 12; // First byte from here is length, then following bytes are entries
 }
 
+function bindStaticMethod(assembly: string, typeName: string, method: string) : (...args: any[]) => any {
+  // Fully qualified name looks like this: "[debugger-test] Math:IntAdd"
+  const fqn = `[${assembly}] ${typeName}:${method}`;
+  return Module.mono_bind_static_method(fqn);
+}
+
 function attachInteropInvoker(): void {
-  const dotNetDispatcherInvokeMethodHandle = findMethod('Mono.WebAssembly.Interop', 'Mono.WebAssembly.Interop', 'MonoWebAssemblyJSRuntime', 'InvokeDotNet');
-  const dotNetDispatcherBeginInvokeMethodHandle = findMethod('Mono.WebAssembly.Interop', 'Mono.WebAssembly.Interop', 'MonoWebAssemblyJSRuntime', 'BeginInvokeDotNet');
-  const dotNetDispatcherEndInvokeJSMethodHandle = findMethod('Mono.WebAssembly.Interop', 'Mono.WebAssembly.Interop', 'MonoWebAssemblyJSRuntime', 'EndInvokeJS');
+  const dotNetDispatcherInvokeMethodHandle =  bindStaticMethod('Mono.WebAssembly.Interop', 'Mono.WebAssembly.Interop.MonoWebAssemblyJSRuntime', 'InvokeDotNet');
+  const dotNetDispatcherBeginInvokeMethodHandle = bindStaticMethod('Mono.WebAssembly.Interop', 'Mono.WebAssembly.Interop.MonoWebAssemblyJSRuntime', 'BeginInvokeDotNet');
+  const dotNetDispatcherEndInvokeJSMethodHandle = bindStaticMethod('Mono.WebAssembly.Interop', 'Mono.WebAssembly.Interop.MonoWebAssemblyJSRuntime', 'EndInvokeJS');
 
   DotNet.attachDispatcher({
     beginInvokeDotNetFromJS: (callId: number, assemblyName: string | null, methodIdentifier: string, dotNetObjectId: any | null, argsJson: string): void => {
@@ -362,30 +265,25 @@ function attachInteropInvoker(): void {
         ? dotNetObjectId.toString()
         : assemblyName;
 
-      monoPlatform.callMethod(dotNetDispatcherBeginInvokeMethodHandle, null, [
-        callId ? monoPlatform.toDotNetString(callId.toString()) : null,
-        monoPlatform.toDotNetString(assemblyNameOrDotNetObjectId),
-        monoPlatform.toDotNetString(methodIdentifier),
-        monoPlatform.toDotNetString(argsJson),
-      ]);
+        dotNetDispatcherBeginInvokeMethodHandle(
+          callId ? callId.toString() : null,
+          assemblyNameOrDotNetObjectId,
+          methodIdentifier,
+          argsJson,
+        );
     },
     endInvokeJSFromDotNet: (asyncHandle, succeeded, serializedArgs): void => {
-      monoPlatform.callMethod(
-        dotNetDispatcherEndInvokeJSMethodHandle,
-        null,
-        [monoPlatform.toDotNetString(serializedArgs)]
+      dotNetDispatcherEndInvokeJSMethodHandle(
+        serializedArgs
       );
     },
     invokeDotNetFromJS: (assemblyName, methodIdentifier, dotNetObjectId, argsJson) => {
-      const resultJsonStringPtr = monoPlatform.callMethod(dotNetDispatcherInvokeMethodHandle, null, [
-        assemblyName ? monoPlatform.toDotNetString(assemblyName) : null,
-        monoPlatform.toDotNetString(methodIdentifier),
-        dotNetObjectId ? monoPlatform.toDotNetString(dotNetObjectId.toString()) : null,
-        monoPlatform.toDotNetString(argsJson),
-      ]) as System_String;
-      return resultJsonStringPtr
-        ? monoPlatform.toJavaScriptString(resultJsonStringPtr)
-        : null;
+      return dotNetDispatcherInvokeMethodHandle(
+        assemblyName ? assemblyName : null,
+        methodIdentifier,
+        dotNetObjectId ? dotNetObjectId.toString() : null,
+        argsJson,
+      ) as string;
     },
   });
 }
diff --git a/src/Components/Web.JS/src/Platform/Mono/MonoTypes.d.ts b/src/Components/Web.JS/src/Platform/Mono/MonoTypes.d.ts
index 783af016f7111f1350f0d3761b84d131a861323b..7d2f5c23bf036f0c783cb06a5d81a7de1cdfd6fb 100644
--- a/src/Components/Web.JS/src/Platform/Mono/MonoTypes.d.ts
+++ b/src/Components/Web.JS/src/Platform/Mono/MonoTypes.d.ts
@@ -9,6 +9,8 @@ declare namespace Module {
   // These should probably be in @types/emscripten
   function FS_createPath(parent, path, canRead, canWrite);
   function FS_createDataFile(parent, name, data, canRead, canWrite, canOwn);
+
+  function mono_bind_static_method(fqn: string): BoundStaticMethod;
 }
 
 // Emscripten declares these globals
@@ -26,3 +28,7 @@ declare namespace MONO {
   var mono_wasm_runtime_is_ready: boolean;
   function mono_wasm_setenv (name: string, value: string): void;
 }
+
+// mono_bind_static_method allows arbitrary JS data types to be sent over the wire. However we are
+// artifically limiting it to a subset of types that we actually use.
+declare type BoundStaticMethod = (...args: (string | number | null)[]) => (string | number | null);
diff --git a/src/Components/Web.JS/src/Platform/Platform.ts b/src/Components/Web.JS/src/Platform/Platform.ts
index bb2f52113b501b240f44fe81051f61bddd9fffad..8d5daf454a3f0637a752dd9f139a6cbbf1aa05cd 100644
--- a/src/Components/Web.JS/src/Platform/Platform.ts
+++ b/src/Components/Web.JS/src/Platform/Platform.ts
@@ -1,13 +1,9 @@
 export interface Platform {
   start(loadAssemblyUrls: string[]): Promise<void>;
 
-  callEntryPoint(assemblyName: string, entrypointMethod: string, args: (System_Object | null)[]);
-  findMethod(assemblyName: string, namespace: string, className: string, methodName: string): MethodHandle;
-  callMethod(method: MethodHandle, target: System_Object | null, args: (System_Object | null)[]): System_Object;
+  callEntryPoint(assemblyName: string): void;
 
   toJavaScriptString(dotNetString: System_String): string;
-  toDotNetString(javaScriptString: string): System_String;
-
   toUint8Array(array: System_Array<any>): Uint8Array;
 
   getArrayLength(array: System_Array<any>): number;
diff --git a/src/Components/benchmarkapps/BlazingPizza.Server/Directory.Build.props b/src/Components/benchmarkapps/BlazingPizza.Server/Directory.Build.props
new file mode 100644
index 0000000000000000000000000000000000000000..8c119d5413b585461eb318f359a04eca07a09665
--- /dev/null
+++ b/src/Components/benchmarkapps/BlazingPizza.Server/Directory.Build.props
@@ -0,0 +1,2 @@
+<Project>
+</Project>
diff --git a/src/Components/benchmarkapps/Directory.Build.targets b/src/Components/benchmarkapps/BlazingPizza.Server/Directory.Build.targets
similarity index 100%
rename from src/Components/benchmarkapps/Directory.Build.targets
rename to src/Components/benchmarkapps/BlazingPizza.Server/Directory.Build.targets
diff --git a/src/Components/benchmarkapps/NuGet.config b/src/Components/benchmarkapps/BlazingPizza.Server/NuGet.config
similarity index 100%
rename from src/Components/benchmarkapps/NuGet.config
rename to src/Components/benchmarkapps/BlazingPizza.Server/NuGet.config
diff --git a/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkMeasurement.cs b/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkMeasurement.cs
new file mode 100644
index 0000000000000000000000000000000000000000..62016cf630e6893aabe4b07521ff6af4fe14e029
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkMeasurement.cs
@@ -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.
+
+using System;
+
+namespace Wasm.Performance.Driver
+{
+    internal class BenchmarkMeasurement
+    {
+        public DateTime Timestamp { get; internal set; }
+        public string Name { get; internal set; }
+        public double Value { get; internal set; }
+    }
+}
\ No newline at end of file
diff --git a/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkMetadata.cs b/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkMetadata.cs
new file mode 100644
index 0000000000000000000000000000000000000000..ab98fef891b978f39f11a01040e51631f0566e4a
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkMetadata.cs
@@ -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.
+
+namespace Wasm.Performance.Driver
+{
+    internal class BenchmarkMetadata
+    {
+        public string Source { get; set; }
+        public string Name { get; set; }
+        public string ShortDescription { get; set; }
+        public string LongDescription { get; set; }
+        public string Format { get; set; }
+    }
+}
diff --git a/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkOutput.cs b/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkOutput.cs
new file mode 100644
index 0000000000000000000000000000000000000000..7a32ce146d5832c60155b1738a6ba6d3e15cfccf
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkOutput.cs
@@ -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.
+
+using System.Collections.Generic;
+
+namespace Wasm.Performance.Driver
+{
+    internal class BenchmarkOutput
+    {
+        public List<BenchmarkMetadata> Metadata { get; } = new List<BenchmarkMetadata>();
+
+        public List<BenchmarkMeasurement> Measurements { get; } = new List<BenchmarkMeasurement>();
+    }
+}
diff --git a/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkResult.cs b/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkResult.cs
new file mode 100644
index 0000000000000000000000000000000000000000..33e4c4094bdde72f310356809e94d888ca53bf87
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkResult.cs
@@ -0,0 +1,24 @@
+namespace Wasm.Performance.Driver
+{
+    class BenchmarkResult
+    {
+        public string Name { get; set; }
+
+        public BenchmarkDescriptor Descriptor { get; set; }
+
+        public string ShortDescription { get; set; }
+
+        public bool Success { get; set; }
+
+        public int NumExecutions { get; set; }
+
+        public double Duration { get; set; }
+
+        public class BenchmarkDescriptor
+        {
+            public string Name { get; set; }
+
+            public string Description { get; set; }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkResultsStartup.cs b/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkResultsStartup.cs
new file mode 100644
index 0000000000000000000000000000000000000000..7a4af028dfff0503cca66cc8577d70e5781fe3c8
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/Driver/BenchmarkResultsStartup.cs
@@ -0,0 +1,37 @@
+// 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.Collections.Generic;
+using System.Text.Json;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace Wasm.Performance.Driver
+{
+    public class BenchmarkDriverStartup
+    {
+
+        public void ConfigureServices(IServiceCollection services)
+        {
+            services.AddCors(c => c.AddDefaultPolicy(builder => builder.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin()));
+        }
+
+        public void Configure(IApplicationBuilder app)
+        {
+            app.UseCors();
+
+            app.Run(async context =>
+            {
+                var result = await JsonSerializer.DeserializeAsync<List<BenchmarkResult>>(context.Request.Body, new JsonSerializerOptions
+                {
+                    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+                });
+                await context.Response.WriteAsync("OK");
+                Program.SetBenchmarkResult(result);
+            });
+        }
+    }
+}
diff --git a/src/Components/benchmarkapps/Wasm.Performance/Driver/Program.cs b/src/Components/benchmarkapps/Wasm.Performance/Driver/Program.cs
new file mode 100644
index 0000000000000000000000000000000000000000..8d588b64304130c8382a8f44ee8193bbeef28458
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/Driver/Program.cs
@@ -0,0 +1,272 @@
+// 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.IO;
+using System.IO.Compression;
+using System.Linq;
+using System.Runtime.ExceptionServices;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using DevHostServerProgram = Microsoft.AspNetCore.Blazor.DevServer.Server.Program;
+
+namespace Wasm.Performance.Driver
+{
+    public class Program
+    {
+        static readonly TimeSpan Timeout = TimeSpan.FromMinutes(3);
+        static TaskCompletionSource<List<BenchmarkResult>> benchmarkResult = new TaskCompletionSource<List<BenchmarkResult>>();
+
+        public static async Task<int> Main(string[] args)
+        {
+            var seleniumPort = 4444;
+            if (args.Length > 0)
+            {
+                if (!int.TryParse(args[0], out seleniumPort))
+                {
+                    Console.Error.WriteLine("Usage Driver <selenium-port>");
+                    return 1;
+                }
+            }
+
+            // This write is required for the benchmarking infrastructure.
+            Console.WriteLine("Application started.");
+
+            var cancellationToken = new CancellationTokenSource(Timeout);
+            cancellationToken.Token.Register(() => benchmarkResult.TrySetException(new TimeoutException($"Timed out after {Timeout}")));
+
+            using var browser = await Selenium.CreateBrowser(seleniumPort, cancellationToken.Token);
+            using var testApp = StartTestApp();
+            using var benchmarkReceiver = StartBenchmarkResultReceiver();
+
+            var testAppUrl = GetListeningUrl(testApp);
+            var receiverUrl = GetListeningUrl(benchmarkReceiver);
+
+            Console.WriteLine($"Test app listening at {testAppUrl}.");
+
+            var launchUrl = $"{testAppUrl}?resultsUrl={UrlEncoder.Default.Encode(receiverUrl)}#automated";
+            browser.Url = launchUrl;
+            browser.Navigate();
+
+            var appSize = GetBlazorAppSize();
+            await Task.WhenAll(benchmarkResult.Task, appSize);
+            FormatAsBenchmarksOutput(benchmarkResult.Task.Result, appSize.Result);
+
+            Console.WriteLine("Done executing benchmark");
+            return 0;
+        }
+
+        internal static void SetBenchmarkResult(List<BenchmarkResult> result)
+        {
+            benchmarkResult.TrySetResult(result);
+        }
+
+        private static void FormatAsBenchmarksOutput(List<BenchmarkResult> results, (long publishSize, long compressedSize) sizes)
+        {
+            // Sample of the the format: https://github.com/aspnet/Benchmarks/blob/e55f9e0312a7dd019d1268c1a547d1863f0c7237/src/Benchmarks/Program.cs#L51-L67
+            var output = new BenchmarkOutput();
+            foreach (var result in results)
+            {
+                var scenarioName = result.Descriptor.Name;
+                output.Metadata.Add(new BenchmarkMetadata
+                {
+                    Source = "BlazorWasm",
+                    Name = scenarioName,
+                    ShortDescription = result.Name,
+                    LongDescription = result.Descriptor.Description,
+                    Format = "n2"
+                });
+
+                output.Measurements.Add(new BenchmarkMeasurement
+                {
+                    Timestamp = DateTime.UtcNow,
+                    Name = scenarioName,
+                    Value = result.Duration,
+                });
+            }
+
+            // Statistics about publish sizes
+            output.Metadata.Add(new BenchmarkMetadata
+            {
+                Source = "BlazorWasm",
+                Name = "blazorwasm/publish-size",
+                ShortDescription = "Publish size (KB)",
+                LongDescription = "Publish size (KB)",
+                Format = "n2",
+            });
+
+            output.Measurements.Add(new BenchmarkMeasurement
+            {
+                Timestamp = DateTime.UtcNow,
+                Name = "blazorwasm/publish-size",
+                Value = sizes.publishSize / 1024,
+            });
+
+            output.Metadata.Add(new BenchmarkMetadata
+            {
+                Source = "BlazorWasm",
+                Name = "blazorwasm/compressed-publish-size",
+                ShortDescription = "Publish size  compressed app (KB)",
+                LongDescription = "Publish size - compressed app (KB)",
+                Format = "n2",
+            });
+
+            output.Measurements.Add(new BenchmarkMeasurement
+            {
+                Timestamp = DateTime.UtcNow,
+                Name = "blazorwasm/compressed-publish-size",
+                Value = sizes.compressedSize / 1024,
+            });
+
+            Console.WriteLine("#StartJobStatistics");
+            Console.WriteLine(JsonSerializer.Serialize(output));
+            Console.WriteLine("#EndJobStatistics");
+        }
+
+        static IHost StartTestApp()
+        {
+            var args = new[]
+            {
+                "--urls", "http://127.0.0.1:0",
+                "--applicationpath", typeof(TestApp.Program).Assembly.Location,
+            };
+
+            var host = DevHostServerProgram.BuildWebHost(args);
+            RunInBackgroundThread(host.Start);
+            return host;
+        }
+
+        static IHost StartBenchmarkResultReceiver()
+        {
+            var args = new[]
+            {
+                "--urls", "http://127.0.0.1:0",
+            };
+
+            var host = Host.CreateDefaultBuilder(args)
+                .ConfigureWebHostDefaults(builder => builder.UseStartup<BenchmarkDriverStartup>())
+                .Build();
+
+            RunInBackgroundThread(host.Start);
+            return host;
+        }
+
+        static void RunInBackgroundThread(Action action)
+        {
+            var isDone = new ManualResetEvent(false);
+
+            ExceptionDispatchInfo edi = null;
+            Task.Run(() =>
+            {
+                try
+                {
+                    action();
+                }
+                catch (Exception ex)
+                {
+                    edi = ExceptionDispatchInfo.Capture(ex);
+                }
+
+                isDone.Set();
+            });
+
+            if (!isDone.WaitOne(Timeout))
+            {
+                throw new TimeoutException("Timed out waiting for: " + action);
+            }
+
+            if (edi != null)
+            {
+                throw edi.SourceException;
+            }
+        }
+
+        static string GetListeningUrl(IHost testApp)
+        {
+            return testApp.Services.GetRequiredService<IServer>()
+                .Features
+                .Get<IServerAddressesFeature>()
+                .Addresses
+                .First();
+        }
+
+        static async Task<(long size, long compressedSize)> GetBlazorAppSize()
+        {
+            var testAssembly = typeof(TestApp.Program).Assembly;
+            var testAssemblyLocation = new FileInfo(testAssembly.Location);
+            var testApp = new DirectoryInfo(Path.Combine(
+                testAssemblyLocation.Directory.FullName,
+                testAssembly.GetName().Name));
+
+            return (GetDirectorySize(testApp), await GetBrotliCompressedSize(testApp));
+        }
+
+        static long GetDirectorySize(DirectoryInfo directory)
+        {
+            // This can happen if you run the app without publishing it.
+            if (!directory.Exists)
+            {
+                return 0;
+            }
+
+            long size = 0;
+            foreach (var item in directory.EnumerateFileSystemInfos())
+            {
+                if (item is FileInfo fileInfo)
+                {
+                    size += fileInfo.Length;
+                }
+                else if (item is DirectoryInfo directoryInfo)
+                {
+                    size += GetDirectorySize(directoryInfo);
+                }
+            }
+
+            return size;
+        }
+
+        static async Task<long> GetBrotliCompressedSize(DirectoryInfo directory)
+        {
+            if (!directory.Exists)
+            {
+                return 0;
+    }
+
+            var tasks = new List<Task<long>>();
+            foreach (var item in directory.EnumerateFileSystemInfos())
+            {
+                if (item is FileInfo fileInfo)
+                {
+                    tasks.Add(GetCompressedFileSize(fileInfo));
+                }
+                else if (item is DirectoryInfo directoryInfo)
+                {
+                    tasks.Add(GetBrotliCompressedSize(directoryInfo));
+                }
+            }
+
+            return (await Task.WhenAll(tasks)).Sum(s => s);
+
+            async Task<long> GetCompressedFileSize(FileInfo fileInfo)
+            {
+                using var inputStream = new FileStream(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.Read, 1, useAsync: true);
+                using var outputStream = new MemoryStream();
+
+                using  (var brotliStream = new BrotliStream(outputStream, CompressionLevel.Optimal, leaveOpen: true))
+                {
+                    await inputStream.CopyToAsync(brotliStream);
+                }
+
+                return outputStream.Length;
+            }
+        }
+    }
+}
diff --git a/src/Components/benchmarkapps/Wasm.Performance/Driver/Selenium.cs b/src/Components/benchmarkapps/Wasm.Performance/Driver/Selenium.cs
new file mode 100644
index 0000000000000000000000000000000000000000..1c30e69e20b07b543415754e28d4593f15833f9f
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/Driver/Selenium.cs
@@ -0,0 +1,121 @@
+// 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.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenQA.Selenium;
+using OpenQA.Selenium.Chrome;
+using OpenQA.Selenium.Remote;
+
+namespace Wasm.Performance.Driver
+{
+    class Selenium
+    {
+        static bool RunHeadlessBrowser = true;
+        static bool PoolForBrowserLogs = true;
+
+        private static async ValueTask<Uri> WaitForServerAsync(int port, CancellationToken cancellationToken)
+        {
+            var uri = new UriBuilder("http", "localhost", port, "/wd/hub/").Uri;
+            var httpClient = new HttpClient
+            {
+                BaseAddress = uri,
+                Timeout = TimeSpan.FromSeconds(1),
+            };
+
+            Console.WriteLine($"Attempting to connect to Selenium Server running at {uri}");
+
+            const int MaxRetries = 30;
+            var retries = 0;
+
+            while (retries < MaxRetries)
+            {
+                retries++;
+                try
+                {
+                    var response = (await httpClient.GetAsync("status", cancellationToken)).EnsureSuccessStatusCode();
+                    Console.WriteLine("Connected to Selenium");
+                    return uri;
+                }
+                catch
+                {
+                    if (retries == 1)
+                    {
+                        Console.WriteLine("Could not connect to selenium-server. Has it been started as yet?");
+                    }
+                }
+
+                await Task.Delay(1000);
+            }
+
+            throw new Exception($"Unable to connect to selenium-server at {uri}");
+        }
+
+        public static async Task<RemoteWebDriver> CreateBrowser(int port, CancellationToken cancellationToken)
+        {
+            var uri = await WaitForServerAsync(port, cancellationToken);
+
+            var options = new ChromeOptions();
+
+            if (RunHeadlessBrowser)
+            {
+                options.AddArgument("--headless");
+            }
+
+            options.SetLoggingPreference(LogType.Browser, LogLevel.All);
+
+            var attempt = 0;
+            const int MaxAttempts = 3;
+            do
+            {
+                try
+                {
+                    // The driver opens the browser window and tries to connect to it on the constructor.
+                    // Under heavy load, this can cause issues
+                    // To prevent this we let the client attempt several times to connect to the server, increasing
+                    // the max allowed timeout for a command on each attempt linearly.
+                    var driver = new RemoteWebDriver(
+                        uri,
+                        options.ToCapabilities(),
+                        TimeSpan.FromSeconds(60).Add(TimeSpan.FromSeconds(attempt * 60)));
+
+                    driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(1);
+
+                    if (PoolForBrowserLogs)
+                    {
+                        // Run in background.
+                        var logs = new RemoteLogs(driver);
+                        _ = Task.Run(async () =>
+                        {
+                            while (!cancellationToken.IsCancellationRequested)
+                            {
+                                await Task.Delay(TimeSpan.FromSeconds(3));
+
+                                var consoleLogs = logs.GetLog(LogType.Browser);
+                                foreach (var entry in consoleLogs)
+                                {
+                                    Console.WriteLine($"[Browser Log]: {entry.Timestamp}: {entry.Message}");
+                                }
+                            }
+                        });
+                    }
+
+                    return driver;
+                }
+                catch (Exception ex)
+                {
+                    Console.WriteLine($"Error initializing RemoteWebDriver: {ex.Message}");
+                }
+
+                attempt++;
+
+            } while (attempt < MaxAttempts);
+
+            throw new InvalidOperationException("Couldn't create a Selenium remote driver client. The server is irresponsive");
+        }
+    }
+}
diff --git a/src/Components/benchmarkapps/Wasm.Performance/Driver/Wasm.Performance.Driver.csproj b/src/Components/benchmarkapps/Wasm.Performance/Driver/Wasm.Performance.Driver.csproj
new file mode 100644
index 0000000000000000000000000000000000000000..cf35be4e007e57346443058d983a9f86f6ec6755
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/Driver/Wasm.Performance.Driver.csproj
@@ -0,0 +1,23 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <!-- Intentionally pinned this to .NET Core 3.1 since that's the supported version in the docker image -->
+    <TargetFramework>netcoreapp3.1</TargetFramework>
+
+    <UseLatestAspNetCoreReference>true</UseLatestAspNetCoreReference>
+    <OutputType>exe</OutputType>
+
+    <!-- WebDriver is not strong-named, so this test project cannot be strong named either. -->
+    <SignAssembly>false</SignAssembly>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Selenium.Support" />
+    <Reference Include="Selenium.WebDriver" />
+    <ProjectReference Include="..\..\..\Blazor\DevServer\src\Microsoft.AspNetCore.Blazor.DevServer.csproj" />
+    <ProjectReference Include="..\TestApp\Wasm.Performance.TestApp.csproj" />
+
+    <Content Include="appsettings.json" CopyToPublishDirectory="PreserveNewest" />
+  </ItemGroup>
+
+</Project>
diff --git a/src/Components/benchmarkapps/Wasm.Performance/Driver/appsettings.json b/src/Components/benchmarkapps/Wasm.Performance/Driver/appsettings.json
new file mode 100644
index 0000000000000000000000000000000000000000..bed61b254f6fa94dee1c5f94cdb87155000cc42c
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/Driver/appsettings.json
@@ -0,0 +1,8 @@
+{
+    "Logging": {
+        "IncludeScopes": false,
+        "LogLevel": {
+            "Default": "Warning"
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/Components/benchmarkapps/Wasm.Performance/README.md b/src/Components/benchmarkapps/Wasm.Performance/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..9522ecc50248cd14664bee69828dadba51b30908
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/README.md
@@ -0,0 +1,20 @@
+## Blazor WASM benchmarks
+
+These projects assist in Benchmarking Components.
+See https://github.com/aspnet/Benchmarks#benchmarks for usage guidance on using the Benchmarking tool with your application
+
+### Running the benchmarks
+
+The TestApp is a regular BlazorWASM project and can be run using `dotnet run`. The Driver is an app that connects against an existing Selenium server, and speaks the Benchmark protocol. You generally do not need to run the Driver locally, but if you were to do so, you can either start a selenium-server instance and run using `dotnet run [<selenium-server-port>]` or run it inside a Linux-based docker container.
+
+Here are the commands you would need to run it locally inside docker:
+
+1. `dotnet publish -c Release -r linux-x64 Driver/Wasm.Performance.Driver.csproj`
+2. `docker build -t blazor-local -f ./local.dockerfile . `
+3. `docker run -it blazor-local`
+
+To run the benchmark app in the Benchmark server, run
+
+```
+dotnet run -- --config aspnetcore/src/Components/benchmarkapps/Wasm.Performance/benchmarks.compose.json application.endpoints <BenchmarkServerUri> --scenario blazorwasmbenchmark
+```
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/App.razor b/src/Components/benchmarkapps/Wasm.Performance/TestApp/App.razor
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/App.razor
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/App.razor
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/BenchmarkEvent.cs b/src/Components/benchmarkapps/Wasm.Performance/TestApp/BenchmarkEvent.cs
similarity index 89%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/BenchmarkEvent.cs
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/BenchmarkEvent.cs
index bdf98fd38879bf22d301bf783a5ca61d82f551b6..81cd361dce7cfc3858514cda46bebfbf45415fff 100644
--- a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/BenchmarkEvent.cs
+++ b/src/Components/benchmarkapps/Wasm.Performance/TestApp/BenchmarkEvent.cs
@@ -3,7 +3,7 @@
 
 using Microsoft.JSInterop;
 
-namespace Microsoft.AspNetCore.Blazor.E2EPerformance
+namespace Wasm.Performance.TestApp
 {
     public static class BenchmarkEvent
     {
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Pages/Index.razor b/src/Components/benchmarkapps/Wasm.Performance/TestApp/Pages/Index.razor
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Pages/Index.razor
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/Pages/Index.razor
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Pages/Json.razor b/src/Components/benchmarkapps/Wasm.Performance/TestApp/Pages/Json.razor
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Pages/Json.razor
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/Pages/Json.razor
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Pages/RenderList.razor b/src/Components/benchmarkapps/Wasm.Performance/TestApp/Pages/RenderList.razor
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Pages/RenderList.razor
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/Pages/RenderList.razor
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Pages/_Imports.razor b/src/Components/benchmarkapps/Wasm.Performance/TestApp/Pages/_Imports.razor
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Pages/_Imports.razor
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/Pages/_Imports.razor
diff --git a/src/Components/benchmarkapps/Wasm.Performance/TestApp/Program.cs b/src/Components/benchmarkapps/Wasm.Performance/TestApp/Program.cs
new file mode 100644
index 0000000000000000000000000000000000000000..57a3697382f3a8f5f2547022ab9d3896e7e55c2c
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/TestApp/Program.cs
@@ -0,0 +1,19 @@
+// 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.Threading.Tasks;
+using Microsoft.AspNetCore.Blazor.Hosting;
+
+namespace Wasm.Performance.TestApp
+{
+    public class Program
+    {
+        public static async Task Main(string[] args)
+        {
+            var builder = WebAssemblyHostBuilder.CreateDefault(args);
+            builder.RootComponents.Add<App>("app");
+
+            await builder.Build().RunAsync();
+        }
+    }
+}
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Shared/MainLayout.razor b/src/Components/benchmarkapps/Wasm.Performance/TestApp/Shared/MainLayout.razor
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Shared/MainLayout.razor
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/Shared/MainLayout.razor
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Microsoft.AspNetCore.Blazor.E2EPerformance.csproj b/src/Components/benchmarkapps/Wasm.Performance/TestApp/Wasm.Performance.TestApp.csproj
similarity index 72%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Microsoft.AspNetCore.Blazor.E2EPerformance.csproj
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/Wasm.Performance.TestApp.csproj
index 9f796fdbcf9c564810b2c9c81b5959bbdbeae3d0..3fb5a922a3fd380bcf863a7bd739e4c9cdf2a866 100644
--- a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/Microsoft.AspNetCore.Blazor.E2EPerformance.csproj
+++ b/src/Components/benchmarkapps/Wasm.Performance/TestApp/Wasm.Performance.TestApp.csproj
@@ -1,7 +1,7 @@
-<Project Sdk="Microsoft.NET.Sdk.Web">
+<Project Sdk="Microsoft.NET.Sdk.Web">
 
   <PropertyGroup>
-    <TargetFramework>netstandard2.0</TargetFramework>
+    <TargetFramework>netstandard2.1</TargetFramework>
     <ReferenceBlazorBuildLocally>true</ReferenceBlazorBuildLocally>
     <RazorLangVersion>3.0</RazorLangVersion>
   </PropertyGroup>
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/_Imports.razor b/src/Components/benchmarkapps/Wasm.Performance/TestApp/_Imports.razor
similarity index 56%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/_Imports.razor
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/_Imports.razor
index dc263c9383a577ac41637da1d07b5921d78675ec..fef56339a95f0c04bc9f47121c5ee8822744050f 100644
--- a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/_Imports.razor
+++ b/src/Components/benchmarkapps/Wasm.Performance/TestApp/_Imports.razor
@@ -2,5 +2,5 @@
 @using Microsoft.AspNetCore.Components.Routing
 @using Microsoft.AspNetCore.Components.Web
 @using Microsoft.JSInterop
-@using Microsoft.AspNetCore.Blazor.E2EPerformance
-@using Microsoft.AspNetCore.Blazor.E2EPerformance.Shared
+@using Wasm.Performance.TestApp
+@using Wasm.Performance.TestApp.Shared
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/appStartup.js b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/appStartup.js
similarity index 71%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/appStartup.js
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/appStartup.js
index 4f11b64b0f62759392315dc65c71138f9d07a769..f506b12ee42920049a7a75162381d4ba1d27a914 100644
--- a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/appStartup.js
+++ b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/appStartup.js
@@ -10,6 +10,11 @@ group('App Startup', () => {
     } finally {
       app.dispose();
     }
+  }, {
+    descriptor: {
+      name: "blazorwasm/time-to-first-ui",
+      description: "Time to render first UI (ms)"
+    }
   });
 
 });
diff --git a/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/index.js b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..c1690cfac87d90c762d1bdf4f04a9d2749a64231
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/index.js
@@ -0,0 +1,39 @@
+import { groups, BenchmarkEvent, onBenchmarkEvent } from './lib/minibench/minibench.js';
+import { HtmlUI } from './lib/minibench/minibench.ui.js';
+import './appStartup.js';
+import './renderList.js';
+import './jsonHandling.js';
+
+new HtmlUI('E2E Performance', '#display');
+
+if (location.href.indexOf('#automated') !== -1) {
+  const query = new URLSearchParams(window.location.search);
+  const group = query.get('group');
+  const resultsUrl = query.get('resultsUrl');
+
+  groups.filter(g => !group || g.name === group).forEach(g => g.runAll());
+
+  const benchmarksResults = [];
+  onBenchmarkEvent(async (status, args) => {
+    switch (status) {
+        case BenchmarkEvent.runStarted:
+          benchmarksResults.length = 0;
+          break;
+        case BenchmarkEvent.benchmarkCompleted:
+        case BenchmarkEvent.benchmarkError:
+          console.log(`Completed benchmark ${args.name}`);
+          benchmarksResults.push(args);
+          break;
+        case BenchmarkEvent.runCompleted:
+            if (resultsUrl) {
+              await fetch(resultsUrl, {
+                method: 'post',
+                body: JSON.stringify(benchmarksResults)
+              });
+            }
+            break;
+        default:
+          throw new Error(`Unknown status: ${status}`);
+      }
+  })
+}
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/jsonHandling.js b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/jsonHandling.js
similarity index 66%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/jsonHandling.js
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/jsonHandling.js
index 4f6a31115202bca23a88bc5d295aacc8b8e81267..698eb7cee5cab8f3596b98097f7048771823886c 100644
--- a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/jsonHandling.js
+++ b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/jsonHandling.js
@@ -16,22 +16,47 @@ group('JSON handling', () => {
   teardown(() => app.dispose());
 
   benchmark('Serialize 1kb', () =>
-    benchmarkJson(app, '#serialize-small', '#serialized-length', 935));
+    benchmarkJson(app, '#serialize-small', '#serialized-length', 935), {
+      descriptor: {
+        name: 'blazorwasm/jsonserialize-1kb',
+        description: 'Serialize JSON 1kb - Time in ms'
+      }
+    });
 
   benchmark('Serialize 340kb', () =>
-    benchmarkJson(app, '#serialize-large', '#serialized-length', 339803));
+    benchmarkJson(app, '#serialize-large', '#serialized-length', 339803), {
+      descriptor: {
+        name: 'blazorwasm/jsonserialize-340kb',
+        description: 'Serialize JSON 340kb - Time in ms'
+      }
+    });
 
   benchmark('Deserialize 1kb', () =>
-    benchmarkJson(app, '#deserialize-small', '#deserialized-count', 5));
+    benchmarkJson(app, '#deserialize-small', '#deserialized-count', 5), {
+      descriptor: {
+        name: 'blazorwasm/jsondeserialize-1kb',
+        description: 'Deserialize JSON 1kb - Time in ms'
+      }
+    });
 
   benchmark('Deserialize 340kb', () =>
-    benchmarkJson(app, '#deserialize-large', '#deserialized-count', 1365));
+    benchmarkJson(app, '#deserialize-large', '#deserialized-count', 1365), {
+      descriptor: {
+        name: 'blazorwasm/jsondeserialize-340kb',
+        description: 'Deserialize JSON 340kb - Time in ms'
+      }
+    });
 
   benchmark('Serialize 340kb (JavaScript)', () => {
     const json = JSON.stringify(largeObjectToSerialize);
     if (json.length !== 339803) {
       throw new Error(`Incorrect length: ${json.length}`);
     }
+  }, {
+    descriptor: {
+      name: 'blazorwasm/jsonserialize-javascript-340kb',
+      description: 'Serialize JSON 340kb using JavaScript - Time in ms'
+    }
   });
 
   benchmark('Deserialize 340kb (JavaScript)', () => {
@@ -39,6 +64,11 @@ group('JSON handling', () => {
     if (parsed.name !== 'CEO - Subordinate 0') {
       throw new Error('Incorrect result');
     }
+  }, {
+    descriptor: {
+      name: 'blazorwasm/jsondeserialize-javascript-340kb',
+      description: 'Deserialize JSON 340kb using JavaScript - Time in ms'
+    }
   });
 });
 
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/jsonHandlingData.js b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/jsonHandlingData.js
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/jsonHandlingData.js
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/jsonHandlingData.js
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/bootstrap/bootstrap.min.css b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/lib/bootstrap.min.css
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/bootstrap/bootstrap.min.css
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/lib/bootstrap.min.css
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/lib/minibench/README.md b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/lib/minibench/README.md
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/lib/minibench/README.md
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/lib/minibench/README.md
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/lib/minibench/minibench.js b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/lib/minibench/minibench.js
similarity index 57%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/lib/minibench/minibench.js
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/lib/minibench/minibench.js
index 82144199822c69be2fe94e6dfd04d1a996a715f8..f331321ad0fb9437aac25524a3247101be0f89cf 100644
--- a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/lib/minibench/minibench.js
+++ b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/lib/minibench/minibench.js
@@ -66,7 +66,7 @@ window.addEventListener('message', evt => {
     To work around browsers' current nonsupport for high-resolution timers
     (since Spectre etc.), the approach used here is to group executions into
     blocks of roughly fixed duration.
-    
+
     - In each block, we execute the test code as many times as we can until
       the end of the block duration, without even yielding the thread if
       it's a synchronous call. We count how many executions completed. It
@@ -82,7 +82,7 @@ window.addEventListener('message', evt => {
       during which there was no unrelated GC cycle or other background contention.
     - We keep running blocks until some larger timeout occurs *and* we've done
       at least some minimum number of executions.
-    
+
     Note that this approach does *not* allow for per-execution setup/teardown
     logic whose timing is separated from the code under test. Because of the
     low timer precision, there would be no way to separate the setup duration
@@ -165,7 +165,7 @@ class Benchmark extends EventEmitter {
         this._group = group;
         this.name = name;
         this._fn = fn;
-        this._options = options;
+        this._options = options || {};
         this._state = { status: BenchmarkStatus.idle };
     }
 
@@ -174,10 +174,23 @@ class Benchmark extends EventEmitter {
     }
 
     run(runOptions) {
+        if (reportBenchmarkEvent) {
+            const areAllIdle = groups.reduce(
+                (prev, next) => prev && next.status === BenchmarkStatus.idle,
+                true
+            );
+
+            if (areAllIdle) {
+                // This is the first test being run from the idle state
+                reportBenchmarkEvent(BenchmarkEvent.runStarted);
+            }
+        }
+
         this._currentRunWasAborted = false;
         if (this._state.status === BenchmarkStatus.idle) {
             this._updateState({ status: BenchmarkStatus.queued });
             this.workQueueCancelHandle = addToWorkQueue(async () => {
+
                 try {
                     if (!(runOptions && runOptions.skipGroupSetup)) {
                         await this._group.runSetup();
@@ -192,10 +205,23 @@ class Benchmark extends EventEmitter {
                         await this._group.runTeardown();
                     }
 
+                    reportBenchmarkEvent(BenchmarkEvent.benchmarkCompleted, {
+                        name: this.name,
+                        success: true,
+                        numExecutions: this._state.numExecutions,
+                        duration: this._state.estimatedExecutionDurationMs,
+                        descriptor: this._options.descriptor
+                    });
+
                     this._updateState({ status: BenchmarkStatus.idle });
                 } catch (ex) {
                     this._updateState({ status: BenchmarkStatus.error });
                     console.error(ex);
+                    reportBenchmarkEvent(BenchmarkEvent.benchmarkError, {
+                        name: this.name,
+                        success: false,
+                        descriptor: this._options.descriptor
+                    });
                 }
             });
         }
@@ -237,6 +263,13 @@ const BenchmarkStatus = {
     error: 3,
 };
 
+const BenchmarkEvent = {
+    runStarted: 0,
+    benchmarkCompleted : 1,
+    benchmarkError: 2,
+    runCompleted: 3,
+}
+
 class Group extends EventEmitter {
     constructor(name) {
         super();
@@ -279,6 +312,7 @@ class Group extends EventEmitter {
 }
 
 const groups = [];
+let reportBenchmarkEvent = () => {};
 
 function group(name, configure) {
     groups.push(new Group(name));
@@ -298,184 +332,21 @@ function teardown(fn) {
     groups[groups.length - 1].teardown = fn;
 }
 
-class BenchmarkDisplay {
-    constructor(htmlUi, benchmark) {
-        this.benchmark = benchmark;
-        this.elem = document.createElement('tr');
-        
-        const headerCol = this.elem.appendChild(document.createElement('th'));
-        headerCol.className = 'pl-4';
-        headerCol.textContent = benchmark.name;
-        headerCol.setAttribute('scope', 'row');
-
-        const progressCol = this.elem.appendChild(document.createElement('td'));
-        this.numExecutionsText = progressCol.appendChild(document.createTextNode(''));
-
-        const timingCol = this.elem.appendChild(document.createElement('td'));
-        this.executionDurationText = timingCol.appendChild(document.createElement('span'));
-        
-        const runCol = this.elem.appendChild(document.createElement('td'));
-        runCol.className = 'pr-4';
-        runCol.setAttribute('align', 'right');
-        this.runButton = document.createElement('a');
-        this.runButton.className = 'run-button';
-        runCol.appendChild(this.runButton);
-        this.runButton.textContent = 'Run';
-        this.runButton.onclick = evt => {
-            evt.preventDefault();
-            this.benchmark.run(htmlUi.globalRunOptions);
-        };
+function onBenchmarkEvent(fn) {
+    reportBenchmarkEvent = fn;
 
-        benchmark.on('changed', state => this.updateDisplay(state));
-        this.updateDisplay(this.benchmark.state);
-    }
+    groups.forEach(group$$1 => {
+        group$$1.on('changed', () => {
+            const areAllIdle = groups.reduce(
+                (prev, next) => prev && next.status === BenchmarkStatus.idle,
+                true
+            );
 
-    updateDisplay(state) {
-        const benchmark = this.benchmark;
-        this.elem.className = rowClass(state.status);
-        this.runButton.textContent = runButtonText(state.status);
-        this.numExecutionsText.textContent = state.numExecutions
-            ? `Executions: ${state.numExecutions}` : '';
-        this.executionDurationText.innerHTML = state.estimatedExecutionDurationMs
-            ? `Duration: <b>${parseFloat(state.estimatedExecutionDurationMs.toPrecision(3))}ms</b>` : '';
-        if (state.status === BenchmarkStatus.idle) {
-            this.runButton.setAttribute('href', '');
-        } else {
-            this.runButton.removeAttribute('href');
-            if (state.status === BenchmarkStatus.error) {
-                this.numExecutionsText.textContent = 'Error - see console';
+            if (areAllIdle) {
+                fn(BenchmarkEvent.runCompleted);
             }
-        }
-    }
-}
-
-function runButtonText(status) {
-    switch (status) {
-        case BenchmarkStatus.idle:
-        case BenchmarkStatus.error:
-            return 'Run';
-        case BenchmarkStatus.queued:
-            return 'Waiting...';
-        case BenchmarkStatus.running:
-            return 'Running...';
-        default:
-            throw new Error(`Unknown status: ${status}`);
-    }
-}
-
-function rowClass(status) {
-    switch (status) {
-        case BenchmarkStatus.idle:
-            return 'benchmark-idle';
-        case BenchmarkStatus.queued:
-            return 'benchmark-waiting';
-        case BenchmarkStatus.running:
-            return 'benchmark-running';
-        case BenchmarkStatus.error:
-            return 'benchmark-error';
-        default:
-            throw new Error(`Unknown status: ${status}`);
-    }
-}
-
-class GroupDisplay {
-    constructor(htmlUi, group) {
-        this.group = group;
-
-        this.elem = document.createElement('div');
-        this.elem.className = 'my-3 py-2 bg-white rounded shadow-sm';
-        
-        const headerContainer = this.elem.appendChild(document.createElement('div'));
-        headerContainer.className = 'd-flex align-items-baseline px-4';
-        const header = headerContainer.appendChild(document.createElement('h5'));
-        header.className = 'py-2';
-        header.textContent = group.name;
-
-        this.runButton = document.createElement('a');
-        this.runButton.className = 'ml-auto run-button';
-        this.runButton.setAttribute('href', '');
-        headerContainer.appendChild(this.runButton);
-        this.runButton.textContent = 'Run all';
-        this.runButton.onclick = evt => {
-            evt.preventDefault();
-            group.runAll(htmlUi.globalRunOptions);
-        };
-
-        const table = this.elem.appendChild(document.createElement('table'));
-        table.className = 'table mb-0 benchmarks';
-        const tbody = table.appendChild(document.createElement('tbody'));
-
-        group.benchmarks.forEach(benchmark => {
-            const benchmarkDisplay = new BenchmarkDisplay(htmlUi, benchmark);
-            tbody.appendChild(benchmarkDisplay.elem);
         });
-
-        group.on('changed', () => this.updateDisplay());
-        this.updateDisplay();
-    }
-
-    updateDisplay() {
-        const canRun = this.group.status === BenchmarkStatus.idle;
-        this.runButton.style.display = canRun ? 'block' : 'none';
-    }
-}
-
-class HtmlUI {
-    constructor(title, selector) {
-        this.containerElement = document.querySelector(selector);
-
-        const headerDiv = this.containerElement.appendChild(document.createElement('div'));
-        headerDiv.className = 'd-flex align-items-center';
-
-        const header = headerDiv.appendChild(document.createElement('h2'));
-        header.className = 'mx-3 flex-grow-1';
-        header.textContent = title;
-
-        const verifyCheckboxLabel = document.createElement('label');
-        verifyCheckboxLabel.className = 'ml-auto mr-5';
-        headerDiv.appendChild(verifyCheckboxLabel);
-        this.verifyCheckbox = verifyCheckboxLabel.appendChild(document.createElement('input'));
-        this.verifyCheckbox.type = 'checkbox';
-        this.verifyCheckbox.className = 'mr-2';
-        verifyCheckboxLabel.appendChild(document.createTextNode('Verify only'));
-
-        this.runButton = document.createElement('button');
-        this.runButton.className = 'btn btn-success ml-auto px-4 run-button';
-        headerDiv.appendChild(this.runButton);
-        this.runButton.textContent = 'Run all';
-        this.runButton.onclick = () => {
-            groups.forEach(g => g.runAll(this.globalRunOptions));
-        };
-
-        this.stopButton = document.createElement('button');
-        this.stopButton.className = 'btn btn-danger ml-auto px-4 stop-button';
-        headerDiv.appendChild(this.stopButton);
-        this.stopButton.textContent = 'Stop';
-        this.stopButton.onclick = () => {
-            groups.forEach(g => g.stopAll());
-        };
-
-        groups.forEach(group$$1 => {
-            const groupDisplay = new GroupDisplay(this, group$$1);
-            this.containerElement.appendChild(groupDisplay.elem);
-            group$$1.on('changed', () => this.updateDisplay());
-        });
-
-        this.updateDisplay();
-    }
-
-    updateDisplay() {
-        const areAllIdle = groups.reduce(
-            (prev, next) => prev && next.status === BenchmarkStatus.idle,
-            true
-        );
-        this.runButton.style.display = areAllIdle ? 'block' : 'none';
-        this.stopButton.style.display = areAllIdle ? 'none' : 'block';
-    }
-
-    get globalRunOptions() {
-        return { verifyOnly: this.verifyCheckbox.checked };
-    }
+      });
 }
 
 /**
@@ -483,4 +354,4 @@ class HtmlUI {
  * https://github.com/SteveSanderson/minibench
  */
 
-export { group, benchmark, setup, teardown, HtmlUI };
+export { groups, group, benchmark, setup, teardown, onBenchmarkEvent, BenchmarkEvent, BenchmarkStatus };
diff --git a/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/lib/minibench/minibench.ui.js b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/lib/minibench/minibench.ui.js
new file mode 100644
index 0000000000000000000000000000000000000000..4384b7660b74d5fce7d667e8b09c90f37f1c344e
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/lib/minibench/minibench.ui.js
@@ -0,0 +1,191 @@
+/** minibench - https://github.com/SteveSanderson/minibench */
+
+import { groups, BenchmarkStatus } from './minibench.js';
+
+class BenchmarkDisplay {
+  constructor(htmlUi, benchmark) {
+    this.benchmark = benchmark;
+    this.elem = document.createElement('tr');
+
+    const headerCol = this.elem.appendChild(document.createElement('th'));
+    headerCol.className = 'pl-4';
+    headerCol.textContent = benchmark.name;
+    headerCol.setAttribute('scope', 'row');
+
+    const progressCol = this.elem.appendChild(document.createElement('td'));
+    this.numExecutionsText = progressCol.appendChild(document.createTextNode(''));
+
+    const timingCol = this.elem.appendChild(document.createElement('td'));
+    this.executionDurationText = timingCol.appendChild(document.createElement('span'));
+
+    const runCol = this.elem.appendChild(document.createElement('td'));
+    runCol.className = 'pr-4';
+    runCol.setAttribute('align', 'right');
+    this.runButton = document.createElement('a');
+    this.runButton.className = 'run-button';
+    runCol.appendChild(this.runButton);
+    this.runButton.textContent = 'Run';
+    this.runButton.onclick = evt => {
+      evt.preventDefault();
+      this.benchmark.run(htmlUi.globalRunOptions);
+    };
+
+    benchmark.on('changed', state => this.updateDisplay(state));
+    this.updateDisplay(this.benchmark.state);
+  }
+
+  updateDisplay(state) {
+    const benchmark = this.benchmark;
+    this.elem.className = rowClass(state.status);
+    this.runButton.textContent = runButtonText(state.status);
+    this.numExecutionsText.textContent = state.numExecutions
+      ? `Executions: ${state.numExecutions}` : '';
+    this.executionDurationText.innerHTML = state.estimatedExecutionDurationMs
+      ? `Duration: <b>${parseFloat(state.estimatedExecutionDurationMs.toPrecision(3))}ms</b>` : '';
+    if (state.status === BenchmarkStatus.idle) {
+      this.runButton.setAttribute('href', '');
+    } else {
+      this.runButton.removeAttribute('href');
+      if (state.status === BenchmarkStatus.error) {
+        this.numExecutionsText.textContent = 'Error - see console';
+      }
+    }
+  }
+}
+
+function runButtonText(status) {
+  switch (status) {
+    case BenchmarkStatus.idle:
+    case BenchmarkStatus.error:
+      return 'Run';
+    case BenchmarkStatus.queued:
+      return 'Waiting...';
+    case BenchmarkStatus.running:
+      return 'Running...';
+    default:
+      throw new Error(`Unknown status: ${status}`);
+  }
+}
+
+function rowClass(status) {
+  switch (status) {
+    case BenchmarkStatus.idle:
+      return 'benchmark-idle';
+    case BenchmarkStatus.queued:
+      return 'benchmark-waiting';
+    case BenchmarkStatus.running:
+      return 'benchmark-running';
+    case BenchmarkStatus.error:
+      return 'benchmark-error';
+    default:
+      throw new Error(`Unknown status: ${status}`);
+  }
+}
+
+class GroupDisplay {
+  constructor(htmlUi, group) {
+    this.group = group;
+
+    this.elem = document.createElement('div');
+    this.elem.className = 'my-3 py-2 bg-white rounded shadow-sm';
+
+    const headerContainer = this.elem.appendChild(document.createElement('div'));
+    headerContainer.className = 'd-flex align-items-baseline px-4';
+    const header = headerContainer.appendChild(document.createElement('h5'));
+    header.className = 'py-2';
+    header.textContent = group.name;
+
+    this.runButton = document.createElement('a');
+    this.runButton.className = 'ml-auto run-button';
+    this.runButton.setAttribute('href', '');
+    headerContainer.appendChild(this.runButton);
+    this.runButton.textContent = 'Run all';
+    this.runButton.onclick = evt => {
+      evt.preventDefault();
+      group.runAll(htmlUi.globalRunOptions);
+    };
+
+    const table = this.elem.appendChild(document.createElement('table'));
+    table.className = 'table mb-0 benchmarks';
+    const tbody = table.appendChild(document.createElement('tbody'));
+
+    group.benchmarks.forEach(benchmark => {
+      const benchmarkDisplay = new BenchmarkDisplay(htmlUi, benchmark);
+      tbody.appendChild(benchmarkDisplay.elem);
+    });
+
+    group.on('changed', () => this.updateDisplay());
+    this.updateDisplay();
+  }
+
+  updateDisplay() {
+    const canRun = this.group.status === BenchmarkStatus.idle;
+    this.runButton.style.display = canRun ? 'block' : 'none';
+  }
+}
+
+class HtmlUI {
+  constructor(title, selector) {
+    this.containerElement = document.querySelector(selector);
+
+    const headerDiv = this.containerElement.appendChild(document.createElement('div'));
+    headerDiv.className = 'd-flex align-items-center';
+
+    const header = headerDiv.appendChild(document.createElement('h2'));
+    header.className = 'mx-3 flex-grow-1';
+    header.textContent = title;
+
+    const verifyCheckboxLabel = document.createElement('label');
+    verifyCheckboxLabel.className = 'ml-auto mr-5';
+    headerDiv.appendChild(verifyCheckboxLabel);
+    this.verifyCheckbox = verifyCheckboxLabel.appendChild(document.createElement('input'));
+    this.verifyCheckbox.type = 'checkbox';
+    this.verifyCheckbox.className = 'mr-2';
+    verifyCheckboxLabel.appendChild(document.createTextNode('Verify only'));
+
+    this.runButton = document.createElement('button');
+    this.runButton.className = 'btn btn-success ml-auto px-4 run-button';
+    headerDiv.appendChild(this.runButton);
+    this.runButton.textContent = 'Run all';
+    this.runButton.setAttribute('id', 'runAll');
+    this.runButton.onclick = () => {
+      groups.forEach(g => g.runAll(this.globalRunOptions));
+    };
+
+    this.stopButton = document.createElement('button');
+    this.stopButton.className = 'btn btn-danger ml-auto px-4 stop-button';
+    headerDiv.appendChild(this.stopButton);
+    this.stopButton.textContent = 'Stop';
+    this.stopButton.onclick = () => {
+      groups.forEach(g => g.stopAll());
+    };
+
+    groups.forEach(group$$1 => {
+      const groupDisplay = new GroupDisplay(this, group$$1);
+      this.containerElement.appendChild(groupDisplay.elem);
+      group$$1.on('changed', () => this.updateDisplay());
+    });
+
+    this.updateDisplay();
+  }
+
+  updateDisplay() {
+    const areAllIdle = groups.reduce(
+      (prev, next) => prev && next.status === BenchmarkStatus.idle,
+      true
+    );
+    this.runButton.style.display = areAllIdle ? 'block' : 'none';
+    this.stopButton.style.display = areAllIdle ? 'none' : 'block';;
+  }
+
+  get globalRunOptions() {
+    return { verifyOnly: this.verifyCheckbox.checked };
+  }
+}
+
+/**
+ * minibench
+ * https://github.com/SteveSanderson/minibench
+ */
+
+export { HtmlUI };
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/lib/minibench/style.css b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/lib/minibench/style.css
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/lib/minibench/style.css
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/lib/minibench/style.css
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/renderList.js b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/renderList.js
similarity index 74%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/renderList.js
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/renderList.js
index 68bf32d747c10f020ba3dd32f029597a3a6e3689..32a1bb8a7282cff9d4719714ecd4fc55933c1495 100644
--- a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/renderList.js
+++ b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/renderList.js
@@ -16,9 +16,24 @@ group('Rendering list', () => {
     app.dispose();
   });
 
-  benchmark('Render 10 items', () => measureRenderList(app, 10));
-  benchmark('Render 100 items', () => measureRenderList(app, 100));
-  benchmark('Render 1000 items', () => measureRenderList(app, 1000));
+  benchmark('Render 10 items', () => measureRenderList(app, 10), {
+    descriptor: {
+      name: 'blazorwasm/render-10-items',
+      description: 'Time to render 10 item list (ms)'
+    }
+  });
+  benchmark('Render 100 items', () => measureRenderList(app, 100), {
+    descriptor: {
+      name: 'blazorwasm/render-100-items',
+      description: 'Time to render 100 item list (ms)'
+    }
+  });
+  benchmark('Render 1000 items', () => measureRenderList(app, 1000), {
+    descriptor: {
+      name: 'blazorwasm/render-1000-items',
+      description: 'Time to render 1000 item list (ms)'
+    }
+  });
 
 });
 
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/util/BenchmarkEvents.js b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/util/BenchmarkEvents.js
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/util/BenchmarkEvents.js
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/util/BenchmarkEvents.js
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/util/BlazorApp.js b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/util/BlazorApp.js
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/util/BlazorApp.js
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/util/BlazorApp.js
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/util/DOM.js b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/util/DOM.js
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/util/DOM.js
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/benchmarks/util/DOM.js
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/blazor-frame.html b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/blazor-frame.html
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/blazor-frame.html
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/blazor-frame.html
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/index.html b/src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/index.html
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/index.html
rename to src/Components/benchmarkapps/Wasm.Performance/TestApp/wwwroot/index.html
diff --git a/src/Components/benchmarkapps/Wasm.Performance/benchmarks.compose.json b/src/Components/benchmarkapps/Wasm.Performance/benchmarks.compose.json
new file mode 100644
index 0000000000000000000000000000000000000000..442346e79cd788496980f52fb6b2d6753d5503e7
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/benchmarks.compose.json
@@ -0,0 +1,24 @@
+{
+  "$schema": "https://raw.githubusercontent.com/aspnet/Benchmarks/master/src/BenchmarksDriver2/benchmarks.schema.json",
+  "scenarios": {
+    "blazorwasmbenchmark": {
+      "application": {
+        "job": "blazorwasmbenchmark"
+      }
+    }
+  },
+  "jobs": {
+    "blazorwasmbenchmark": {
+      "source": {
+        "repository": "https://github.com/dotnet/AspNetCore.git",
+        "branchOrCommit": "blazor-wasm",
+        "dockerfile": "src/Components/benchmarkapps/Wasm.Performance/dockerfile"
+      },
+      "buildArguments": [
+        "gitBranch=blazor-wasm"
+      ],
+      "waitForExit": true,
+      "readyStateText": "Application started."
+    }
+  }
+}
diff --git a/src/Components/benchmarkapps/Wasm.Performance/dockerfile b/src/Components/benchmarkapps/Wasm.Performance/dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..69f27a9212314354b7443a6d9d9248e9971e0a21
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/dockerfile
@@ -0,0 +1,32 @@
+FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
+
+ARG DEBIAN_FRONTEND=noninteractive
+
+# Setup for nodejs
+RUN curl -sL https://deb.nodesource.com/setup_13.x | bash -
+
+RUN apt-get update \
+    && apt-get install -y --no-install-recommends \
+    libunwind-dev \
+    nodejs \
+    git
+
+ARG gitBranch=blazor-wasm
+
+WORKDIR /src
+ADD https://api.github.com/repos/dotnet/aspnetcore/git/ref/heads/${gitBranch} /aspnetcore.commit
+
+RUN git init \
+    && git fetch https://github.com/aspnet/aspnetcore ${gitBranch} \
+    && git reset --hard FETCH_HEAD \
+    && git submodule update --init
+
+RUN dotnet publish -c Release -r linux-x64 -o /app ./src/Components/benchmarkapps/Wasm.Performance/Driver/Wasm.Performance.Driver.csproj
+RUN chmod +x /app/Wasm.Performance.Driver
+
+WORKDIR /app
+FROM selenium/standalone-chrome:3.141.59-mercury as final
+COPY --from=build ./app ./
+COPY ./exec.sh ./
+
+ENTRYPOINT [ "bash", "./exec.sh" ]
diff --git a/src/Components/benchmarkapps/Wasm.Performance/exec.sh b/src/Components/benchmarkapps/Wasm.Performance/exec.sh
new file mode 100644
index 0000000000000000000000000000000000000000..bae38ae1e166799819ae4653c37c1c242afa9b46
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/exec.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+
+/opt/bin/start-selenium-standalone.sh&
+./Wasm.Performance.Driver
+
diff --git a/src/Components/benchmarkapps/Wasm.Performance/local.dockerfile b/src/Components/benchmarkapps/Wasm.Performance/local.dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..188bc5dc5a81c264af59c3df1d20cd7df20387b1
--- /dev/null
+++ b/src/Components/benchmarkapps/Wasm.Performance/local.dockerfile
@@ -0,0 +1,7 @@
+FROM selenium/standalone-chrome:3.141.59-mercury as final
+
+WORKDIR /app
+COPY ./Driver/bin/Release/netcoreapp3.1/linux-x64/publish ./
+COPY ./exec.sh ./
+
+ENTRYPOINT [ "bash", "./exec.sh" ]
diff --git a/src/Components/test/E2ETest/Infrastructure/AssemblyInfo.AssemblyFixtures.cs b/src/Components/test/E2ETest/Infrastructure/AssemblyInfo.AssemblyFixtures.cs
index 9392d18aa9d09066e393c701dd51f279d7ad6489..4f2868269a6b1cafb69bd653370a7c10ce7cccc6 100644
--- a/src/Components/test/E2ETest/Infrastructure/AssemblyInfo.AssemblyFixtures.cs
+++ b/src/Components/test/E2ETest/Infrastructure/AssemblyInfo.AssemblyFixtures.cs
@@ -6,3 +6,4 @@ using Xunit;
 
 [assembly: TestFramework("Microsoft.AspNetCore.E2ETesting.XunitTestFrameworkWithAssemblyFixture", "Microsoft.AspNetCore.Components.E2ETests")]
 [assembly: AssemblyFixture(typeof(SeleniumStandaloneServer))]
+[assembly: AssemblyFixture(typeof(SauceConnectServer))]
diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs
index 98ad897bb9a8ac97ccb8fa29476a83334b4fabc4..abb11bc748f847fdf4dc3241b9717c7f19e220bc 100644
--- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs
+++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/AspNetSiteServerFixture.cs
@@ -6,6 +6,7 @@ using System.Collections.Generic;
 using System.IO;
 using System.Linq;
 using System.Reflection;
+using Microsoft.AspNetCore.E2ETesting;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.Extensions.Hosting;
 
@@ -34,9 +35,15 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
             var assembly = ApplicationAssembly ?? BuildWebHostMethod.Method.DeclaringType.Assembly;
             var sampleSitePath = FindSampleOrTestSitePath(assembly.FullName);
 
+            var host = "127.0.0.1";
+            if (E2ETestOptions.Instance.SauceTest)
+            {
+                host = E2ETestOptions.Instance.Sauce.HostName;
+            }
+
             return BuildWebHostMethod(new[]
             {
-                "--urls", "http://127.0.0.1:0",
+                "--urls", $"http://{host}:0",
                 "--contentroot", sampleSitePath,
                 "--environment", Environment.ToString(),
             }.Concat(AdditionalArguments).ToArray());
diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/DevHostServerFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/DevHostServerFixture.cs
index d487598539d344af8a5e99ed4f57d94ba130b096..d09118ef3f5d8658b03fa99f05d5d08e4e669d5d 100644
--- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/DevHostServerFixture.cs
+++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/DevHostServerFixture.cs
@@ -1,6 +1,7 @@
 // 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 Microsoft.AspNetCore.E2ETesting;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Hosting.Server;
 using Microsoft.AspNetCore.Http.Features;
@@ -24,9 +25,15 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
             ContentRoot = FindSampleOrTestSitePath(
                 typeof(TProgram).Assembly.FullName);
 
+            var host = "127.0.0.1";
+            if (E2ETestOptions.Instance.SauceTest)
+            {
+                host = E2ETestOptions.Instance.Sauce.HostName;
+            }
+
             var args = new List<string>
             {
-                "--urls", "http://127.0.0.1:0",
+                "--urls", $"http://{host}:0",
                 "--contentroot", ContentRoot,
                 "--pathbase", PathBase,
                 "--applicationpath", typeof(TProgram).Assembly.Location,
diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/ServerFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/ServerFixture.cs
index 742a83f12f8fd0fc050967f1372f26d9b42c2230..67c164543caf4b6767fa1b4f700921c64791ddcd 100644
--- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/ServerFixture.cs
+++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/ServerFixture.cs
@@ -8,6 +8,7 @@ using System.Linq;
 using System.Reflection;
 using System.Runtime.ExceptionServices;
 using System.Threading;
+using Microsoft.AspNetCore.E2ETesting;
 
 namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
 {
@@ -22,7 +23,15 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
         public ServerFixture()
         {
             _rootUriInitializer = new Lazy<Uri>(() =>
-                new Uri(StartAndGetRootUri()));
+            {
+                var uri = new Uri(StartAndGetRootUri());
+                if (E2ETestOptions.Instance.SauceTest)
+                {
+                    uri = new UriBuilder(uri.Scheme, E2ETestOptions.Instance.Sauce.HostName, uri.Port).Uri;
+                }
+
+                return uri;
+            });
         }
 
         public abstract void Dispose();
diff --git a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/StaticSiteServerFixture.cs b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/StaticSiteServerFixture.cs
index 899f165e936a2a5192ccf1112e527e8844fce515..096e9315feb5a7c7f6b4d9bd8d8638e70bcc2231 100644
--- a/src/Components/test/E2ETest/Infrastructure/ServerFixtures/StaticSiteServerFixture.cs
+++ b/src/Components/test/E2ETest/Infrastructure/ServerFixtures/StaticSiteServerFixture.cs
@@ -4,6 +4,7 @@
 using System;
 using System.IO;
 using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.E2ETesting;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.Extensions.Hosting;
 
@@ -26,13 +27,19 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures
 
             var sampleSitePath = FindSampleOrTestSitePath(SampleSiteName);
 
+            var host = "127.0.0.1";
+            if (E2ETestOptions.Instance.SauceTest)
+            {
+                host = E2ETestOptions.Instance.Sauce.HostName;
+            }
+
             return new HostBuilder()
                 .ConfigureWebHost(webHostBuilder => webHostBuilder
                     .UseKestrel()
                     .UseContentRoot(sampleSitePath)
                     .UseWebRoot(string.Empty)
                     .UseStartup<StaticSiteStartup>()
-                    .UseUrls("http://127.0.0.1:0"))
+                    .UseUrls($"http://{host}:0"))
                 .Build();
         }
 
diff --git a/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj b/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj
index 97c30edc5da6739361b5672b153544e7a66583d9..30eac8a9a17d386815d6df405e77f1d78f86940d 100644
--- a/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj
+++ b/src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj
@@ -7,6 +7,9 @@
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
     <TestGroupName>Components.E2ETests</TestGroupName>
 
+    <!-- This is so that we add the FrameworkReference to Microsoft.AspNetCore.App -->
+    <UseLatestAspNetCoreReference>true</UseLatestAspNetCoreReference>
+
     <!-- https://github.com/aspnet/AspNetCore/issues/6857 -->
     <BuildHelixPayload>false</BuildHelixPayload>
 
@@ -37,7 +40,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <ProjectReference Include="..\..\Blazor\testassets\Microsoft.AspNetCore.Blazor.E2EPerformance\Microsoft.AspNetCore.Blazor.E2EPerformance.csproj" />
+    <ProjectReference Include="..\..\benchmarkapps\Wasm.Performance\TestApp\Wasm.Performance.TestApp.csproj" />
     <ProjectReference Include="..\..\Blazor\testassets\HostedInAspNet.Client\HostedInAspNet.Client.csproj" />
     <ProjectReference Include="..\..\Blazor\testassets\HostedInAspNet.Server\HostedInAspNet.Server.csproj" />
     <ProjectReference Include="..\..\Blazor\testassets\MonoSanityClient\MonoSanityClient.csproj" />
diff --git a/src/Components/test/E2ETest/Tests/ErrorNotificationServerSideTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/ServerErrorNotificationTest.cs
similarity index 85%
rename from src/Components/test/E2ETest/Tests/ErrorNotificationServerSideTest.cs
rename to src/Components/test/E2ETest/ServerExecutionTests/ServerErrorNotificationTest.cs
index 6d2e860573752e50e25db27d1528dc7388951990..77b7da75d65a21e2562820ede45f29dabed7c582 100644
--- a/src/Components/test/E2ETest/Tests/ErrorNotificationServerSideTest.cs
+++ b/src/Components/test/E2ETest/ServerExecutionTests/ServerErrorNotificationTest.cs
@@ -5,16 +5,15 @@ using BasicTestApp;
 using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
 using Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests;
 using Microsoft.AspNetCore.E2ETesting;
-using OpenQA.Selenium;
 using Xunit;
 using Xunit.Abstractions;
 
 namespace Microsoft.AspNetCore.Components.E2ETest.Tests
 {
     [Collection("ErrorNotification")] // When the clientside and serverside tests run together it seems to cause failures, possibly due to connection lose on exception.
-    public class ErrorNotificationServerSideTest : ErrorNotificationClientSideTest
+    public class ServerErrorNotificationTest : ErrorNotificationTest
     {
-        public ErrorNotificationServerSideTest(
+        public ServerErrorNotificationTest(
             BrowserFixture browserFixture,
             ToggleExecutionModeServerFixture<Program> serverFixture,
             ITestOutputHelper output)
diff --git a/src/Components/test/E2ETest/Tests/ErrorNotificationClientSideTest.cs b/src/Components/test/E2ETest/Tests/ErrorNotificationTest.cs
similarity index 94%
rename from src/Components/test/E2ETest/Tests/ErrorNotificationClientSideTest.cs
rename to src/Components/test/E2ETest/Tests/ErrorNotificationTest.cs
index 7c0705acdefd28bb2876c2e93911461ee73933cf..883d1bc5ab21c74f7be843ae03c627aaa8b69d15 100644
--- a/src/Components/test/E2ETest/Tests/ErrorNotificationClientSideTest.cs
+++ b/src/Components/test/E2ETest/Tests/ErrorNotificationTest.cs
@@ -13,9 +13,9 @@ using Xunit.Abstractions;
 namespace Microsoft.AspNetCore.Components.E2ETest.Tests
 {
     [Collection("ErrorNotification")] // When the clientside and serverside tests run together it seems to cause failures, possibly due to connection lose on exception.
-    public class ErrorNotificationClientSideTest : ServerTestBase<ToggleExecutionModeServerFixture<Program>>
+    public class ErrorNotificationTest : ServerTestBase<ToggleExecutionModeServerFixture<Program>>
     {
-        public ErrorNotificationClientSideTest(
+        public ErrorNotificationTest(
             BrowserFixture browserFixture,
             ToggleExecutionModeServerFixture<Program> serverFixture,
             ITestOutputHelper output)
diff --git a/src/Components/test/E2ETest/Tests/MonoSanityTest.cs b/src/Components/test/E2ETest/Tests/MonoSanityTest.cs
index 1fb1b26ec903c6f6c1ee6693d74d7827dd344f64..b8db27fd2b211a43ebb4695ce6d0414bdc1345bb 100644
--- a/src/Components/test/E2ETest/Tests/MonoSanityTest.cs
+++ b/src/Components/test/E2ETest/Tests/MonoSanityTest.cs
@@ -74,14 +74,6 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
             Assert.Contains("Hello from test", GetValue(Browser, "triggerExceptionMessageStackTrace"));
         }
 
-        [Fact]
-        public void ProvidesDiagnosticIfInvokingWipedMethod()
-        {
-            Browser.FindElement(By.CssSelector("#invokeWipedMethod button")).Click();
-
-            Assert.Contains("System.NotImplementedException: Cannot invoke method because it was wiped. See stack trace for details.", GetValue(Browser, "invokeWipedMethodStackTrace"));
-        }
-
         [Fact]
         public void CanCallJavaScriptFromDotNet()
         {
diff --git a/src/Components/test/E2ETest/Tests/PerformanceTest.cs b/src/Components/test/E2ETest/Tests/PerformanceTest.cs
index 652226bf26c0001f6ac40d544d32a5573a4c9c7e..f7187a45573e8469cfe118ff64f2b212f8ad320d 100644
--- a/src/Components/test/E2ETest/Tests/PerformanceTest.cs
+++ b/src/Components/test/E2ETest/Tests/PerformanceTest.cs
@@ -13,11 +13,11 @@ using Xunit.Abstractions;
 namespace Microsoft.AspNetCore.Components.E2ETest.Tests
 {
     public class PerformanceTest
-        : ServerTestBase<DevHostServerFixture<Blazor.E2EPerformance.Program>>
+        : ServerTestBase<DevHostServerFixture<Wasm.Performance.TestApp.Program>>
     {
         public PerformanceTest(
             BrowserFixture browserFixture,
-            DevHostServerFixture<Blazor.E2EPerformance.Program> serverFixture,
+            DevHostServerFixture<Wasm.Performance.TestApp.Program> serverFixture,
             ITestOutputHelper output)
             : base(browserFixture, serverFixture, output)
         {
@@ -52,10 +52,8 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
                 () => runAllButton.Displayed || Browser.FindElements(By.CssSelector(".benchmark-error")).Any(),
                 TimeSpan.FromSeconds(60));
 
-            var finishedBenchmarks = Browser.FindElements(By.CssSelector(".benchmark-idle"));
-            var failedBenchmarks = Browser.FindElements(By.CssSelector(".benchmark-error"));
-            Assert.NotEmpty(finishedBenchmarks);
-            Assert.Empty(failedBenchmarks);
+            Browser.DoesNotExist(By.CssSelector(".benchmark-error")); // no failures
+            Browser.Exists(By.CssSelector(".benchmark-idle")); // everything's done
         }
     }
 }
diff --git a/src/Components/test/E2ETest/Tests/StandaloneAppTest.cs b/src/Components/test/E2ETest/Tests/StandaloneAppTest.cs
index db665d64c01a215be12cf251281622dd3395c16e..4cc1f57cffd45d0e81bbcfac42cb2035282d6efb 100644
--- a/src/Components/test/E2ETest/Tests/StandaloneAppTest.cs
+++ b/src/Components/test/E2ETest/Tests/StandaloneAppTest.cs
@@ -8,7 +8,6 @@ using OpenQA.Selenium;
 using OpenQA.Selenium.Support.UI;
 using System;
 using System.Linq;
-using System.Threading.Tasks;
 using Xunit;
 using Xunit.Abstractions;
 
@@ -52,7 +51,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
             // Verify we start at home, with the home link highlighted
             Assert.Equal("Hello, world!", Browser.FindElement(mainHeaderSelector).Text);
             Assert.Collection(Browser.FindElements(activeNavLinksSelector),
-                item => Assert.Equal("Home", item.Text));
+                item => Assert.Equal("Home", item.Text.Trim()));
 
             // Click on the "counter" link
             Browser.FindElement(By.LinkText("Counter")).Click();
@@ -60,13 +59,13 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
             // Verify we're now on the counter page, with that nav link (only) highlighted
             Assert.Equal("Counter", Browser.FindElement(mainHeaderSelector).Text);
             Assert.Collection(Browser.FindElements(activeNavLinksSelector),
-                item => Assert.Equal("Counter", item.Text));
+                item => Assert.Equal("Counter", item.Text.Trim()));
 
             // Verify we can navigate back to home too
             Browser.FindElement(By.LinkText("Home")).Click();
             Assert.Equal("Hello, world!", Browser.FindElement(mainHeaderSelector).Text);
             Assert.Collection(Browser.FindElements(activeNavLinksSelector),
-                item => Assert.Equal("Home", item.Text));
+                item => Assert.Equal("Home", item.Text.Trim()));
         }
 
         [Fact]
diff --git a/src/Components/test/E2ETest/Tests/StartupErrorNotificationTest.cs b/src/Components/test/E2ETest/Tests/StartupErrorNotificationTest.cs
new file mode 100644
index 0000000000000000000000000000000000000000..75359253c0a545462ef769b4623752de1eee10f5
--- /dev/null
+++ b/src/Components/test/E2ETest/Tests/StartupErrorNotificationTest.cs
@@ -0,0 +1,40 @@
+// 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.E2ETesting;
+using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
+using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
+using OpenQA.Selenium;
+using Xunit.Abstractions;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.E2ETest.Tests
+{
+    public class StartupErrorNotificationTest : ServerTestBase<DevHostServerFixture<Program>>
+    {
+        public StartupErrorNotificationTest(
+            BrowserFixture browserFixture,
+            DevHostServerFixture<Program> serverFixture,
+            ITestOutputHelper output)
+            : base(browserFixture, serverFixture, output)
+        {
+            _serverFixture.PathBase = ServerPathBase;
+        }
+
+        [Theory]
+        [InlineData(false)]
+        [InlineData(true)]
+        public void DisplaysNotificationForStartupException(bool errorIsAsync)
+        {
+            var url = $"{ServerPathBase}?error={(errorIsAsync ? "async" : "sync")}";
+
+            Navigate(url, noReload: true);
+            var errorUiElem = Browser.Exists(By.Id("blazor-error-ui"), TimeSpan.FromSeconds(10));
+            Assert.NotNull(errorUiElem);
+
+            Browser.Equal("block", () => errorUiElem.GetCssValue("display"));
+        }
+    }
+}
diff --git a/src/Components/test/E2ETest/e2eTestSettings.json b/src/Components/test/E2ETest/e2eTestSettings.json
index 809f33f04653c2413951e51dcb079b1c636cc0b2..1a7155db3091c1ca5f9513ce9fef8941a5bec94a 100644
--- a/src/Components/test/E2ETest/e2eTestSettings.json
+++ b/src/Components/test/E2ETest/e2eTestSettings.json
@@ -1,4 +1,4 @@
 {
   "DefaultWaitTimeoutInSeconds": 20,
-  "ScreenShotsPath": "../../screenshots"
+  "ScreenShotsPath": "../../screenshots",
 }
diff --git a/src/Components/test/E2ETest/package.json b/src/Components/test/E2ETest/package.json
index a84e769eb42bfe0f4dc96c8e722060bcb1b21fcc..26a767f77b4eeeae45393beaf48ac89f07971388 100644
--- a/src/Components/test/E2ETest/package.json
+++ b/src/Components/test/E2ETest/package.json
@@ -6,11 +6,18 @@
   "private": true,
   "scripts": {
     "selenium-standalone": "selenium-standalone",
-    "prepare": "selenium-standalone install"
+    "prepare": "selenium-standalone install",
+    "sauce": "ts-node ./scripts/sauce.ts"
   },
   "author": "",
   "license": "Apache-2.0",
   "dependencies": {
+    "sauce-connect-launcher": "^1.3.1",
     "selenium-standalone": "^6.15.4"
+  },
+  "devDependencies": {
+    "@types/node": "^13.1.7",
+    "ts-node": "^8.6.2",
+    "typescript": "^3.7.5"
   }
 }
diff --git a/src/Components/test/E2ETest/scripts/sauce.ts b/src/Components/test/E2ETest/scripts/sauce.ts
new file mode 100644
index 0000000000000000000000000000000000000000..395d0c1324b8a87989a903812a1d6abb4c2d31bb
--- /dev/null
+++ b/src/Components/test/E2ETest/scripts/sauce.ts
@@ -0,0 +1,82 @@
+import { EOL } from "os";
+import * as _fs from "fs";
+import { promisify } from "util";
+
+// Promisify things from fs we want to use.
+const fs = {
+    createWriteStream: _fs.createWriteStream,
+    exists: promisify(_fs.exists),
+    mkdir: promisify(_fs.mkdir),
+    appendFile: promisify(_fs.appendFile),
+    readFile: promisify(_fs.readFile),
+};
+
+process.on("unhandledRejection", (reason) => {
+    console.error(`Unhandled promise rejection: ${reason}`);
+    process.exit(1);
+});
+
+let sauceUser = null;
+let sauceKey = null;
+let tunnelIdentifier = null;
+let hostName = null;
+
+for (let i = 0; i < process.argv.length; i += 1) {
+    switch (process.argv[i]) {
+        case "--sauce-user":
+            i += 1;
+            sauceUser = process.argv[i];
+            break;
+        case "--sauce-key":
+            i += 1;
+            sauceKey = process.argv[i];
+            break;
+        case "--sauce-tunnel":
+            i += 1;
+            tunnelIdentifier = process.argv[i];
+            break;
+        case "--use-hostname":
+            i += 1;
+            hostName = process.argv[i];
+            break;
+    }
+}
+
+const HOSTSFILE_PATH = process.platform === "win32" ? `${process.env.SystemRoot}\\System32\\drivers\\etc\\hosts` : null;
+
+(async () => {
+
+    if (hostName) {
+        // Register a custom hostname in the hosts file (requires Admin, but AzDO agents run as Admin)
+        // Used to work around issues in Sauce Labs.
+        if (process.platform !== "win32") {
+            throw new Error("Can't use '--use-hostname' on non-Windows platform.");
+        }
+
+        try {
+
+            console.log(`Updating Hosts file (${HOSTSFILE_PATH}) to register host name '${hostName}'`);
+            await fs.appendFile(HOSTSFILE_PATH, `${EOL}127.0.0.1 ${hostName}${EOL}`);
+
+        } catch (error) {
+            console.log(`Unable to update hosts file at ${HOSTSFILE_PATH}. Error: ${error}`);
+        }
+    }
+
+
+    // Creates a persistent proxy tunnel using Sauce Connect.
+    var sauceConnectLauncher = require('sauce-connect-launcher');
+
+    sauceConnectLauncher({
+        username: sauceUser,
+        accessKey: sauceKey,
+        tunnelIdentifier: tunnelIdentifier,
+    }, function (err, sauceConnectProcess) {
+        if (err) {
+            console.error(err.message);
+            return;
+        }
+
+        console.log("Sauce Connect ready");
+    });
+})();
diff --git a/src/Components/test/E2ETest/yarn.lock b/src/Components/test/E2ETest/yarn.lock
index 6f2b1cc3e3dd28a1a788ed7729ddbdbc60caddd9..250aa50786f27a0a173067f5ea6b324926093428 100644
--- a/src/Components/test/E2ETest/yarn.lock
+++ b/src/Components/test/E2ETest/yarn.lock
@@ -2,6 +2,23 @@
 # yarn lockfile v1
 
 
+"@types/node@^13.1.7":
+  version "13.1.8"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-13.1.8.tgz#1d590429fe8187a02707720ecf38a6fe46ce294b"
+  integrity sha512-6XzyyNM9EKQW4HKuzbo/CkOIjn/evtCmsU+MUM1xDfJ+3/rNjBttM1NgN7AOQvN6tP1Sl1D1PIKMreTArnxM9A==
+
+adm-zip@~0.4.3:
+  version "0.4.13"
+  resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.13.tgz#597e2f8cc3672151e1307d3e95cddbc75672314a"
+  integrity sha512-fERNJX8sOXfel6qCBCMPvZLzENBEhZTzKqg6vrOW5pvoEaQuJhRU4ndTAh6lHOxn1I6jnz2NHra56ZODM751uw==
+
+agent-base@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
+  integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
+  dependencies:
+    es6-promisify "^5.0.0"
+
 ajv@^6.5.5:
   version "6.10.2"
   resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52"
@@ -12,6 +29,11 @@ ajv@^6.5.5:
     json-schema-traverse "^0.4.1"
     uri-js "^4.2.2"
 
+arg@^4.1.0:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.2.tgz#e70c90579e02c63d80e3ad4e31d8bfdb8bd50064"
+  integrity sha512-+ytCkGcBtHZ3V2r2Z06AncYO8jz46UEamcspGoU8lHcEbpn6J77QK0vdWvChsclg/tM5XIJC5tnjmPp7Eq6Obg==
+
 asn1@~0.2.3:
   version "0.2.4"
   resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
@@ -24,7 +46,7 @@ assert-plus@1.0.0, assert-plus@^1.0.0:
   resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
   integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
 
-async@^2.6.2:
+async@^2.1.2, async@^2.6.2:
   version "2.6.3"
   resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
   integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
@@ -46,6 +68,11 @@ aws4@^1.8.0:
   resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
   integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
 
+balanced-match@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
+  integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
+
 bcrypt-pbkdf@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
@@ -61,11 +88,24 @@ bl@^2.2.0:
     readable-stream "^2.3.5"
     safe-buffer "^5.1.1"
 
+brace-expansion@^1.1.7:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+  dependencies:
+    balanced-match "^1.0.0"
+    concat-map "0.0.1"
+
 buffer-crc32@~0.2.3:
   version "0.2.13"
   resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
   integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
 
+buffer-from@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
+  integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
+
 caseless@~0.12.0:
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
@@ -83,6 +123,11 @@ commander@^2.19.0:
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422"
   integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==
 
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
+
 core-util-is@1.0.2, core-util-is@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -106,6 +151,13 @@ dashdash@^1.12.0:
   dependencies:
     assert-plus "^1.0.0"
 
+debug@^3.1.0:
+  version "3.2.6"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
+  integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
+  dependencies:
+    ms "^2.1.1"
+
 debug@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
@@ -118,6 +170,11 @@ delayed-stream@~1.0.0:
   resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
   integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
 
+diff@^4.0.1:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
+  integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
+
 ecc-jsbn@~0.1.1:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
@@ -133,6 +190,18 @@ end-of-stream@^1.4.1:
   dependencies:
     once "^1.4.0"
 
+es6-promise@^4.0.3:
+  version "4.2.8"
+  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
+  integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
+
+es6-promisify@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
+  integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
+  dependencies:
+    es6-promise "^4.0.3"
+
 extend@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
@@ -184,6 +253,11 @@ fs-constants@^1.0.0:
   resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
   integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
 
+fs.realpath@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
+
 getpass@^0.1.1:
   version "0.1.7"
   resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
@@ -191,6 +265,18 @@ getpass@^0.1.1:
   dependencies:
     assert-plus "^1.0.0"
 
+glob@^7.1.3:
+  version "7.1.6"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
+  integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
 har-schema@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
@@ -213,7 +299,23 @@ http-signature@~1.2.0:
     jsprim "^1.2.2"
     sshpk "^1.7.0"
 
-inherits@^2.0.3, inherits@~2.0.3:
+https-proxy-agent@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81"
+  integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==
+  dependencies:
+    agent-base "^4.3.0"
+    debug "^3.1.0"
+
+inflight@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+  dependencies:
+    once "^1.3.0"
+    wrappy "1"
+
+inherits@2, inherits@^2.0.3, inherits@~2.0.3:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -268,11 +370,21 @@ jsprim@^1.2.2:
     json-schema "0.2.3"
     verror "1.10.0"
 
+lodash@^4.16.6:
+  version "4.17.15"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
+  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+
 lodash@^4.17.11, lodash@^4.17.14:
   version "4.17.14"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba"
   integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw==
 
+make-error@^1.1.1:
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.5.tgz#efe4e81f6db28cadd605c70f29c831b58ef776c8"
+  integrity sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g==
+
 mime-db@1.40.0:
   version "1.40.0"
   resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32"
@@ -285,6 +397,13 @@ mime-types@^2.1.12, mime-types@~2.1.19:
   dependencies:
     mime-db "1.40.0"
 
+minimatch@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
+  dependencies:
+    brace-expansion "^1.1.7"
+
 minimist@0.0.8:
   version "0.0.8"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
@@ -317,13 +436,18 @@ oauth-sign@~0.9.0:
   resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
   integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
 
-once@^1.4.0:
+once@^1.3.0, once@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
   integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
   dependencies:
     wrappy "1"
 
+path-is-absolute@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+  integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
+
 path-key@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
@@ -417,6 +541,13 @@ request@2.88.0:
     tunnel-agent "^0.6.0"
     uuid "^3.3.2"
 
+rimraf@^2.5.4:
+  version "2.7.1"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
+  integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
+  dependencies:
+    glob "^7.1.3"
+
 safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519"
@@ -432,6 +563,17 @@ safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
+sauce-connect-launcher@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.3.1.tgz#31137f57b0f7176e1c0525b7fb09c6da746647cf"
+  integrity sha512-vIf9qDol3q2FlYzrKt0dr3kvec6LSjX2WS+/mVnAJIhqh1evSkPKCR2AzcJrnSmx9Xt9PtV0tLY7jYh0wsQi8A==
+  dependencies:
+    adm-zip "~0.4.3"
+    async "^2.1.2"
+    https-proxy-agent "^3.0.0"
+    lodash "^4.16.6"
+    rimraf "^2.5.4"
+
 selenium-standalone@^6.15.4:
   version "6.16.0"
   resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.16.0.tgz#ffcf02665c58ff7a7472427ae819ba79c15967ac"
@@ -468,6 +610,19 @@ shebang-regex@^1.0.0:
   resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
   integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
 
+source-map-support@^0.5.6:
+  version "0.5.16"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.16.tgz#0ae069e7fe3ba7538c64c98515e35339eac5a042"
+  integrity sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==
+  dependencies:
+    buffer-from "^1.0.0"
+    source-map "^0.6.0"
+
+source-map@^0.6.0:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
 sshpk@^1.7.0:
   version "1.16.1"
   resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
@@ -516,6 +671,17 @@ tough-cookie@~2.4.3:
     psl "^1.1.24"
     punycode "^1.4.1"
 
+ts-node@^8.6.2:
+  version "8.6.2"
+  resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.6.2.tgz#7419a01391a818fbafa6f826a33c1a13e9464e35"
+  integrity sha512-4mZEbofxGqLL2RImpe3zMJukvEvcO1XP8bj8ozBPySdCUXEcU5cIRwR0aM3R+VoZq7iXc8N86NC0FspGRqP4gg==
+  dependencies:
+    arg "^4.1.0"
+    diff "^4.0.1"
+    make-error "^1.1.1"
+    source-map-support "^0.5.6"
+    yn "3.1.1"
+
 tunnel-agent@^0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
@@ -528,6 +694,11 @@ tweetnacl@^0.14.3, tweetnacl@~0.14.0:
   resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
   integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
 
+typescript@^3.7.5:
+  version "3.7.5"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.7.5.tgz#0692e21f65fd4108b9330238aac11dd2e177a1ae"
+  integrity sha512-/P5lkRXkWHNAbcJIiHPfRoKqyd7bsyCma1hZNUGfn20qm64T6ZBlrzprymeu918H+mB/0rIg2gGK/BXkhhYgBw==
+
 uri-js@^4.2.2:
   version "4.2.2"
   resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
@@ -578,3 +749,8 @@ yauzl@^2.10.0:
   dependencies:
     buffer-crc32 "~0.2.3"
     fd-slicer "~1.1.0"
+
+yn@3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
+  integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
diff --git a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj
index 98357d0e8835d0030cc97238c4bab13aef49240d..9914ec452179c2a8c8c4c32a94d6c5e89a45f544 100644
--- a/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj
+++ b/src/Components/test/testassets/BasicTestApp/BasicTestApp.csproj
@@ -1,7 +1,7 @@
 <Project Sdk="Microsoft.NET.Sdk.Web">
 
   <PropertyGroup>
-    <TargetFramework>netstandard2.0</TargetFramework>
+    <TargetFramework>netstandard2.1</TargetFramework>
     <RazorLangVersion>3.0</RazorLangVersion>
 
     <ReferenceBlazorBuildLocally>true</ReferenceBlazorBuildLocally>
diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor
index 0548f382a898c3767f2996960f786ae7eb3ffe4d..eff29276a459f98c9232cd4e586dc81e3deec3c0 100644
--- a/src/Components/test/testassets/BasicTestApp/Index.razor
+++ b/src/Components/test/testassets/BasicTestApp/Index.razor
@@ -87,12 +87,6 @@
     @((RenderFragment)RenderSelectedComponent)
 </app>
 
-<div id="blazor-error-ui">
-    An unhandled error has occurred.
-    <a href class='reload'>Reload</a>
-    <a class='dismiss' style="cursor: pointer;">🗙</a>
-</div>
-
 @code {
     string SelectedComponentTypeName { get; set; } = "none";
 
diff --git a/src/Components/test/testassets/BasicTestApp/Program.cs b/src/Components/test/testassets/BasicTestApp/Program.cs
index cebb226e7c46cde58d766dacd657b36b832566f0..0c62f05bd1afa090cea5a3ff8903a0ea6393c2dd 100644
--- a/src/Components/test/testassets/BasicTestApp/Program.cs
+++ b/src/Components/test/testassets/BasicTestApp/Program.cs
@@ -1,23 +1,64 @@
 // 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.Globalization;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using BasicTestApp.AuthTest;
 using Microsoft.AspNetCore.Blazor.Hosting;
+using Microsoft.AspNetCore.Blazor.Http;
+using Microsoft.AspNetCore.Components.Authorization;
+using Microsoft.Extensions.DependencyInjection;
+using Mono.WebAssembly.Interop;
 
 namespace BasicTestApp
 {
     public class Program
     {
-        public static void Main(string[] args)
+        public static async Task Main(string[] args)
         {
+            await SimulateErrorsIfNeededForTest();
+
             // We want the culture to be en-US so that the tests for bind can work consistently.
             CultureInfo.CurrentCulture = new CultureInfo("en-US");
 
-            CreateHostBuilder(args).Build().Run();
+            var builder = WebAssemblyHostBuilder.CreateDefault(args);
+
+            if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("WEBASSEMBLY")))
+            {
+                // Needed because the test server runs on a different port than the client app,
+                // and we want to test sending/receiving cookies under this config
+                WebAssemblyHttpMessageHandlerOptions.DefaultCredentials = FetchCredentialsOption.Include;
+            }
+
+            builder.RootComponents.Add<Index>("root");
+
+            builder.Services.AddSingleton<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
+            builder.Services.AddAuthorizationCore(options =>
+            {
+                options.AddPolicy("NameMustStartWithB", policy =>
+                    policy.RequireAssertion(ctx => ctx.User.Identity.Name?.StartsWith("B") ?? false));
+            });
+
+            await builder.Build().RunAsync();
         }
 
-        public static IWebAssemblyHostBuilder CreateHostBuilder(string[] args) =>
-            BlazorWebAssemblyHost.CreateDefaultBuilder()
-                .UseBlazorStartup<Startup>();
+        // Supports E2E tests in StartupErrorNotificationTest
+        private static async Task SimulateErrorsIfNeededForTest()
+        {
+            var currentUrl = new MonoWebAssemblyJSRuntime().Invoke<string>("getCurrentUrl");
+            if (currentUrl.Contains("error=sync"))
+            {
+                throw new InvalidTimeZoneException("This is a synchronous startup exception");
+            }
+
+            await Task.Yield();
+
+            if (currentUrl.Contains("error=async"))
+            {
+                throw new InvalidTimeZoneException("This is an asynchronous startup exception");
+            }
+        }
     }
 }
diff --git a/src/Components/test/testassets/BasicTestApp/Startup.cs b/src/Components/test/testassets/BasicTestApp/Startup.cs
deleted file mode 100644
index 008a988316c95a759d57fcb884f29d8d5f5417dc..0000000000000000000000000000000000000000
--- a/src/Components/test/testassets/BasicTestApp/Startup.cs
+++ /dev/null
@@ -1,39 +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.Runtime.InteropServices;
-using BasicTestApp.AuthTest;
-using Microsoft.AspNetCore.Blazor.Http;
-using Microsoft.AspNetCore.Components;
-using Microsoft.AspNetCore.Components.Authorization;
-using Microsoft.AspNetCore.Components.Builder;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace BasicTestApp
-{
-    public class Startup
-    {
-        public void ConfigureServices(IServiceCollection services)
-        {
-            services.AddSingleton<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
-
-            services.AddAuthorizationCore(options =>
-            {
-                options.AddPolicy("NameMustStartWithB", policy =>
-                    policy.RequireAssertion(ctx => ctx.User.Identity.Name?.StartsWith("B") ?? false));
-            });
-        }
-
-        public void Configure(IComponentsApplicationBuilder app)
-        {
-            if (RuntimeInformation.IsOSPlatform(OSPlatform.Create("WEBASSEMBLY")))
-            {
-                // Needed because the test server runs on a different port than the client app,
-                // and we want to test sending/receiving cookies underling this config
-                WebAssemblyHttpMessageHandlerOptions.DefaultCredentials = FetchCredentialsOption.Include;
-            }
-
-            app.AddComponent<Index>("root");
-        }
-    }
-}
diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/index.html b/src/Components/test/testassets/BasicTestApp/wwwroot/index.html
index 517d9d6fcc1074d8c43447f1784ca9307925674b..a37c08a7d1a212cbd5d40532d25c9b28b76cf7cb 100644
--- a/src/Components/test/testassets/BasicTestApp/wwwroot/index.html
+++ b/src/Components/test/testassets/BasicTestApp/wwwroot/index.html
@@ -14,6 +14,13 @@
 <body>
     <root>Loading...</root>
 
+    <!-- Explicit display:none required so StartupErrorNotificationTest can observe it change -->
+    <div id="blazor-error-ui" style="display: none;">
+        An unhandled error has occurred.
+        <a href class='reload'>Reload</a>
+        <a class='dismiss' style="cursor: pointer;">🗙</a>
+    </div>
+
     <!-- Used for testing interop scenarios between JS and .NET -->
     <script src="js/jsinteroptests.js"></script>
 
@@ -27,6 +34,10 @@
         function navigationManagerNavigate() {
             Blazor.navigateTo('/subdir/some-path');
         }
+
+        function getCurrentUrl() {
+            return location.href;
+        }
     </script>
     <script src="_framework/blazor.webassembly.js"></script>
 
diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/style.css b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css
index 777375d9e0e2727be3f4531e6bffd0d68fc6c3d7..ea9900430b1c034f18014ea19bc3c322351d5146 100644
--- a/src/Components/test/testassets/BasicTestApp/wwwroot/style.css
+++ b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css
@@ -7,11 +7,23 @@
 }
 
 #blazor-error-ui {
+    background: lightyellow;
+    bottom: 0;
+    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
     display: none;
+    left: 0;
+    padding: 0.6rem 1.25rem 0.7rem 1.25rem;
+    position: fixed;
+    width: 100%;
+    z-index: 1000;
+    box-sizing: border-box;
 }
 
-    #blazor-error-ui dismiss {
+    #blazor-error-ui .dismiss {
         cursor: pointer;
+        position: absolute;
+        right: 0.75rem;
+        top: 0.5rem;
     }
 
 .validation-message {
diff --git a/src/Components/test/testassets/TestServer/CorsStartup.cs b/src/Components/test/testassets/TestServer/CorsStartup.cs
index a4d91a84aa041ea82e66dcb74f6b82ed49d8ccf2..28dda32d903adfe391052229c52a607eb7c3ea1e 100644
--- a/src/Components/test/testassets/TestServer/CorsStartup.cs
+++ b/src/Components/test/testassets/TestServer/CorsStartup.cs
@@ -46,7 +46,7 @@ namespace TestServer
             app.Map("/subdir", app =>
             {
                 app.UseStaticFiles();
-                app.UseClientSideBlazorFiles<BasicTestApp.Startup>();
+                app.UseClientSideBlazorFiles<BasicTestApp.Program>();
 
                 app.UseRouting();
 
@@ -55,7 +55,7 @@ namespace TestServer
                 app.UseEndpoints(endpoints =>
                 {
                     endpoints.MapControllers();
-                    endpoints.MapFallbackToClientSideBlazor<BasicTestApp.Startup>("index.html");
+                    endpoints.MapFallbackToClientSideBlazor<BasicTestApp.Program>("index.html");
                 });
             });
         }
diff --git a/src/Components/test/testassets/TestServer/InternationalizationStartup.cs b/src/Components/test/testassets/TestServer/InternationalizationStartup.cs
index d508ed797b443a4f141c638975add2228d47a93b..7521ebe34b28aee3938bad067ac2621ec99e7727 100644
--- a/src/Components/test/testassets/TestServer/InternationalizationStartup.cs
+++ b/src/Components/test/testassets/TestServer/InternationalizationStartup.cs
@@ -37,7 +37,7 @@ namespace TestServer
             app.Map("/subdir", app =>
             {
                 app.UseStaticFiles();
-                app.UseClientSideBlazorFiles<BasicTestApp.Startup>();
+                app.UseClientSideBlazorFiles<BasicTestApp.Program>();
 
                 app.UseRequestLocalization(options =>
                 {
diff --git a/src/Components/test/testassets/TestServer/Pages/_ServerHost.cshtml b/src/Components/test/testassets/TestServer/Pages/_ServerHost.cshtml
index af2f28f65854b80d99e43d18a186507ebf45a13e..b0ba837af22b59cbabc99a7bca4d31a1bc43d032 100644
--- a/src/Components/test/testassets/TestServer/Pages/_ServerHost.cshtml
+++ b/src/Components/test/testassets/TestServer/Pages/_ServerHost.cshtml
@@ -17,6 +17,12 @@
     <!-- Used for testing interop scenarios between JS and .NET -->
     <script src="js/jsinteroptests.js"></script>
 
+    <div id="blazor-error-ui">
+        An unhandled error has occurred.
+        <a href class='reload'>Reload</a>
+        <a class='dismiss' style="cursor: pointer;">🗙</a>
+    </div>
+
     <script>
         // Used by ElementRefComponent
         function setElementValue(element, newValue) {
diff --git a/src/Components/test/testassets/TestServer/StartupWithMapFallbackToClientSideBlazor.cs b/src/Components/test/testassets/TestServer/StartupWithMapFallbackToClientSideBlazor.cs
index 483dd00d786416c3123a377fbcfbeacc75f8215a..d32f48f8e3a66a1b686be18ecfc0c227744cb670 100644
--- a/src/Components/test/testassets/TestServer/StartupWithMapFallbackToClientSideBlazor.cs
+++ b/src/Components/test/testassets/TestServer/StartupWithMapFallbackToClientSideBlazor.cs
@@ -36,7 +36,7 @@ namespace TestServer
             // The client-side files middleware needs to be here because the base href in hardcoded to /subdir/
             app.Map("/subdir", app =>
             {
-                app.UseClientSideBlazorFiles<BasicTestApp.Startup>();
+                app.UseClientSideBlazorFiles<BasicTestApp.Program>();
             });
 
             // The calls to `Map` allow us to test each of these overloads, while keeping them isolated.
@@ -46,7 +46,7 @@ namespace TestServer
 
                 app.UseEndpoints(endpoints =>
                 {
-                    endpoints.MapFallbackToClientSideBlazor<BasicTestApp.Startup>("index.html");
+                    endpoints.MapFallbackToClientSideBlazor<BasicTestApp.Program>("index.html");
                 });
             });
 
@@ -56,7 +56,7 @@ namespace TestServer
 
                 app.UseEndpoints(endpoints =>
                 {
-                    endpoints.MapFallbackToClientSideBlazor<BasicTestApp.Startup>("test/{*path:nonfile}", "index.html");
+                    endpoints.MapFallbackToClientSideBlazor<BasicTestApp.Program>("test/{*path:nonfile}", "index.html");
                 });
             });
 
@@ -66,7 +66,7 @@ namespace TestServer
 
                 app.UseEndpoints(endpoints =>
                 {
-                    endpoints.MapFallbackToClientSideBlazor(typeof(BasicTestApp.Startup).Assembly.Location, "index.html");
+                    endpoints.MapFallbackToClientSideBlazor(typeof(BasicTestApp.Program).Assembly.Location, "index.html");
                 });
             });
 
@@ -76,7 +76,7 @@ namespace TestServer
 
                 app.UseEndpoints(endpoints =>
                 {
-                    endpoints.MapFallbackToClientSideBlazor(typeof(BasicTestApp.Startup).Assembly.Location, "test/{*path:nonfile}", "index.html");
+                    endpoints.MapFallbackToClientSideBlazor(typeof(BasicTestApp.Program).Assembly.Location, "test/{*path:nonfile}", "index.html");
                 });
             });
         }
diff --git a/src/Framework/src/Microsoft.AspNetCore.App.Runtime.csproj b/src/Framework/src/Microsoft.AspNetCore.App.Runtime.csproj
index 3451584734c2149daaaf6d2e07bdfd2b067912e7..bee0f2b49985aa5360b7c5753e27d48e3cb5b596 100644
--- a/src/Framework/src/Microsoft.AspNetCore.App.Runtime.csproj
+++ b/src/Framework/src/Microsoft.AspNetCore.App.Runtime.csproj
@@ -175,7 +175,6 @@ This package is an internal implementation of the .NET Core SDK and is not meant
       _BatchCopyToRedistLayout;
       _CreateInternalSharedFxArchive;
       _CreateRedistSharedFxArchive;
-      _InstallFrameworkIntoLocalDotNet;
     </CoreBuildDependsOn>
     <CrossGenDependsOn>
       ResolveReferences;
diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/.gitignore b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..9310b1ea5295dccd60b89b5c77615f5ecbb54339
--- /dev/null
+++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/.gitignore
@@ -0,0 +1,3 @@
+# This file is generated by the build
+content/*/*.*proj
+content/*/*/*.*proj
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/BlazorWasm-CSharp.Client.csproj b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Client.csproj.in
similarity index 61%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/BlazorWasm-CSharp.Client.csproj
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Client.csproj.in
index c4eb65bded248c5e71e026de9b10d61be2e635e4..70927b08e331758b3feaaf68cea816fd3023bddd 100644
--- a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/BlazorWasm-CSharp.Client.csproj
+++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Client.csproj.in
@@ -1,15 +1,15 @@
 <Project Sdk="Microsoft.NET.Sdk.Web">
 
   <PropertyGroup>
-    <TargetFramework>netstandard2.0</TargetFramework>
+    <TargetFramework>netstandard2.1</TargetFramework>
     <RazorLangVersion>3.0</RazorLangVersion>
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.AspNetCore.Blazor" Version="$(TemplateBlazorPackageVersion)" />
-    <PackageReference Include="Microsoft.AspNetCore.Blazor.Build" Version="$(TemplateBlazorPackageVersion)" PrivateAssets="all" />
-    <PackageReference Include="Microsoft.AspNetCore.Blazor.HttpClient" Version="$(TemplateBlazorPackageVersion)" />
-    <PackageReference Include="Microsoft.AspNetCore.Blazor.DevServer" Version="$(TemplateBlazorPackageVersion)" PrivateAssets="all" />
+    <PackageReference Include="Microsoft.AspNetCore.Blazor" Version="${MicrosoftAspNetCoreBlazorPackageVersion}" />
+    <PackageReference Include="Microsoft.AspNetCore.Blazor.Build" Version="${MicrosoftAspNetCoreBlazorBuildPackageVersion}" PrivateAssets="all" />
+    <PackageReference Include="Microsoft.AspNetCore.Blazor.DevServer" Version="${MicrosoftAspNetCoreBlazorDevServerPackageVersion}" PrivateAssets="all" />
+    <PackageReference Include="Microsoft.AspNetCore.Blazor.HttpClient" Version="${MicrosoftAspNetCoreBlazorHttpClientPackageVersion}" />
   </ItemGroup>
   <!--#if Hosted -->
   <ItemGroup>
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Server/BlazorWasm-CSharp.Server.csproj b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Server.csproj.in
similarity index 72%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Server/BlazorWasm-CSharp.Server.csproj
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Server.csproj.in
index 2742595431f2565d7c0f65894402e2304a339735..5fe7473679dce2654d7951b5cc44f25b3a478018 100644
--- a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Server/BlazorWasm-CSharp.Server.csproj
+++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Server.csproj.in
@@ -1,12 +1,11 @@
 <Project Sdk="Microsoft.NET.Sdk.Web">
 
   <PropertyGroup>
-    <TargetFramework>netcoreapp3.1</TargetFramework>
-    <LangVersion>7.3</LangVersion>
+    <TargetFramework>${DefaultNetCoreTargetFramework}</TargetFramework>
   </PropertyGroup>
 
   <ItemGroup>
-    <PackageReference Include="Microsoft.AspNetCore.Blazor.Server" Version="$(TemplateBlazorPackageVersion)" />
+    <PackageReference Include="Microsoft.AspNetCore.Blazor.Server" Version="${MicrosoftAspNetCoreBlazorServerPackageVersion}" />
   </ItemGroup>
 
   <ItemGroup>
diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Shared.csproj.in b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Shared.csproj.in
new file mode 100644
index 0000000000000000000000000000000000000000..d4c395e8cb7848ff696be637f8a8c858c3ed207f
--- /dev/null
+++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/BlazorWasm-CSharp.Shared.csproj.in
@@ -0,0 +1,7 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <TargetFramework>netstandard2.1</TargetFramework>
+  </PropertyGroup>
+
+</Project>
diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/Microsoft.AspNetCore.Blazor.Templates.csproj b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/Microsoft.AspNetCore.Blazor.Templates.csproj
new file mode 100644
index 0000000000000000000000000000000000000000..65457a000d3566e81d13822a8495049e2c09de6b
--- /dev/null
+++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/Microsoft.AspNetCore.Blazor.Templates.csproj
@@ -0,0 +1,45 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <BlazorProjectsRoot>$(RepoRoot)src\Components\Blazor\</BlazorProjectsRoot>
+  </PropertyGroup>
+
+  <Import Project="$(BlazorProjectsRoot)Blazor.Version.props" />
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <IsShippingPackage>true</IsShippingPackage>
+    <Description>Templates for ASP.NET Core Blazor projects.</Description>
+    <PackageTags>$(PackageTags);blazor;spa</PackageTags>
+  </PropertyGroup>
+
+  <PropertyGroup>
+    <!-- Lists the versions of dependencies not built in this repo. Packages produced from this repo should be listed as a PackageVersionVariableReference.  -->
+    <GeneratedContentProperties>
+      DefaultNetCoreTargetFramework=$(DefaultNetCoreTargetFramework);
+      MicrosoftAspNetCoreBlazorPackageVersion=$(MicrosoftAspNetCoreBlazorPackageVersion);
+      MicrosoftAspNetCoreBlazorBuildPackageVersion=$(MicrosoftAspNetCoreBlazorBuildPackageVersion);
+      MicrosoftAspNetCoreBlazorDevServerPackageVersion=$(MicrosoftAspNetCoreBlazorDevServerPackageVersion);
+      MicrosoftAspNetCoreBlazorHttpClientPackageVersion=$(MicrosoftAspNetCoreBlazorHttpClientPackageVersion);
+      MonoWebAssemblyInteropPackageVersion=$(MonoWebAssemblyInteropPackageVersion);
+      MicrosoftEntityFrameworkCoreSqlServerPackageVersion=$(MicrosoftEntityFrameworkCoreSqlServerPackageVersion);
+      MicrosoftAspNetCoreBlazorServerPackageVersion=$(MicrosoftAspNetCoreBlazorServerPackageVersion);
+    </GeneratedContentProperties>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <!-- These projects product packages that the templates depend on. See GenerateContent.targets -->
+    <PackageVersionVariableReference Include="$(BlazorProjectsRoot)Blazor\src\Microsoft.AspNetCore.Blazor.csproj" />
+    <PackageVersionVariableReference Include="$(BlazorProjectsRoot)Build\src\Microsoft.AspNetCore.Blazor.Build.csproj" />
+    <PackageVersionVariableReference Include="$(BlazorProjectsRoot)DevServer\src\Microsoft.AspNetCore.Blazor.DevServer.csproj" />
+    <PackageVersionVariableReference Include="$(BlazorProjectsRoot)Http\src\Microsoft.AspNetCore.Blazor.HttpClient.csproj" />
+    <PackageVersionVariableReference Include="$(BlazorProjectsRoot)Mono.WebAssembly.Interop\src\Mono.WebAssembly.Interop.csproj" />
+    <PackageVersionVariableReference Include="$(BlazorProjectsRoot)Server\src\Microsoft.AspNetCore.Blazor.Server.csproj" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <GeneratedContent Include="BlazorWasm-CSharp.Client.csproj.in" OutputPath="content/BlazorWasm-CSharp/Client/BlazorWasm-CSharp.Client.csproj" />
+    <GeneratedContent Include="BlazorWasm-CSharp.Shared.csproj.in" OutputPath="content/BlazorWasm-CSharp/Shared/BlazorWasm-CSharp.Shared.csproj" />
+    <GeneratedContent Include="BlazorWasm-CSharp.Server.csproj.in" OutputPath="content/BlazorWasm-CSharp/Server/BlazorWasm-CSharp.Server.csproj" />
+  </ItemGroup>
+
+</Project>
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/.template.config.src/dotnetcli.host.json b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/dotnetcli.host.json
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/.template.config.src/dotnetcli.host.json
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/dotnetcli.host.json
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/.template.config.src/icon.png b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/icon.png
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/.template.config.src/icon.png
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/icon.png
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/.template.config.src/template.json b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/template.json
similarity index 86%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/.template.config.src/template.json
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/template.json
index 33e094e356684fcbe6336d18b57f88ef1cb94817..b6cb64fcec5f9c3052769b45021a4fd48fcde500 100644
--- a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/.template.config.src/template.json
+++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/template.json
@@ -1,13 +1,16 @@
 {
+  "$schema": "http://json.schemastore.org/template",
   "author": "Microsoft",
   "classifications": [
     "Web",
     "Blazor",
     "WebAssembly"
   ],
+  "name": "Blazor WebAssembly App",
   "defaultName": "WebApplication",
   "description": "A project template for creating a Blazor app that runs on WebAssembly and is optionally hosted by an ASP.NET Core app. This template can be used for web apps with rich dynamic user interfaces (UIs).",
   "groupIdentity": "Microsoft.Web.Blazor.Wasm",
+  "precedence": "6001",
   "guids": [
     "4C26868E-5E7C-458D-82E3-040509D0C71F",
     "5990939C-7E7B-4CFA-86FF-44CA5756498A",
@@ -15,7 +18,6 @@
     "0AFFA7FD-4E37-4636-AB91-3753E746DB98"
   ],
   "identity": "Microsoft.Web.Blazor.Wasm.CSharp",
-  "name": "Blazor WebAssembly App",
   "preferNameDirectory": true,
   "primaryOutputs": [
     {
@@ -94,20 +96,6 @@
       "type": "bind",
       "binding": "HostIdentifier"
     },
-    "TemplateBlazorVersionSymbol": {
-      "type": "parameter",
-      "datatype": "string",
-      "description": "Specifies which version of Blazor packages to use.",
-      "replaces": "$(TemplateBlazorPackageVersion)",
-      "defaultValue": "${TemplateBlazorVersion}"
-    },
-    "TemplateComponentsVersionSymbol": {
-      "type": "parameter",
-      "datatype": "string",
-      "description": "Specifies which version of Components packages to use.",
-      "replaces": "$(TemplateComponentsPackageVersion)",
-      "defaultValue": "${TemplateComponentsVersion}"
-    },
     "skipRestore": {
       "type": "parameter",
       "datatype": "bool",
@@ -146,7 +134,7 @@
         }
       ],
       "args": {
-        "files": [ "BlazorWasm-CSharp.Client.csproj" ]
+        "files": ["BlazorWasm-CSharp.Client.csproj"]
       },
       "actionId": "210D431B-A78B-4D2F-B762-4ED3E3EA9025",
       "continueOnError": true
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/.template.config.src/vs-2017.3.host.json b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/vs-2017.3.host.json
similarity index 92%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/.template.config.src/vs-2017.3.host.json
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/vs-2017.3.host.json
index 4d0b04c1b3a36dee1041fb9faa3e3179336669bf..5cb50d10a519f7fa886acab48452f5d78295dd2c 100644
--- a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/.template.config.src/vs-2017.3.host.json
+++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/.template.config/vs-2017.3.host.json
@@ -11,7 +11,7 @@
   },
   "order": 610,
   "icon": "icon.png",
-  "learnMoreLink": "https://github.com/aspnet/blazor",
+  "learnMoreLink": "https://github.com/aspnet/AspNetCore",
   "uiFilters": [
     "oneaspnet"
   ],
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/BlazorWasm-CSharp.sln b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/BlazorWasm-CSharp.sln
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/BlazorWasm-CSharp.sln
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/BlazorWasm-CSharp.sln
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/App.razor b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/App.razor
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/App.razor
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/App.razor
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Pages/Counter.razor b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Pages/Counter.razor
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Pages/Counter.razor
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Pages/Counter.razor
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Pages/FetchData.razor b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Pages/FetchData.razor
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Pages/FetchData.razor
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Pages/FetchData.razor
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Pages/Index.razor b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Pages/Index.razor
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Pages/Index.razor
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Pages/Index.razor
diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Program.cs b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Program.cs
new file mode 100644
index 0000000000000000000000000000000000000000..5790d212dcdecb2088458eda260124aee8baeb7c
--- /dev/null
+++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Program.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using System.Text;
+using Microsoft.AspNetCore.Blazor.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+
+#if (Hosted)
+namespace BlazorWasm_CSharp.Client
+#else
+namespace BlazorWasm_CSharp
+#endif
+{
+    public class Program
+    {
+        public static async Task Main(string[] args)
+        {
+            var builder = WebAssemblyHostBuilder.CreateDefault(args);
+            builder.RootComponents.Add<App>("app");
+
+            await builder.Build().RunAsync();
+        }
+    }
+}
diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Properties/launchSettings.json b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Properties/launchSettings.json
new file mode 100644
index 0000000000000000000000000000000000000000..5b741f29135a86b0930deb9ee1ca39aeb24ab042
--- /dev/null
+++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Properties/launchSettings.json
@@ -0,0 +1,3 @@
+{
+    "useWebAssemblyDebugging": true
+}
\ No newline at end of file
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Shared/MainLayout.razor b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Shared/MainLayout.razor
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Shared/MainLayout.razor
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Shared/MainLayout.razor
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Shared/NavMenu.razor b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Shared/NavMenu.razor
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Shared/NavMenu.razor
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Shared/NavMenu.razor
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Shared/SurveyPrompt.razor b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Shared/SurveyPrompt.razor
similarity index 88%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Shared/SurveyPrompt.razor
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Shared/SurveyPrompt.razor
index 2b7b716c201e8daf05fcb8fe15f45fc22df26f9e..adb69df8e84217f0a8a1b6fafa07eb9a707a4b63 100644
--- a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/Shared/SurveyPrompt.razor
+++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/Shared/SurveyPrompt.razor
@@ -4,7 +4,7 @@
 
     <span class="text-nowrap">
         Please take our
-        <a target="_blank" class="font-weight-bold" href="https://go.microsoft.com/fwlink/?linkid=2109206">brief survey</a>
+        <a target="_blank" class="font-weight-bold" href="https://go.microsoft.com/fwlink/?linkid=2116045">brief survey</a>
     </span>
     and tell us what you think.
 </div>
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/_Imports.razor b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/_Imports.razor
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/_Imports.razor
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/_Imports.razor
diff --git a/src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/lib/bootstrap.min.css b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/bootstrap/bootstrap.min.css
similarity index 100%
rename from src/Components/Blazor/testassets/Microsoft.AspNetCore.Blazor.E2EPerformance/wwwroot/benchmarks/lib/bootstrap.min.css
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/bootstrap/bootstrap.min.css
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/bootstrap/bootstrap.min.css.map b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/bootstrap/bootstrap.min.css.map
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/bootstrap/bootstrap.min.css.map
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/bootstrap/bootstrap.min.css.map
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/FONT-LICENSE b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/FONT-LICENSE
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/FONT-LICENSE
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/FONT-LICENSE
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/ICON-LICENSE b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/ICON-LICENSE
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/ICON-LICENSE
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/ICON-LICENSE
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/README.md b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/README.md
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/README.md
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/README.md
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/css/open-iconic-bootstrap.min.css
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.eot b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.eot
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.eot
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.eot
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.otf b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.otf
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.otf
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.otf
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.svg b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.svg
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.svg
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.svg
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.ttf
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.woff b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.woff
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.woff
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/open-iconic/font/fonts/open-iconic.woff
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/site.css b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/site.css
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/css/site.css
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/css/site.css
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/index.html b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/index.html
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/index.html
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/index.html
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/sample-data/weather.json b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/sample-data/weather.json
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Client/wwwroot/sample-data/weather.json
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Client/wwwroot/sample-data/weather.json
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Server/Controllers/WeatherForecastController.cs b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Server/Controllers/WeatherForecastController.cs
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Server/Controllers/WeatherForecastController.cs
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Server/Controllers/WeatherForecastController.cs
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Server/Program.cs b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Server/Program.cs
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Server/Program.cs
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Server/Program.cs
diff --git a/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Server/Properties/launchSettings.json b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Server/Properties/launchSettings.json
new file mode 100644
index 0000000000000000000000000000000000000000..3cf9f85e48c0718a7162028811bf3080fe3b44d6
--- /dev/null
+++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Server/Properties/launchSettings.json
@@ -0,0 +1,36 @@
+{
+    "useWebAssemblyDebugging": true,
+    "iisSettings": {
+      "windowsAuthentication": false,
+      "anonymousAuthentication": true,
+      "iisExpress": {
+        "applicationUrl": "http://localhost:8080",
+        //#if(RequiresHttps)
+        "sslPort": 44300
+        //#else
+        "sslPort": 0
+        //#endif
+      }
+    },
+    "profiles": {
+      "IIS Express": {
+        "commandName": "IISExpress",
+        "launchBrowser": true,
+        "environmentVariables": {
+          "ASPNETCORE_ENVIRONMENT": "Development"
+        }
+      },
+      "Company.WebApplication1": {
+        "commandName": "Project",
+        "launchBrowser": true,
+        //#if(RequiresHttps)
+        "applicationUrl": "https://localhost:5001;http://localhost:5000",
+        //#else
+        "applicationUrl": "http://localhost:5000",
+        //#endif
+        "environmentVariables": {
+          "ASPNETCORE_ENVIRONMENT": "Development"
+        }
+      }
+    }
+  }
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Server/Startup.cs b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Server/Startup.cs
similarity index 90%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Server/Startup.cs
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Server/Startup.cs
index fc9fe042124b4297808a77ffe71539eacb83e404..ac46fb46ddb3fb0c0f89490708278186cabccba3 100644
--- a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Server/Startup.cs
+++ b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Server/Startup.cs
@@ -33,14 +33,14 @@ namespace BlazorWasm_CSharp.Server
             }
 
             app.UseStaticFiles();
-            app.UseClientSideBlazorFiles<Client.Startup>();
+            app.UseClientSideBlazorFiles<Client.Program>();
 
             app.UseRouting();
 
             app.UseEndpoints(endpoints =>
             {
                 endpoints.MapDefaultControllerRoute();
-                endpoints.MapFallbackToClientSideBlazor<Client.Startup>("index.html");
+                endpoints.MapFallbackToClientSideBlazor<Client.Program>("index.html");
             });
         }
     }
diff --git a/src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Shared/WeatherForecast.cs b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Shared/WeatherForecast.cs
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/BlazorWasm-CSharp/Shared/WeatherForecast.cs
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/BlazorWasm-CSharp/Shared/WeatherForecast.cs
diff --git a/src/Components/Blazor/Templates/src/content/Directory.Build.props b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/Directory.Build.props
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/Directory.Build.props
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/Directory.Build.props
diff --git a/src/Components/Blazor/Templates/src/content/Directory.Build.targets b/src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/Directory.Build.targets
similarity index 100%
rename from src/Components/Blazor/Templates/src/content/Directory.Build.targets
rename to src/ProjectTemplates/BlazorWasm.ProjectTemplates/content/Directory.Build.targets
diff --git a/src/ProjectTemplates/ProjectTemplates.sln b/src/ProjectTemplates/ProjectTemplates.sln
index da18f3a2e57dc4cf5d32dbaf1539505a99bc7809..7628d8233da898009e476cd75f25bd51bec2acfa 100644
--- a/src/ProjectTemplates/ProjectTemplates.sln
+++ b/src/ProjectTemplates/ProjectTemplates.sln
@@ -177,6 +177,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.ApiAut
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.SpaServices.Extensions", "..\Middleware\SpaServices.Extensions\src\Microsoft.AspNetCore.SpaServices.Extensions.csproj", "{06D0D7B2-EDA3-45A2-A060-AB791ED1DB80}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Blazor.Templates", "BlazorWasm.ProjectTemplates\Microsoft.AspNetCore.Blazor.Templates.csproj", "{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -1231,6 +1233,18 @@ Global
 		{06D0D7B2-EDA3-45A2-A060-AB791ED1DB80}.Release|x64.Build.0 = Release|Any CPU
 		{06D0D7B2-EDA3-45A2-A060-AB791ED1DB80}.Release|x86.ActiveCfg = Release|Any CPU
 		{06D0D7B2-EDA3-45A2-A060-AB791ED1DB80}.Release|x86.Build.0 = Release|Any CPU
+		{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}.Debug|x64.Build.0 = Debug|Any CPU
+		{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}.Debug|x86.Build.0 = Debug|Any CPU
+		{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}.Release|Any CPU.Build.0 = Release|Any CPU
+		{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}.Release|x64.ActiveCfg = Release|Any CPU
+		{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}.Release|x64.Build.0 = Release|Any CPU
+		{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}.Release|x86.ActiveCfg = Release|Any CPU
+		{C6D371A8-D8EA-4CF4-844E-8C6DFDF61642}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
diff --git a/src/ProjectTemplates/test/BlazorWasmTemplateTest.cs b/src/ProjectTemplates/test/BlazorWasmTemplateTest.cs
new file mode 100644
index 0000000000000000000000000000000000000000..41cb82ac21ec2a38987876bf8392f37d887d344d
--- /dev/null
+++ b/src/ProjectTemplates/test/BlazorWasmTemplateTest.cs
@@ -0,0 +1,167 @@
+// 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.IO;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.E2ETesting;
+using Microsoft.Extensions.CommandLineUtils;
+using OpenQA.Selenium;
+using Templates.Test.Helpers;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace Templates.Test
+{
+    public class BlazorWasmTemplateTest : BrowserTestBase
+    {
+        public BlazorWasmTemplateTest(ProjectFactoryFixture projectFactory, BrowserFixture browserFixture, ITestOutputHelper output)
+            : base(browserFixture, output)
+        {
+            ProjectFactory = projectFactory;
+        }
+
+        public ProjectFactoryFixture ProjectFactory { get; set; }
+
+        [Fact]
+        public async Task BlazorWasmStandaloneTemplate_Works()
+        {
+            var project = await ProjectFactory.GetOrCreateProject("blazorstandalone", Output);
+            project.TargetFramework = "netstandard2.1";
+
+            var createResult = await project.RunDotNetNewAsync("blazorwasm");
+            Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", project, createResult));
+
+            var publishResult = await project.RunDotNetPublishAsync();
+            Assert.True(0 == publishResult.ExitCode, ErrorMessages.GetFailedProcessMessage("publish", project, publishResult));
+
+            var buildResult = await project.RunDotNetBuildAsync();
+            Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", project, buildResult));
+
+            await BuildAndRunTest(project.ProjectName, project);
+
+            var publishDir = Path.Combine(project.TemplatePublishDir, project.ProjectName, "dist");
+            AspNetProcess.EnsureDevelopmentCertificates();
+
+            Output.WriteLine("Running dotnet serve on published output...");
+            using var serveProcess = ProcessEx.Run(Output, publishDir, DotNetMuxer.MuxerPathOrDefault(), "serve -S");
+
+            // Todo: Use dynamic port assignment: https://github.com/natemcmaster/dotnet-serve/pull/40/files
+            var listeningUri = "https://localhost:8080";
+            Output.WriteLine($"Opening browser at {listeningUri}...");
+            Browser.Navigate().GoToUrl(listeningUri);
+            TestBasicNavigation(project.ProjectName);
+        }
+
+        [Fact]
+        public async Task BlazorWasmHostedTemplate_Works()
+        {
+            var project = await ProjectFactory.GetOrCreateProject("blazorhosted", Output);
+
+            var createResult = await project.RunDotNetNewAsync("blazorwasm", args: new[] { "--hosted" });
+            Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", project, createResult));
+
+            var serverProject = GetSubProject(project, "Server", $"{project.ProjectName}.Server");
+
+            var publishResult = await serverProject.RunDotNetPublishAsync();
+            Assert.True(0 == publishResult.ExitCode, ErrorMessages.GetFailedProcessMessage("publish", serverProject, publishResult));
+
+            var buildResult = await serverProject.RunDotNetBuildAsync();
+            Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", serverProject, buildResult));
+
+            await BuildAndRunTest(project.ProjectName, serverProject);
+
+            using var aspNetProcess = serverProject.StartPublishedProjectAsync();
+
+            Assert.False(
+                aspNetProcess.Process.HasExited,
+                ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", serverProject, aspNetProcess.Process));
+
+            await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html");
+            if (BrowserFixture.IsHostAutomationSupported())
+            {
+                aspNetProcess.VisitInBrowser(Browser);
+                TestBasicNavigation(project.ProjectName);
+            }
+            else
+            {
+                BrowserFixture.EnforceSupportedConfigurations();
+            }
+        }
+
+        protected async Task BuildAndRunTest(string appName, Project project)
+        {
+            using var aspNetProcess = project.StartBuiltProjectAsync();
+
+            Assert.False(
+                aspNetProcess.Process.HasExited,
+                ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", project, aspNetProcess.Process));
+
+            await aspNetProcess.AssertStatusCode("/", HttpStatusCode.OK, "text/html");
+            if (BrowserFixture.IsHostAutomationSupported())
+            {
+                aspNetProcess.VisitInBrowser(Browser);
+                TestBasicNavigation(appName);
+            }
+            else
+            {
+                BrowserFixture.EnforceSupportedConfigurations();
+            }
+        }
+
+        private void TestBasicNavigation(string appName)
+        {
+            // Give components.server enough time to load so that it can replace
+            // the prerendered content before we start making assertions.
+            Thread.Sleep(5000);
+            Browser.Exists(By.TagName("ul"));
+
+            // <title> element gets project ID injected into it during template execution
+            Browser.Equal(appName.Trim(), () => Browser.Title.Trim());
+
+            // Initially displays the home page
+            Browser.Equal("Hello, world!", () => Browser.FindElement(By.TagName("h1")).Text);
+
+            // Can navigate to the counter page
+            Browser.FindElement(By.PartialLinkText("Counter")).Click();
+            Browser.Contains("counter", () => Browser.Url);
+            Browser.Equal("Counter", () => Browser.FindElement(By.TagName("h1")).Text);
+
+            // Clicking the counter button works
+            Browser.Equal("Current count: 0", () => Browser.FindElement(By.CssSelector("h1 + p")).Text);
+            Browser.FindElement(By.CssSelector("p+button")).Click();
+            Browser.Equal("Current count: 1", () => Browser.FindElement(By.CssSelector("h1 + p")).Text);
+
+            // Can navigate to the 'fetch data' page
+            Browser.FindElement(By.PartialLinkText("Fetch data")).Click();
+            Browser.Contains("fetchdata", () => Browser.Url);
+            Browser.Equal("Weather forecast", () => Browser.FindElement(By.TagName("h1")).Text);
+
+            // Asynchronously loads and displays the table of weather forecasts
+            Browser.Exists(By.CssSelector("table>tbody>tr"));
+            Browser.Equal(5, () => Browser.FindElements(By.CssSelector("p+table>tbody>tr")).Count);
+        }
+
+        private Project GetSubProject(Project project, string projectDirectory, string projectName)
+        {
+            var subProjectDirectory = Path.Combine(project.TemplateOutputDir, projectDirectory);
+            if (!Directory.Exists(subProjectDirectory))
+            {
+                throw new DirectoryNotFoundException($"Directory {subProjectDirectory} was not found.");
+            }
+
+            var subProject = new Project
+            {
+                DotNetNewLock = project.DotNetNewLock,
+                NodeLock = project.NodeLock,
+                Output = project.Output,
+                DiagnosticsMessageSink = project.DiagnosticsMessageSink,
+                ProjectName = projectName,
+                TemplateOutputDir = subProjectDirectory,
+            };
+
+            return subProject;
+        }
+    }
+}
diff --git a/src/ProjectTemplates/test/EmptyWebTemplateTest.cs b/src/ProjectTemplates/test/EmptyWebTemplateTest.cs
deleted file mode 100644
index 56e2e8c10539e9d1b878e52da96cd53a9e44c361..0000000000000000000000000000000000000000
--- a/src/ProjectTemplates/test/EmptyWebTemplateTest.cs
+++ /dev/null
@@ -1,73 +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.Threading.Tasks;
-using Templates.Test.Helpers;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Templates.Test
-{
-    public class EmptyWebTemplateTest
-    {
-        public EmptyWebTemplateTest(ProjectFactoryFixture projectFactory, ITestOutputHelper output)
-        {
-            ProjectFactory = projectFactory;
-            Output = output;
-        }
-
-        public Project Project { get; set; }
-
-        public ProjectFactoryFixture ProjectFactory { get; }
-
-        public ITestOutputHelper Output { get; }
-
-        [Fact]
-        public async Task EmptyWebTemplateCSharp()
-        {
-            await EmtpyTemplateCore(languageOverride: null);
-        }
-
-        [Fact]
-        public async Task EmptyWebTemplateFSharp()
-        {
-            await EmtpyTemplateCore("F#");
-        }
-
-        private async Task EmtpyTemplateCore(string languageOverride)
-        {
-            Project = await ProjectFactory.GetOrCreateProject("empty" + (languageOverride == "F#" ? "fsharp" : "csharp"), Output);
-
-            var createResult = await Project.RunDotNetNewAsync("web", language: languageOverride);
-            Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", Project, createResult));
-
-            var publishResult = await Project.RunDotNetPublishAsync();
-            Assert.True(0 == publishResult.ExitCode, ErrorMessages.GetFailedProcessMessage("publish", Project, publishResult));
-
-            // Run dotnet build after publish. The reason is that one uses Config = Debug and the other uses Config = Release
-            // The output from publish will go into bin/Release/netcoreappX.Y/publish and won't be affected by calling build
-            // later, while the opposite is not true.
-
-            var buildResult = await Project.RunDotNetBuildAsync();
-            Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", Project, buildResult));
-
-            using (var aspNetProcess = Project.StartBuiltProjectAsync())
-            {
-                Assert.False(
-                   aspNetProcess.Process.HasExited,
-                   ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", Project, aspNetProcess.Process));
-
-                await aspNetProcess.AssertOk("/");
-            }
-
-            using (var aspNetProcess = Project.StartPublishedProjectAsync())
-            {
-                Assert.False(
-                    aspNetProcess.Process.HasExited,
-                    ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", Project, aspNetProcess.Process));
-
-                await aspNetProcess.AssertOk("/");
-            }
-        }
-    }
-}
diff --git a/src/ProjectTemplates/test/GrpcTemplateTest.cs b/src/ProjectTemplates/test/GrpcTemplateTest.cs
deleted file mode 100644
index 4713a7a9c45424a8991179b13e90a810b6e64b74..0000000000000000000000000000000000000000
--- a/src/ProjectTemplates/test/GrpcTemplateTest.cs
+++ /dev/null
@@ -1,89 +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 System.Runtime.InteropServices;
-using System.Threading.Tasks;
-using Templates.Test.Helpers;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Templates.Test
-{
-    public class GrpcTemplateTest
-    {
-        public GrpcTemplateTest(ProjectFactoryFixture projectFactory, ITestOutputHelper output)
-        {
-            ProjectFactory = projectFactory;
-            Output = output;
-        }
-
-        public Project Project { get; set; }
-
-        public ProjectFactoryFixture ProjectFactory { get; }
-        public ITestOutputHelper Output { get; }
-
-        [Fact]
-        public async Task GrpcTemplate()
-        {
-            Project = await ProjectFactory.GetOrCreateProject("grpc", Output);
-
-            var createResult = await Project.RunDotNetNewAsync("grpc");
-            Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", Project, createResult));
-
-            var publishResult = await Project.RunDotNetPublishAsync();
-            Assert.True(0 == publishResult.ExitCode, ErrorMessages.GetFailedProcessMessage("publish", Project, publishResult));
-
-            var buildResult = await Project.RunDotNetBuildAsync();
-            Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", Project, buildResult));
-
-            using (var serverProcess = Project.StartBuiltProjectAsync())
-            {
-                // These templates are HTTPS + HTTP/2 only which is not supported on Mac due to missing ALPN support.
-                // https://github.com/aspnet/AspNetCore/issues/11061
-                if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
-                {
-                    Assert.True(serverProcess.Process.HasExited, "built");
-                    Assert.Contains("System.NotSupportedException: HTTP/2 over TLS is not supported on macOS due to missing ALPN support.",
-                        ErrorMessages.GetFailedProcessMessageOrEmpty("Run built service", Project, serverProcess.Process));
-                }
-                else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && Environment.OSVersion.Version < new Version(6, 2))
-                {
-                    Assert.True(serverProcess.Process.HasExited, "built");
-                    Assert.Contains("System.NotSupportedException: HTTP/2 over TLS is not supported on Windows 7 due to missing ALPN support.",
-                        ErrorMessages.GetFailedProcessMessageOrEmpty("Run built service", Project, serverProcess.Process));
-                }
-                else
-                {
-                    Assert.False(
-                        serverProcess.Process.HasExited,
-                        ErrorMessages.GetFailedProcessMessageOrEmpty("Run built service", Project, serverProcess.Process));
-                }
-            }
-
-            using (var aspNetProcess = Project.StartPublishedProjectAsync())
-            {
-                // These templates are HTTPS + HTTP/2 only which is not supported on Mac due to missing ALPN support.
-                // https://github.com/aspnet/AspNetCore/issues/11061
-                if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
-                {
-                    Assert.True(aspNetProcess.Process.HasExited, "published");
-                    Assert.Contains("System.NotSupportedException: HTTP/2 over TLS is not supported on macOS due to missing ALPN support.",
-                        ErrorMessages.GetFailedProcessMessageOrEmpty("Run published service", Project, aspNetProcess.Process));
-                }
-                else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && Environment.OSVersion.Version < new Version(6, 2))
-                {
-                    Assert.True(aspNetProcess.Process.HasExited, "published");
-                    Assert.Contains("System.NotSupportedException: HTTP/2 over TLS is not supported on Windows 7 due to missing ALPN support.",
-                        ErrorMessages.GetFailedProcessMessageOrEmpty("Run published service", Project, aspNetProcess.Process));
-                }
-                else
-                {
-                    Assert.False(
-                        aspNetProcess.Process.HasExited,
-                        ErrorMessages.GetFailedProcessMessageOrEmpty("Run published service", Project, aspNetProcess.Process));
-                }
-            }
-        }
-    }
-}
diff --git a/src/ProjectTemplates/test/Helpers/AspNetProcess.cs b/src/ProjectTemplates/test/Helpers/AspNetProcess.cs
index c4415bf55f0d48c9ea7c033a391a8715b9726476..753eb1258a521708fd1d1ca28ab47d0458411630 100644
--- a/src/ProjectTemplates/test/Helpers/AspNetProcess.cs
+++ b/src/ProjectTemplates/test/Helpers/AspNetProcess.cs
@@ -51,8 +51,7 @@ namespace Templates.Test.Helpers
                 Timeout = TimeSpan.FromMinutes(2)
             };
 
-            var now = DateTimeOffset.Now;
-            new CertificateManager().EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
+            EnsureDevelopmentCertificates();
 
             output.WriteLine("Running ASP.NET application...");
 
@@ -64,6 +63,12 @@ namespace Templates.Test.Helpers
             }
         }
 
+        internal static void EnsureDevelopmentCertificates()
+        {
+            var now = DateTimeOffset.Now;
+            new CertificateManager().EnsureAspNetCoreHttpsDevelopmentCertificate(now, now.AddYears(1));
+        }
+
         public void VisitInBrowser(IWebDriver driver)
         {
             _output.WriteLine($"Opening browser at {ListeningUri}...");
diff --git a/src/ProjectTemplates/test/Helpers/Project.cs b/src/ProjectTemplates/test/Helpers/Project.cs
index fc6923ae5c59da67ccaf3daad2fed2945e2138cd..19f1d46b7f9599dddef34d8be9fa3f2a4de84296 100644
--- a/src/ProjectTemplates/test/Helpers/Project.cs
+++ b/src/ProjectTemplates/test/Helpers/Project.cs
@@ -21,13 +21,10 @@ namespace Templates.Test.Helpers
     {
         private const string _urls = "http://127.0.0.1:0;https://127.0.0.1:0";
 
-        public const string DefaultFramework = "netcoreapp3.1";
-
         public static bool IsCIEnvironment => typeof(Project).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
             .Any(a => a.Key == "ContinuousIntegrationBuild");
 
-        public static string ArtifactsLogDir => typeof(Project).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
-            .Single(a => a.Key == "ArtifactsLogDir")?.Value;
+        public static string ArtifactsLogDir => GetAssemblyMetadata("ArtifactsLogDir");
 
         public SemaphoreSlim DotNetNewLock { get; set; }
         public SemaphoreSlim NodeLock { get; set; }
@@ -35,14 +32,16 @@ namespace Templates.Test.Helpers
         public string ProjectArguments { get; set; }
         public string ProjectGuid { get; set; }
         public string TemplateOutputDir { get; set; }
-        public string TemplateBuildDir => Path.Combine(TemplateOutputDir, "bin", "Debug", DefaultFramework);
-        public string TemplatePublishDir => Path.Combine(TemplateOutputDir, "bin", "Release", DefaultFramework, "publish");
+        public string TargetFramework { get; set; } = GetAssemblyMetadata("Test.DefaultTargetFramework");
+
+        public string TemplateBuildDir => Path.Combine(TemplateOutputDir, "bin", "Debug", TargetFramework);
+        public string TemplatePublishDir => Path.Combine(TemplateOutputDir, "bin", "Release", TargetFramework, "publish");
 
         private string TemplateServerDir => Path.Combine(TemplateOutputDir, $"{ProjectName}.Server");
         private string TemplateClientDir => Path.Combine(TemplateOutputDir, $"{ProjectName}.Client");
-        public string TemplateClientDebugDir => Path.Combine(TemplateClientDir, "bin", "Debug", DefaultFramework);
-        public string TemplateClientReleaseDir => Path.Combine(TemplateClientDir, "bin", "Release", DefaultFramework, "publish");
-        public string TemplateServerReleaseDir => Path.Combine(TemplateServerDir, "bin", "Release", DefaultFramework, "publish");
+        public string TemplateClientDebugDir => Path.Combine(TemplateClientDir, "bin", "Debug", TargetFramework);
+        public string TemplateClientReleaseDir => Path.Combine(TemplateClientDir, "bin", "Release", TargetFramework, "publish");
+        public string TemplateServerReleaseDir => Path.Combine(TemplateServerDir, "bin", "Release", TargetFramework, "publish");
 
         public ITestOutputHelper Output { get; set; }
         public IMessageSink DiagnosticsMessageSink { get; set; }
@@ -110,7 +109,7 @@ namespace Templates.Test.Helpers
             }
         }
 
-        internal async Task<ProcessEx> RunDotNetPublishAsync(bool takeNodeLock = false, IDictionary<string,string> packageOptions = null)
+        internal async Task<ProcessEx> RunDotNetPublishAsync(bool takeNodeLock = false, IDictionary<string, string> packageOptions = null, string additionalArgs = null)
         {
             Output.WriteLine("Publishing ASP.NET application...");
 
@@ -121,7 +120,7 @@ namespace Templates.Test.Helpers
             await effectiveLock.WaitAsync();
             try
             {
-                var result = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), $"publish -c Release /bl", packageOptions);
+                var result = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), $"publish -c Release /bl /nr:false {additionalArgs}", packageOptions);
                 await result.Exited;
                 CaptureBinLogOnFailure(result);
                 return result;
@@ -132,7 +131,7 @@ namespace Templates.Test.Helpers
             }
         }
 
-        internal async Task<ProcessEx> RunDotNetBuildAsync(bool takeNodeLock = false, IDictionary<string,string> packageOptions = null)
+        internal async Task<ProcessEx> RunDotNetBuildAsync(bool takeNodeLock = false, IDictionary<string, string> packageOptions = null, string additionalArgs = null)
         {
             Output.WriteLine("Building ASP.NET application...");
 
@@ -143,7 +142,7 @@ namespace Templates.Test.Helpers
             await effectiveLock.WaitAsync();
             try
             {
-                var result = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), "build -c Debug /bl", packageOptions);
+                var result = ProcessEx.Run(Output, TemplateOutputDir, DotNetMuxer.MuxerPathOrDefault(), $"build -c Debug /bl /nr:false {additionalArgs}", packageOptions);
                 await result.Exited;
                 CaptureBinLogOnFailure(result);
                 return result;
@@ -211,7 +210,7 @@ namespace Templates.Test.Helpers
             };
 
             var projectDll = Path.Combine(TemplateBuildDir, $"{ProjectName}.dll");
-            return new AspNetProcess(Output, TemplateOutputDir, projectDll, environment, hasListeningUri: hasListeningUri);
+            return new AspNetProcess(Output, TemplateOutputDir, projectDll, environment, published: false, hasListeningUri: hasListeningUri);
         }
 
         internal AspNetProcess StartPublishedProjectAsync(bool hasListeningUri = true)
@@ -524,5 +523,18 @@ namespace Templates.Test.Helpers
         }
 
         public override string ToString() => $"{ProjectName}: {TemplateOutputDir}";
+
+        private static string GetAssemblyMetadata(string key)
+        {
+            var attribute = typeof(Project).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()
+                .FirstOrDefault(a => a.Key == key);
+
+            if (attribute is null)
+            {
+                throw new ArgumentException($"AssemblyMetadataAttribute with key {key} was not found.");
+            }
+
+            return attribute.Value;
+        }
     }
 }
diff --git a/src/ProjectTemplates/test/Helpers/TemplatePackageInstaller.cs b/src/ProjectTemplates/test/Helpers/TemplatePackageInstaller.cs
index 2c3425959208d2714ffee75a09aa1700c6b398e3..5d6985f691d8febfda248810d85e8836691402cf 100644
--- a/src/ProjectTemplates/test/Helpers/TemplatePackageInstaller.cs
+++ b/src/ProjectTemplates/test/Helpers/TemplatePackageInstaller.cs
@@ -36,7 +36,8 @@ namespace Templates.Test.Helpers
             "Microsoft.DotNet.Web.Spa.ProjectTemplates.2.2",
             "Microsoft.DotNet.Web.Spa.ProjectTemplates.3.0",
             "Microsoft.DotNet.Web.Spa.ProjectTemplates.3.1",
-            "Microsoft.DotNet.Web.Spa.ProjectTemplates"
+            "Microsoft.DotNet.Web.Spa.ProjectTemplates",
+            "Microsoft.AspNetCore.Blazor.Templates",
         };
 
         public static string CustomHivePath { get; } = typeof(TemplatePackageInstaller)
@@ -86,7 +87,7 @@ namespace Templates.Test.Helpers
                 .Where(p => _templatePackages.Any(t => Path.GetFileName(p).StartsWith(t, StringComparison.OrdinalIgnoreCase)))
                 .ToArray();
 
-            Assert.Equal(4, builtPackages.Length);
+            Assert.Equal(5, builtPackages.Length);
 
             /*
              * The templates are indexed by path, for example:
diff --git a/src/ProjectTemplates/test/IdentityUIPackageTest.cs b/src/ProjectTemplates/test/IdentityUIPackageTest.cs
deleted file mode 100644
index deede64521a5111a35b4a21f3f1b783eedc54244..0000000000000000000000000000000000000000
--- a/src/ProjectTemplates/test/IdentityUIPackageTest.cs
+++ /dev/null
@@ -1,190 +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.Collections.Generic;
-using System.IO;
-using System.Net;
-using System.Threading.Tasks;
-using Templates.Test.Helpers;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Templates.Test
-{
-    public class IdentityUIPackageTest
-    {
-        public IdentityUIPackageTest(ProjectFactoryFixture projectFactory, ITestOutputHelper output)
-        {
-            ProjectFactory = projectFactory;
-            Output = output;
-        }
-
-        public Project Project { get; set; }
-
-        public ProjectFactoryFixture ProjectFactory { get; set; }
-
-        public ITestOutputHelper Output { get; }
-
-        public static TheoryData<IDictionary<string, string>, string, string[]> MSBuildIdentityUIPackageOptions
-        {
-            get
-            {
-                var data = new TheoryData<IDictionary<string, string>, string, string[]>();
-
-                data.Add(new Dictionary<string, string>
-                {
-                    ["IdentityUIFrameworkVersion"] = "Bootstrap3"
-                },
-                "Bootstrap v3.4.1",
-                Bootstrap3ContentFiles);
-
-                data.Add(new Dictionary<string, string>(), "Bootstrap v4.3.1", Bootstrap4ContentFiles);
-
-                return data;
-            }
-        }
-
-        public static string[] Bootstrap3ContentFiles { get; } = new string[]
-        {
-            "Identity/css/site.css",
-            "Identity/js/site.js",
-            "Identity/lib/bootstrap/dist/css/bootstrap-theme.css",
-            "Identity/lib/bootstrap/dist/css/bootstrap-theme.css.map",
-            "Identity/lib/bootstrap/dist/css/bootstrap-theme.min.css",
-            "Identity/lib/bootstrap/dist/css/bootstrap-theme.min.css.map",
-            "Identity/lib/bootstrap/dist/css/bootstrap.css",
-            "Identity/lib/bootstrap/dist/css/bootstrap.css.map",
-            "Identity/lib/bootstrap/dist/css/bootstrap.min.css",
-            "Identity/lib/bootstrap/dist/css/bootstrap.min.css.map",
-            "Identity/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.eot",
-            "Identity/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.svg",
-            "Identity/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.ttf",
-            "Identity/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.woff",
-            "Identity/lib/bootstrap/dist/fonts/glyphicons-halflings-regular.woff2",
-            "Identity/lib/bootstrap/dist/js/bootstrap.js",
-            "Identity/lib/bootstrap/dist/js/bootstrap.min.js",
-            "Identity/lib/bootstrap/dist/js/npm.js",
-            "Identity/lib/jquery/LICENSE.txt",
-            "Identity/lib/jquery/dist/jquery.js",
-            "Identity/lib/jquery/dist/jquery.min.js",
-            "Identity/lib/jquery/dist/jquery.min.map",
-            "Identity/lib/jquery-validation/LICENSE.md",
-            "Identity/lib/jquery-validation/dist/additional-methods.js",
-            "Identity/lib/jquery-validation/dist/additional-methods.min.js",
-            "Identity/lib/jquery-validation/dist/jquery.validate.js",
-            "Identity/lib/jquery-validation/dist/jquery.validate.min.js",
-            "Identity/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js",
-            "Identity/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js",
-            "Identity/lib/jquery-validation-unobtrusive/LICENSE.txt",
-        };
-
-        public static string[] Bootstrap4ContentFiles { get; } = new string[]
-        {
-            "Identity/favicon.ico",
-            "Identity/css/site.css",
-            "Identity/js/site.js",
-            "Identity/lib/bootstrap/dist/css/bootstrap-grid.css",
-            "Identity/lib/bootstrap/dist/css/bootstrap-grid.css.map",
-            "Identity/lib/bootstrap/dist/css/bootstrap-grid.min.css",
-            "Identity/lib/bootstrap/dist/css/bootstrap-grid.min.css.map",
-            "Identity/lib/bootstrap/dist/css/bootstrap-reboot.css",
-            "Identity/lib/bootstrap/dist/css/bootstrap-reboot.css.map",
-            "Identity/lib/bootstrap/dist/css/bootstrap-reboot.min.css",
-            "Identity/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map",
-            "Identity/lib/bootstrap/dist/css/bootstrap.css",
-            "Identity/lib/bootstrap/dist/css/bootstrap.css.map",
-            "Identity/lib/bootstrap/dist/css/bootstrap.min.css",
-            "Identity/lib/bootstrap/dist/css/bootstrap.min.css.map",
-            "Identity/lib/bootstrap/dist/js/bootstrap.bundle.js",
-            "Identity/lib/bootstrap/dist/js/bootstrap.bundle.js.map",
-            "Identity/lib/bootstrap/dist/js/bootstrap.bundle.min.js",
-            "Identity/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map",
-            "Identity/lib/bootstrap/dist/js/bootstrap.js",
-            "Identity/lib/bootstrap/dist/js/bootstrap.js.map",
-            "Identity/lib/bootstrap/dist/js/bootstrap.min.js",
-            "Identity/lib/bootstrap/dist/js/bootstrap.min.js.map",
-            "Identity/lib/jquery/LICENSE.txt",
-            "Identity/lib/jquery/dist/jquery.js",
-            "Identity/lib/jquery/dist/jquery.min.js",
-            "Identity/lib/jquery/dist/jquery.min.map",
-            "Identity/lib/jquery-validation/LICENSE.md",
-            "Identity/lib/jquery-validation/dist/additional-methods.js",
-            "Identity/lib/jquery-validation/dist/additional-methods.min.js",
-            "Identity/lib/jquery-validation/dist/jquery.validate.js",
-            "Identity/lib/jquery-validation/dist/jquery.validate.min.js",
-            "Identity/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js",
-            "Identity/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js",
-            "Identity/lib/jquery-validation-unobtrusive/LICENSE.txt",
-        };
-
-        [Theory]
-        [MemberData(nameof(MSBuildIdentityUIPackageOptions))]
-        public async Task IdentityUIPackage_WorksWithDifferentOptions(IDictionary<string, string> packageOptions, string versionValidator, string[] expectedFiles)
-        {
-            Project = await ProjectFactory.GetOrCreateProject("identityuipackage" + string.Concat(packageOptions.Values), Output);
-            var useLocalDB = false;
-
-            var createResult = await Project.RunDotNetNewAsync("razor", auth: "Individual", useLocalDB: useLocalDB, environmentVariables: packageOptions);
-            Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", Project, createResult));
-
-            var projectFileContents = ReadFile(Project.TemplateOutputDir, $"{Project.ProjectName}.csproj");
-            Assert.Contains(".db", projectFileContents);
-
-            var publishResult = await Project.RunDotNetPublishAsync(packageOptions: packageOptions);
-            Assert.True(0 == publishResult.ExitCode, ErrorMessages.GetFailedProcessMessage("publish", Project, publishResult));
-
-            // Run dotnet build after publish. The reason is that one uses Config = Debug and the other uses Config = Release
-            // The output from publish will go into bin/Release/netcoreappX.Y/publish and won't be affected by calling build
-            // later, while the opposite is not true.
-
-            var buildResult = await Project.RunDotNetBuildAsync(packageOptions: packageOptions);
-            Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", Project, buildResult));
-
-            var migrationsResult = await Project.RunDotNetEfCreateMigrationAsync("razorpages");
-            Assert.True(0 == migrationsResult.ExitCode, ErrorMessages.GetFailedProcessMessage("run EF migrations", Project, migrationsResult));
-            Project.AssertEmptyMigration("razorpages");
-
-            using (var aspNetProcess = Project.StartBuiltProjectAsync())
-            {
-                Assert.False(
-                    aspNetProcess.Process.HasExited,
-                    ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", Project, aspNetProcess.Process));
-
-                var response = await aspNetProcess.SendRequest("/Identity/lib/bootstrap/dist/css/bootstrap.css");
-                Assert.Equal(HttpStatusCode.OK, response.StatusCode);
-                Assert.Contains(versionValidator, await response.Content.ReadAsStringAsync());
-                await ValidatePublishedFiles(aspNetProcess, expectedFiles);
-            }
-
-            using (var aspNetProcess = Project.StartPublishedProjectAsync())
-            {
-                Assert.False(
-                    aspNetProcess.Process.HasExited,
-                    ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", Project, aspNetProcess.Process));
-
-                var response = await aspNetProcess.SendRequest("/Identity/lib/bootstrap/dist/css/bootstrap.css");
-                Assert.Equal(HttpStatusCode.OK, response.StatusCode);
-                Assert.Contains(versionValidator, await response.Content.ReadAsStringAsync());
-                await ValidatePublishedFiles(aspNetProcess, expectedFiles);
-            }
-        }
-
-        private async Task ValidatePublishedFiles(AspNetProcess aspNetProcess, string[] expectedContentFiles)
-        {
-            foreach (var file in expectedContentFiles)
-            {
-                var response = await aspNetProcess.SendRequest(file);
-                Assert.True(response?.StatusCode == HttpStatusCode.OK, $"Couldn't find file '{file}'");
-            }
-        }
-
-        private string ReadFile(string basePath, string path)
-        {
-            var fullPath = Path.Combine(basePath, path);
-            var doesExist = File.Exists(fullPath);
-
-            Assert.True(doesExist, $"Expected file to exist, but it doesn't: {path}");
-            return File.ReadAllText(Path.Combine(basePath, path));
-        }
-    }
-}
diff --git a/src/ProjectTemplates/test/Infrastructure/GenerateTestProps.targets b/src/ProjectTemplates/test/Infrastructure/GenerateTestProps.targets
index 948108395aaa1444b3f7076a90083bf101d702d2..6beacfa4d81568fb533f9d15e9e15cb32317db4c 100644
--- a/src/ProjectTemplates/test/Infrastructure/GenerateTestProps.targets
+++ b/src/ProjectTemplates/test/Infrastructure/GenerateTestProps.targets
@@ -16,6 +16,9 @@
     <PropertyGroup>
       <MicrosoftAspNetCoreAppRefPackageVersion Condition="'$(IsTargetingPackBuilding)' != 'false'">%(_TargetingPackVersionInfo.PackageVersion)</MicrosoftAspNetCoreAppRefPackageVersion>
       <MicrosoftAspNetCoreAppRefPackageVersion Condition="'$(IsTargetingPackBuilding)' == 'false'">$(TargetingPackVersionPrefix)</MicrosoftAspNetCoreAppRefPackageVersion>
+
+    <!-- For blazor-wasm we want the runtime to match the baseline, as we want to target the latest released asp.net core app -->
+    <MicrosoftAspNetCoreAppRuntimePackageVersion>$(AspNetCoreBaselineVersion)</MicrosoftAspNetCoreAppRuntimePackageVersion>
     </PropertyGroup>
 
     <!-- Runtime and Ref packs may have separate versions. -->
@@ -34,7 +37,7 @@
         MicrosoftNETCorePlatformsPackageVersion=$(MicrosoftNETCorePlatformsPackageVersion);
         MicrosoftNETSdkRazorPackageVersion=$(MicrosoftNETSdkRazorPackageVersion);
         MicrosoftAspNetCoreAppRefPackageVersion=$(MicrosoftAspNetCoreAppRefPackageVersion);
-        MicrosoftAspNetCoreAppRuntimePackageVersion=@(_RuntimePackageVersionInfo->'%(PackageVersion)');
+        MicrosoftAspNetCoreAppRuntimePackageVersion=$(MicrosoftAspNetCoreAppRuntimePackageVersion);
         SupportedRuntimeIdentifiers=$(SupportedRuntimeIdentifiers);
         DefaultNetCoreTargetFramework=$(DefaultNetCoreTargetFramework);
       </PropsProperties>
diff --git a/src/ProjectTemplates/test/ItemTemplateTests/BlazorServerTests.cs b/src/ProjectTemplates/test/ItemTemplateTests/BlazorServerTests.cs
deleted file mode 100644
index 842ce8a5d9a8f1c27fcfcc2c92f5bdb5c81a4c06..0000000000000000000000000000000000000000
--- a/src/ProjectTemplates/test/ItemTemplateTests/BlazorServerTests.cs
+++ /dev/null
@@ -1,36 +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.Threading.Tasks;
-using Templates.Test.Helpers;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Templates.Items.Test
-{
-    public class BlazorServerTest
-    {
-        public BlazorServerTest(ProjectFactoryFixture projectFactory, ITestOutputHelper output)
-        {
-            ProjectFactory = projectFactory;
-            Output = output;
-        }
-
-        public Project Project { get; set; }
-
-        public ProjectFactoryFixture ProjectFactory { get; }
-        public ITestOutputHelper Output { get; }
-
-        [Fact]
-        public async Task BlazorServerItemTemplate()
-        {
-            Project = await ProjectFactory.GetOrCreateProject("razorcomponentitem", Output);
-
-            var createResult = await Project.RunDotNetNewAsync("razorcomponent --name Different");
-            Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create", Project, createResult));
-
-            Project.AssertFileExists("Different.razor", shouldExist: true);
-            Assert.Contains("<h3>Different</h3>", Project.ReadFile("Different.razor"));
-        }
-    }
-}
diff --git a/src/ProjectTemplates/test/ProjectTemplates.Tests.csproj b/src/ProjectTemplates/test/ProjectTemplates.Tests.csproj
index 5ed506cf28cf5236431e453e71645d63a2a66c68..bc280ce2a3d7bde22efd743da392c7cdb1c39c13 100644
--- a/src/ProjectTemplates/test/ProjectTemplates.Tests.csproj
+++ b/src/ProjectTemplates/test/ProjectTemplates.Tests.csproj
@@ -47,6 +47,7 @@
     <ProjectReference Include="../Web.ItemTemplates/Microsoft.DotNet.Web.ItemTemplates.csproj" ReferenceOutputAssembly="false" />
     <ProjectReference Include="../Web.ProjectTemplates/Microsoft.DotNet.Web.ProjectTemplates.csproj" ReferenceOutputAssembly="false" />
     <ProjectReference Include="../Web.Spa.ProjectTemplates/Microsoft.DotNet.Web.Spa.ProjectTemplates.csproj" ReferenceOutputAssembly="false" />
+    <ProjectReference Include="../BlazorWasm.ProjectTemplates/Microsoft.AspNetCore.Blazor.Templates.csproj" ReferenceOutputAssembly="false" />
   </ItemGroup>
 
   <ItemGroup>
@@ -58,6 +59,10 @@
       <_Parameter1>TestPackageRestorePath</_Parameter1>
       <_Parameter2>$(TestPackageRestorePath)</_Parameter2>
     </AssemblyAttribute>
+    <AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
+      <_Parameter1>Test.DefaultTargetFramework</_Parameter1>
+      <_Parameter2>$(DefaultNetCoreTargetFramework)</_Parameter2>
+    </AssemblyAttribute>
     <AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute" Condition="'$(ContinuousIntegrationBuild)' == 'true'">
       <_Parameter1>ContinuousIntegrationBuild</_Parameter1>
       <_Parameter2>true</_Parameter2>
@@ -81,7 +86,7 @@
         <_Parameter1>ArtifactsLogDir</_Parameter1>
         <_Parameter2>$([MSBuild]::NormalizeDirectory('$(ArtifactsDir)', 'log'))</_Parameter2>
       </AssemblyAttribute>
-      
+
       <AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
         <_Parameter1>ArtifactsNonShippingPackagesDir</_Parameter1>
         <_Parameter2>$(ArtifactsNonShippingPackagesDir)</_Parameter2>
diff --git a/src/ProjectTemplates/test/RazorClassLibraryTemplateTest.cs b/src/ProjectTemplates/test/RazorClassLibraryTemplateTest.cs
deleted file mode 100644
index 0d02a56f8f84a517d3babb413efdb4da48156858..0000000000000000000000000000000000000000
--- a/src/ProjectTemplates/test/RazorClassLibraryTemplateTest.cs
+++ /dev/null
@@ -1,62 +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.Threading.Tasks;
-using Templates.Test.Helpers;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Templates.Test
-{
-    public class RazorClassLibraryTemplateTest
-    {
-        public RazorClassLibraryTemplateTest(ProjectFactoryFixture projectFactory, ITestOutputHelper output)
-        {
-            ProjectFactory = projectFactory;
-            Output = output;
-        }
-
-        public Project Project { get; set; }
-
-        public ProjectFactoryFixture ProjectFactory { get; }
-        public ITestOutputHelper Output { get; }
-
-        [Fact]
-        public async Task RazorClassLibraryTemplate_WithViews_Async()
-        {
-            Project = await ProjectFactory.GetOrCreateProject("razorclasslibwithviews", Output);
-
-            var createResult = await Project.RunDotNetNewAsync("razorclasslib", args: new[] { "--support-pages-and-views", "true" });
-            Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", Project, createResult));
-
-            var publishResult = await Project.RunDotNetPublishAsync();
-            Assert.True(0 == publishResult.ExitCode, ErrorMessages.GetFailedProcessMessage("publish", Project, publishResult));
-
-            // Run dotnet build after publish. The reason is that one uses Config = Debug and the other uses Config = Release
-            // The output from publish will go into bin/Release/netcoreappX.Y/publish and won't be affected by calling build
-            // later, while the opposite is not true.
-
-            var buildResult = await Project.RunDotNetBuildAsync();
-            Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", Project, buildResult));
-        }
-
-        [Fact]
-        public async Task RazorClassLibraryTemplateAsync()
-        {
-            Project = await ProjectFactory.GetOrCreateProject("razorclasslib", Output);
-
-            var createResult = await Project.RunDotNetNewAsync("razorclasslib");
-            Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", Project, createResult));
-
-            var publishResult = await Project.RunDotNetPublishAsync();
-            Assert.True(0 == publishResult.ExitCode, ErrorMessages.GetFailedProcessMessage("publish", Project, publishResult));
-
-            // Run dotnet build after publish. The reason is that one uses Config = Debug and the other uses Config = Release
-            // The output from publish will go into bin/Release/netcoreappX.Y/publish and won't be affected by calling build
-            // later, while the opposite is not true.
-
-            var buildResult = await Project.RunDotNetBuildAsync();
-            Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", Project, buildResult));
-        }
-    }
-}
diff --git a/src/ProjectTemplates/test/SpaTemplateTest/AngularTemplateTest.cs b/src/ProjectTemplates/test/SpaTemplateTest/AngularTemplateTest.cs
deleted file mode 100644
index e1d5db13388e73af2eb9635de8ffee277cf7b8df..0000000000000000000000000000000000000000
--- a/src/ProjectTemplates/test/SpaTemplateTest/AngularTemplateTest.cs
+++ /dev/null
@@ -1,30 +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.Threading.Tasks;
-using Microsoft.AspNetCore.E2ETesting;
-using Microsoft.AspNetCore.Testing;
-using Templates.Test.Helpers;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Templates.Test.SpaTemplateTest
-{
-    public class AngularTemplateTest : SpaTemplateTestBase
-    {
-        public AngularTemplateTest(ProjectFactoryFixture projectFactory, BrowserFixture browserFixture, ITestOutputHelper output)
-            : base(projectFactory, browserFixture, output) { }
-
-        [Fact]
-        public Task AngularTemplate_Works()
-            => SpaTemplateImplAsync("angularnoauth", "angular", useLocalDb: false, usesAuth: false);
-
-        [Fact]
-        public Task AngularTemplate_IndividualAuth_Works()
-            => SpaTemplateImplAsync("angularindividual", "angular", useLocalDb: false, usesAuth: true);
-
-        [Fact]
-        public Task AngularTemplate_IndividualAuth_Works_LocalDb()
-            => SpaTemplateImplAsync("angularindividualuld", "angular", useLocalDb: true, usesAuth: true);
-    }
-}
diff --git a/src/ProjectTemplates/test/SpaTemplateTest/ReactReduxTemplateTest.cs b/src/ProjectTemplates/test/SpaTemplateTest/ReactReduxTemplateTest.cs
deleted file mode 100644
index 44d6b67f32560b106aaab3800337d5dd8c4067e7..0000000000000000000000000000000000000000
--- a/src/ProjectTemplates/test/SpaTemplateTest/ReactReduxTemplateTest.cs
+++ /dev/null
@@ -1,23 +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.Threading.Tasks;
-using Microsoft.AspNetCore.E2ETesting;
-using Templates.Test.Helpers;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Templates.Test.SpaTemplateTest
-{
-    public class ReactReduxTemplateTest : SpaTemplateTestBase
-    {
-        public ReactReduxTemplateTest(ProjectFactoryFixture projectFactory, BrowserFixture browserFixture, ITestOutputHelper output)
-            : base(projectFactory, browserFixture, output)
-        {
-        }
-
-        [Fact]
-        public Task ReactReduxTemplate_Works_NetCore()
-            => SpaTemplateImplAsync("reactredux", "reactredux", useLocalDb: false, usesAuth: false);
-    }
-}
diff --git a/src/ProjectTemplates/test/SpaTemplateTest/ReactTemplateTest.cs b/src/ProjectTemplates/test/SpaTemplateTest/ReactTemplateTest.cs
deleted file mode 100644
index 469e87acd560b96bf3c010d9bfa3609e7af8ba4f..0000000000000000000000000000000000000000
--- a/src/ProjectTemplates/test/SpaTemplateTest/ReactTemplateTest.cs
+++ /dev/null
@@ -1,32 +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.Threading.Tasks;
-using Microsoft.AspNetCore.E2ETesting;
-using Microsoft.AspNetCore.Testing;
-using Templates.Test.Helpers;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Templates.Test.SpaTemplateTest
-{
-    public class ReactTemplateTest : SpaTemplateTestBase
-    {
-        public ReactTemplateTest(ProjectFactoryFixture projectFactory, BrowserFixture browserFixture, ITestOutputHelper output)
-            : base(projectFactory, browserFixture, output)
-        {
-        }
-
-        [Fact]
-        public Task ReactTemplate_Works_NetCore()
-            => SpaTemplateImplAsync("reactnoauth", "react", useLocalDb: false, usesAuth: false);
-
-        [Fact]
-        public Task ReactTemplate_IndividualAuth_NetCore()
-            => SpaTemplateImplAsync("reactindividual", "react", useLocalDb: false, usesAuth: true);
-
-        [Fact]
-        public Task ReactTemplate_IndividualAuth_NetCore_LocalDb()
-            => SpaTemplateImplAsync("reactindividualuld", "react", useLocalDb: true, usesAuth: true);
-    }
-}
diff --git a/src/ProjectTemplates/test/WebApiTemplateTest.cs b/src/ProjectTemplates/test/WebApiTemplateTest.cs
deleted file mode 100644
index 89d047a06e635801a05f5a23602df475f525e67e..0000000000000000000000000000000000000000
--- a/src/ProjectTemplates/test/WebApiTemplateTest.cs
+++ /dev/null
@@ -1,70 +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.Threading.Tasks;
-using Templates.Test.Helpers;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Templates.Test
-{
-    public class WebApiTemplateTest
-    {
-        public WebApiTemplateTest(ProjectFactoryFixture factoryFixture, ITestOutputHelper output)
-        {
-            FactoryFixture = factoryFixture;
-            Output = output;
-        }
-
-        public ProjectFactoryFixture FactoryFixture { get; }
-
-        public ITestOutputHelper Output { get; }
-
-        public Project Project { get; set; }
-
-        [Fact]
-        public async Task WebApiTemplateFSharp() => await WebApiTemplateCore(languageOverride: "F#");
-
-        [Fact]
-        public async Task WebApiTemplateCSharp() => await WebApiTemplateCore(languageOverride: null);
-
-        private async Task WebApiTemplateCore(string languageOverride)
-        {
-            Project = await FactoryFixture.GetOrCreateProject("webapi" + (languageOverride == "F#" ? "fsharp" : "csharp"), Output);
-
-            var createResult = await Project.RunDotNetNewAsync("webapi", language: languageOverride);
-            Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", Project, createResult));
-
-            var publishResult = await Project.RunDotNetPublishAsync();
-            Assert.True(0 == publishResult.ExitCode, ErrorMessages.GetFailedProcessMessage("publish", Project, publishResult));
-
-            // Run dotnet build after publish. The reason is that one uses Config = Debug and the other uses Config = Release
-            // The output from publish will go into bin/Release/netcoreappX.Y/publish and won't be affected by calling build
-            // later, while the opposite is not true.
-
-            var buildResult = await Project.RunDotNetBuildAsync();
-            Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", Project, buildResult));
-
-            using (var aspNetProcess = Project.StartBuiltProjectAsync())
-            {
-                Assert.False(
-                    aspNetProcess.Process.HasExited,
-                    ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", Project, aspNetProcess.Process));
-
-                await aspNetProcess.AssertOk("weatherforecast");
-                await aspNetProcess.AssertNotFound("/");
-            }
-
-            using (var aspNetProcess = Project.StartPublishedProjectAsync())
-            {
-                Assert.False(
-                    aspNetProcess.Process.HasExited,
-                    ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", Project, aspNetProcess.Process));
-
-
-                await aspNetProcess.AssertOk("weatherforecast");
-                await aspNetProcess.AssertNotFound("/");
-            }
-        }
-    }
-}
diff --git a/src/ProjectTemplates/test/WorkerTemplateTest.cs b/src/ProjectTemplates/test/WorkerTemplateTest.cs
deleted file mode 100644
index 738eafc61d6a9184a66a85e34ac994f62778bec6..0000000000000000000000000000000000000000
--- a/src/ProjectTemplates/test/WorkerTemplateTest.cs
+++ /dev/null
@@ -1,56 +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.Threading.Tasks;
-using Templates.Test.Helpers;
-using Xunit;
-using Xunit.Abstractions;
-
-namespace Templates.Test
-{
-    public class WorkerTemplateTest
-    {
-        public WorkerTemplateTest(ProjectFactoryFixture projectFactory, ITestOutputHelper output)
-        {
-            ProjectFactory = projectFactory;
-            Output = output;
-        }
-
-        public Project Project { get; set; }
-        public ProjectFactoryFixture ProjectFactory { get; }
-        public ITestOutputHelper Output { get; }
-
-        [Fact]
-        public async Task WorkerTemplateAsync()
-        {
-            Project = await ProjectFactory.GetOrCreateProject("worker", Output);
-
-            var createResult = await Project.RunDotNetNewAsync("worker");
-            Assert.True(0 == createResult.ExitCode, ErrorMessages.GetFailedProcessMessage("create/restore", Project, createResult));
-
-            var publishResult = await Project.RunDotNetPublishAsync();
-            Assert.True(0 == publishResult.ExitCode, ErrorMessages.GetFailedProcessMessage("publish", Project, publishResult));
-
-            // Run dotnet build after publish. The reason is that one uses Config = Debug and the other uses Config = Release
-            // The output from publish will go into bin/Release/netcoreappX.Y/publish and won't be affected by calling build
-            // later, while the opposite is not true.
-
-            var buildResult = await Project.RunDotNetBuildAsync();
-            Assert.True(0 == buildResult.ExitCode, ErrorMessages.GetFailedProcessMessage("build", Project, buildResult));
-
-            using (var aspNetProcess = Project.StartBuiltProjectAsync(hasListeningUri: false))
-            {
-                Assert.False(
-                    aspNetProcess.Process.HasExited,
-                    ErrorMessages.GetFailedProcessMessageOrEmpty("Run built project", Project, aspNetProcess.Process));
-            }
-
-            using (var aspNetProcess = Project.StartPublishedProjectAsync(hasListeningUri: false))
-            {
-                Assert.False(
-                    aspNetProcess.Process.HasExited,
-                    ErrorMessages.GetFailedProcessMessageOrEmpty("Run published project", Project, aspNetProcess.Process));
-            }
-        }
-    }
-}
diff --git a/src/Shared/E2ETesting/BrowserFixture.cs b/src/Shared/E2ETesting/BrowserFixture.cs
index 780817631863eadec5ca5f47ea6802f883090b94..bf8b31d29426246cba07b46833ef108c44b2f266 100644
--- a/src/Shared/E2ETesting/BrowserFixture.cs
+++ b/src/Shared/E2ETesting/BrowserFixture.cs
@@ -72,13 +72,24 @@ namespace Microsoft.AspNetCore.E2ETesting
 
         public Task<(IWebDriver, ILogs)> GetOrCreateBrowserAsync(ITestOutputHelper output, string isolationContext = "")
         {
-            if (!IsHostAutomationSupported())
+            Func<string, ITestOutputHelper, Task<(IWebDriver, ILogs)>> createBrowserFunc;
+            if (E2ETestOptions.Instance.SauceTest)
             {
-                output.WriteLine($"{nameof(BrowserFixture)}: Host does not support browser automation.");
-                return Task.FromResult<(IWebDriver, ILogs)>(default);
+                createBrowserFunc = CreateSauceBrowserAsync;
             }
+            else
+            {
+                if (!IsHostAutomationSupported())
+                {
+                    output.WriteLine($"{nameof(BrowserFixture)}: Host does not support browser automation.");
+                    return Task.FromResult<(IWebDriver, ILogs)>(default);
+                }
 
-            return _browsers.GetOrAdd(isolationContext, CreateBrowserAsync, output);
+                createBrowserFunc = CreateBrowserAsync;
+            }
+
+
+            return _browsers.GetOrAdd(isolationContext, createBrowserFunc, output);
         }
 
         public Task InitializeAsync() => Task.CompletedTask;
@@ -143,5 +154,106 @@ namespace Microsoft.AspNetCore.E2ETesting
 
             throw new InvalidOperationException("Couldn't create a Selenium remote driver client. The server is irresponsive");
         }
+
+        private async Task<(IWebDriver browser, ILogs log)> CreateSauceBrowserAsync(string context, ITestOutputHelper output)
+        {
+            var sauce = E2ETestOptions.Instance.Sauce;
+
+            if (sauce == null ||
+                string.IsNullOrEmpty(sauce.TestName) ||
+                string.IsNullOrEmpty(sauce.Username) ||
+                string.IsNullOrEmpty(sauce.AccessKey) ||
+                string.IsNullOrEmpty(sauce.TunnelIdentifier) ||
+                string.IsNullOrEmpty(sauce.PlatformName) ||
+                string.IsNullOrEmpty(sauce.BrowserName))
+            {
+                throw new InvalidOperationException("Required SauceLabs environment variables not set.");
+            }
+
+            var name = sauce.TestName;
+            if (!string.IsNullOrEmpty(context))
+            {
+                name = $"{name} - {context}";
+            }
+
+            var capabilities = new DesiredCapabilities();
+
+            // Required config
+            capabilities.SetCapability("username", sauce.Username);
+            capabilities.SetCapability("accessKey", sauce.AccessKey);
+            capabilities.SetCapability("tunnelIdentifier", sauce.TunnelIdentifier);
+            capabilities.SetCapability("name", name);
+
+            if (!string.IsNullOrEmpty(sauce.BrowserName))
+            {
+                capabilities.SetCapability("browserName", sauce.BrowserName);
+            }
+
+            if (!string.IsNullOrEmpty(sauce.PlatformVersion))
+            {
+                capabilities.SetCapability("platformName", sauce.PlatformName);
+                capabilities.SetCapability("platformVersion", sauce.PlatformVersion);
+            }
+            else
+            {
+                // In some cases (like macOS), SauceLabs expects us to set "platform" instead of "platformName".
+                capabilities.SetCapability("platform", sauce.PlatformName);
+            }
+
+            if (!string.IsNullOrEmpty(sauce.BrowserVersion))
+            {
+                capabilities.SetCapability("browserVersion", sauce.BrowserVersion);
+            }
+
+            if (!string.IsNullOrEmpty(sauce.DeviceName))
+            {
+                capabilities.SetCapability("deviceName", sauce.DeviceName);
+            }
+
+            if (!string.IsNullOrEmpty(sauce.DeviceOrientation))
+            {
+                capabilities.SetCapability("deviceOrientation", sauce.DeviceOrientation);
+            }
+
+            if (!string.IsNullOrEmpty(sauce.AppiumVersion))
+            {
+                capabilities.SetCapability("appiumVersion", sauce.AppiumVersion);
+            }
+
+            if (!string.IsNullOrEmpty(sauce.SeleniumVersion))
+            {
+                capabilities.SetCapability("seleniumVersion", sauce.SeleniumVersion);
+            }
+
+            await SauceConnectServer.StartAsync(output);
+
+            var attempt = 0;
+            const int maxAttempts = 3;
+            do
+            {
+                try
+                {
+                    // Attempt to create a new browser in SauceLabs.
+                    var driver = new RemoteWebDriver(
+                        new Uri("http://localhost:4445/wd/hub"),
+                        capabilities,
+                        TimeSpan.FromSeconds(60).Add(TimeSpan.FromSeconds(attempt * 60)));
+
+                    driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(1);
+                    var logs = new RemoteLogs(driver);
+
+                    return (driver, logs);
+                }
+                catch (Exception ex)
+                {
+                    output.WriteLine($"Error initializing RemoteWebDriver: {ex.Message}");
+                }
+
+                attempt++;
+
+            } while (attempt < maxAttempts);
+
+            throw new InvalidOperationException("Couldn't create a SauceLabs remote driver client.");
+        }
     }
 }
diff --git a/src/Shared/E2ETesting/E2ETestOptions.cs b/src/Shared/E2ETesting/E2ETestOptions.cs
index 0b353201761e58ab5560e5be5e68fd4cbca552bc..4be16ef7799ba3a4472bfcd25bb6fb3ec3ce85b5 100644
--- a/src/Shared/E2ETesting/E2ETestOptions.cs
+++ b/src/Shared/E2ETesting/E2ETestOptions.cs
@@ -39,7 +39,7 @@ namespace Microsoft.AspNetCore.E2ETesting
                 }
 
                 Configuration = builder 
-                    .AddEnvironmentVariables("E2ETESTS")
+                    .AddEnvironmentVariables("E2ETESTS_")
                     .Build();
 
                 var instance = new E2ETestOptions();
@@ -56,5 +56,9 @@ namespace Microsoft.AspNetCore.E2ETesting
         public string ScreenShotsPath { get; set; }
 
         public double DefaultAfterFailureWaitTimeoutInSeconds { get; set; } = 3;
+
+        public bool SauceTest { get; set; }
+
+        public SauceOptions Sauce { get; set; }
     }
 }
diff --git a/src/Shared/E2ETesting/E2ETesting.props b/src/Shared/E2ETesting/E2ETesting.props
index 82769181de3c118eb9bf31b1474a0fe8533a072a..47eff5955bfaa550cbc888ad2535a6065f1922d7 100644
--- a/src/Shared/E2ETesting/E2ETesting.props
+++ b/src/Shared/E2ETesting/E2ETesting.props
@@ -5,6 +5,7 @@
     <SeleniumScreenShotsFolderPath>$([MSBuild]::NormalizeDirectory('$(ArtifactsTestResultsDir)','$(MSBuildProjectName)'))</SeleniumScreenShotsFolderPath>
     <SeleniumProcessTrackingFolder Condition="'$(SeleniumProcessTrackingFolder)' == ''">$([MSBuild]::EnsureTrailingSlash('$(RepoRoot)'))artifacts\tmp\selenium\</SeleniumProcessTrackingFolder>
     <SeleniumE2ETestsSupported Condition="'$(SeleniumE2ETestsSupported)' == '' and '$(TargetArchitecture)' != 'arm' and '$(TargetArchitecture)' != 'arm64' and '$(OS)' == 'Windows_NT'">true</SeleniumE2ETestsSupported>
+    <SauceConnectProcessTrackingFolder Condition="'$(SauceConnectProcessTrackingFolder)' == ''">$([MSBuild]::EnsureTrailingSlash('$(RepoRoot)'))artifacts\tmp\sauceconnect\</SauceConnectProcessTrackingFolder>
 
     <!-- We want to enforce prerequisites when we build from the CI or within Visual Studio -->
     <EnforcedE2EBuildEnvironment Condition="'$(ContinuousIntegrationBuild)' == 'true' or '$(BuildingInsideVisualStudio)' == 'true'">true</EnforcedE2EBuildEnvironment>
diff --git a/src/Shared/E2ETesting/E2ETesting.targets b/src/Shared/E2ETesting/E2ETesting.targets
index 500d910a300b8d4f179ef0c617b97b24eb78cfc9..1cb421de3bba24fe99037a2119e6ec7fde4a570c 100644
--- a/src/Shared/E2ETesting/E2ETesting.targets
+++ b/src/Shared/E2ETesting/E2ETesting.targets
@@ -121,6 +121,14 @@
         <_Parameter2>$(SeleniumProcessTrackingFolder)</_Parameter2>
       </AssemblyAttribute>
     </ItemGroup>
+    <MakeDir Directories="$(SauceConnectProcessTrackingFolder)" />
+    <ItemGroup>
+      <AssemblyAttribute
+        Include="System.Reflection.AssemblyMetadataAttribute">
+        <_Parameter1>Microsoft.AspNetCore.Testing.SauceConnect.ProcessTracking</_Parameter1>
+        <_Parameter2>$(SauceConnectProcessTrackingFolder)</_Parameter2>
+      </AssemblyAttribute>
+    </ItemGroup>
   </Target>
 
   <Target Name="_EnsureSeleniumScreenShotsFolder" BeforeTargets="Build">
diff --git a/src/Shared/E2ETesting/SauceConnectServer.cs b/src/Shared/E2ETesting/SauceConnectServer.cs
new file mode 100644
index 0000000000000000000000000000000000000000..ac53b9b472311339afbeded801b63ba3dad6266d
--- /dev/null
+++ b/src/Shared/E2ETesting/SauceConnectServer.cs
@@ -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.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.E2ETesting;
+using Microsoft.Extensions.Internal;
+using Xunit.Abstractions;
+using Xunit.Sdk;
+
+namespace Microsoft.AspNetCore.E2ETesting
+{
+    public class SauceConnectServer : IDisposable
+    {
+        private static SemaphoreSlim _semaphore = new SemaphoreSlim(1);
+
+        private Process _process;
+        private string _sentinelPath;
+        private Process _sentinelProcess;
+        private static IMessageSink _diagnosticsMessageSink;
+
+        // 2h
+        private static int SauceConnectProcessTimeout = 7200;
+
+        public SauceConnectServer(IMessageSink diagnosticsMessageSink)
+        {
+            if (Instance != null || _diagnosticsMessageSink != null)
+            {
+                throw new InvalidOperationException("Sauce connect singleton already created.");
+            }
+
+            // The assembly level attribute AssemblyFixture takes care of this being being instantiated before tests run
+            // and disposed after tests are run, gracefully shutting down the server when possible by calling Dispose on
+            // the singleton.
+            Instance = this;
+            _diagnosticsMessageSink = diagnosticsMessageSink;
+        }
+
+        private void Initialize(
+            Process process,
+            string sentinelPath,
+            Process sentinelProcess)
+        {
+            _process = process;
+            _sentinelPath = sentinelPath;
+            _sentinelProcess = sentinelProcess;
+        }
+
+        internal static SauceConnectServer Instance { get; private set; }
+
+        public static async Task StartAsync(ITestOutputHelper output)
+        {
+            try
+            {
+                await _semaphore.WaitAsync();
+                if (Instance._process == null)
+                {
+                    // No process was started, meaning the instance wasn't initialized.
+                    await InitializeInstance(output);
+                }
+            }
+            finally
+            {
+                _semaphore.Release();
+            }
+        }
+
+        private static async Task InitializeInstance(ITestOutputHelper output)
+        {
+            var psi = new ProcessStartInfo
+            {
+                FileName = "npm",
+                Arguments = "run sauce --" +
+                    $" --sauce-user {E2ETestOptions.Instance.Sauce.Username}" +
+                    $" --sauce-key {E2ETestOptions.Instance.Sauce.AccessKey}" +
+                    $" --sauce-tunnel {E2ETestOptions.Instance.Sauce.TunnelIdentifier}" +
+                    $" --use-hostname {E2ETestOptions.Instance.Sauce.HostName}",
+                RedirectStandardOutput = true,
+                RedirectStandardError = true,
+            };
+
+            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+            {
+                psi.FileName = "cmd";
+                psi.Arguments = $"/c npm {psi.Arguments}";
+            }
+
+            // It's important that we get the folder value before we start the process to prevent
+            // untracked processes when the tracking folder is not correctly configure.
+            var trackingFolder = GetProcessTrackingFolder();
+            if (!Directory.Exists(trackingFolder))
+            {
+                throw new InvalidOperationException($"Invalid tracking folder. Set the 'SauceConnectProcessTrackingFolder' MSBuild property to a valid folder.");
+            }
+
+            Process process = null;
+            Process sentinel = null;
+            string pidFilePath = null;
+            try
+            {
+                process = Process.Start(psi);
+                pidFilePath = await WriteTrackingFileAsync(output, trackingFolder, process);
+                sentinel = StartSentinelProcess(process, pidFilePath, SauceConnectProcessTimeout);
+            }
+            catch
+            {
+                ProcessCleanup(process, pidFilePath);
+                ProcessCleanup(sentinel, pidFilePath: null);
+                throw;
+            }
+
+            // Log output for sauce connect process.
+            // This is for the case where the server fails to launch.
+            var logOutput = new BlockingCollection<string>();
+
+            process.OutputDataReceived += LogOutput;
+            process.ErrorDataReceived += LogOutput;
+
+            process.BeginOutputReadLine();
+            process.BeginErrorReadLine();
+
+            // The Sauce connect server has to be up for the entirety of the tests and is only shutdown when the application (i.e. the test) exits.
+            AppDomain.CurrentDomain.ProcessExit += (sender, args) => ProcessCleanup(process, pidFilePath);
+
+            // Log
+            void LogOutput(object sender, DataReceivedEventArgs e)
+            {
+                logOutput.TryAdd(e.Data);
+
+                // We avoid logging on the output here because it is unreliable. We can only log in the diagnostics sink.
+                lock (_diagnosticsMessageSink)
+                {
+                    _diagnosticsMessageSink.OnMessage(new DiagnosticMessage(e.Data));
+                }
+            }
+
+            var uri = new UriBuilder("http", E2ETestOptions.Instance.Sauce.HostName, 4445).Uri;
+            var httpClient = new HttpClient
+            {
+                Timeout = TimeSpan.FromSeconds(1),
+            };
+
+            var retries = 0;
+            do
+            {
+                await Task.Delay(1000);
+                try
+                {
+                    var response = await httpClient.GetAsync(uri);
+                    if (response.StatusCode == HttpStatusCode.OK)
+                    {
+                        output = null;
+                        Instance.Initialize(process, pidFilePath, sentinel);
+                        return;
+                    }
+                }
+                catch (OperationCanceledException)
+                {
+                }
+                catch (HttpRequestException)
+                {
+                }
+
+                retries++;
+            } while (retries < 30);
+
+            // Make output null so that we stop logging to it.
+            output = null;
+            logOutput.CompleteAdding();
+            var exitCodeString = process.HasExited ? process.ExitCode.ToString() : "Process has not yet exited.";
+            var message = $@"Failed to launch the server.
+ExitCode: {exitCodeString}
+Captured output lines:
+{string.Join(Environment.NewLine, logOutput.GetConsumingEnumerable())}.";
+
+            // If we got here, we couldn't launch Sauce connect or get it to respond. So shut it down.
+            ProcessCleanup(process, pidFilePath);
+            throw new InvalidOperationException(message);
+        }
+
+        private static Process StartSentinelProcess(Process process, string sentinelFile, int timeout)
+        {
+            // This sentinel process will start and will kill any rouge sauce connect server that wasn't torn down via normal means.
+            var psi = new ProcessStartInfo
+            {
+                FileName = "powershell",
+                Arguments = $"-NoProfile -NonInteractive -Command \"Start-Sleep {timeout}; " +
+                $"if(Test-Path {sentinelFile}){{ " +
+                $"Write-Output 'Stopping process {process.Id}'; Stop-Process {process.Id}; }}" +
+                $"else{{ Write-Output 'Sentinel file {sentinelFile} not found.'}}",
+            };
+
+            return Process.Start(psi);
+        }
+
+        private static void ProcessCleanup(Process process, string pidFilePath)
+        {
+            try
+            {
+                if (process?.HasExited == false)
+                {
+                    try
+                    {
+                        process?.KillTree(TimeSpan.FromSeconds(10));
+                        process?.Dispose();
+                    }
+                    catch
+                    {
+                        // Ignore errors here since we can't do anything
+                    }
+                }
+                if (pidFilePath != null && File.Exists(pidFilePath))
+                {
+                    File.Delete(pidFilePath);
+                }
+            }
+            catch
+            {
+                // Ignore errors here since we can't do anything
+            }
+        }
+
+        private static async Task<string> WriteTrackingFileAsync(ITestOutputHelper output, string trackingFolder, Process process)
+        {
+            var pidFile = Path.Combine(trackingFolder, $"{process.Id}.{Guid.NewGuid()}.pid");
+            for (var i = 0; i < 3; i++)
+            {
+                try
+                {
+                    await File.WriteAllTextAsync(pidFile, process.Id.ToString());
+                    return pidFile;
+                }
+                catch
+                {
+                    output.WriteLine($"Can't write file to process tracking folder: {trackingFolder}");
+                }
+            }
+
+            throw new InvalidOperationException($"Failed to write file for process {process.Id}");
+        }
+
+        private static string GetProcessTrackingFolder() =>
+            typeof(SauceConnectServer).Assembly
+                .GetCustomAttributes<AssemblyMetadataAttribute>()
+                .Single(a => a.Key == "Microsoft.AspNetCore.Testing.SauceConnect.ProcessTracking").Value;
+
+        public void Dispose()
+        {
+            ProcessCleanup(_process, _sentinelPath);
+            ProcessCleanup(_sentinelProcess, pidFilePath: null);
+        }
+    }
+}
diff --git a/src/Shared/E2ETesting/SauceOptions.cs b/src/Shared/E2ETesting/SauceOptions.cs
new file mode 100644
index 0000000000000000000000000000000000000000..b8bb22ec8c5184c5d79b4b5d063a5d89ba26bd93
--- /dev/null
+++ b/src/Shared/E2ETesting/SauceOptions.cs
@@ -0,0 +1,36 @@
+// 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.
+
+namespace Microsoft.AspNetCore.E2ETesting
+{
+    public class SauceOptions
+    {
+        public string Username { get; set; }
+
+        public string AccessKey { get; set; }
+
+        public string TunnelIdentifier { get; set; }
+
+        public string HostName { get; set; }
+
+        public string TestName { get; set; }
+
+        public bool IsRealDevice { get; set; }
+
+        public string PlatformName { get; set; }
+
+        public string PlatformVersion { get; set; }
+
+        public string BrowserName { get; set; }
+
+        public string BrowserVersion { get; set; }
+
+        public string DeviceName { get; set; }
+
+        public string DeviceOrientation { get; set; }
+
+        public string AppiumVersion { get; set; }
+
+        public string SeleniumVersion { get; set; }
+    }
+}