Browse Source

[Blazor] Add `IRazorComponentsBuilder.AddWebAssemblyComponents()` (#48664)

Mackinnon Buck 2 years ago
parent
commit
56f2ba4bea
19 changed files with 385 additions and 6 deletions
  1. 19 0
      AspNetCore.sln
  2. 2 1
      src/Components/Components.slnf
  3. 18 0
      src/Components/Endpoints/src/DependencyInjection/WebAssemblyComponentsEndpointOptions.cs
  4. 5 1
      src/Components/Endpoints/src/PublicAPI.Unshipped.txt
  5. 1 0
      src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj
  6. 2 0
      src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt
  7. 112 0
      src/Components/WebAssembly/Server/src/RazorComponentsBuilderExtensions.cs
  8. 86 0
      src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs
  9. 1 0
      src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj
  10. 10 2
      src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs
  11. 10 1
      src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor
  12. 9 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ServerInteractiveCounter.razor
  13. 32 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/InteractiveComponents.razor
  14. 18 0
      src/Components/test/testassets/Components.WasmMinimal/Components.WasmMinimal.csproj
  15. 10 0
      src/Components/test/testassets/Components.WasmMinimal/Program.cs
  16. 30 0
      src/Components/test/testassets/TestContentPackage/Counter.razor
  17. 9 0
      src/Components/test/testassets/TestContentPackage/ServerInteractiveCounter.razor
  18. 9 0
      src/Components/test/testassets/TestContentPackage/WebAssemblyInteractiveCounter.razor
  19. 2 1
      src/Framework/Framework.slnf

+ 19 - 0
AspNetCore.sln

@@ -1786,6 +1786,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Authen
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IdentitySample.ApiEndpoints", "src\Identity\samples\IdentitySample.ApiEndpoints\IdentitySample.ApiEndpoints.csproj", "{37FC77EA-AC44-4D08-B002-8EFF415C424A}"
 EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Components.WasmMinimal", "src\Components\test\testassets\Components.WasmMinimal\Components.WasmMinimal.csproj", "{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}"
+EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution
 		Debug|Any CPU = Debug|Any CPU
@@ -10739,6 +10741,22 @@ Global
 		{37FC77EA-AC44-4D08-B002-8EFF415C424A}.Release|x64.Build.0 = Release|Any CPU
 		{37FC77EA-AC44-4D08-B002-8EFF415C424A}.Release|x86.ActiveCfg = Release|Any CPU
 		{37FC77EA-AC44-4D08-B002-8EFF415C424A}.Release|x86.Build.0 = Release|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Debug|arm64.ActiveCfg = Debug|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Debug|arm64.Build.0 = Debug|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Debug|x64.ActiveCfg = Debug|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Debug|x64.Build.0 = Debug|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Debug|x86.ActiveCfg = Debug|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Debug|x86.Build.0 = Debug|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Release|Any CPU.Build.0 = Release|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Release|arm64.ActiveCfg = Release|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Release|arm64.Build.0 = Release|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Release|x64.ActiveCfg = Release|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Release|x64.Build.0 = Release|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Release|x86.ActiveCfg = Release|Any CPU
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3}.Release|x86.Build.0 = Release|Any CPU
 	EndGlobalSection
 	GlobalSection(SolutionProperties) = preSolution
 		HideSolutionNode = FALSE
@@ -11621,6 +11639,7 @@ Global
 		{56291265-B7BF-4756-92AB-FC30F09381D1} = {822D1519-77F0-484A-B9AB-F694C2CC25F1}
 		{66FA1041-5556-43A0-9CA3-F9937F085F6E} = {56291265-B7BF-4756-92AB-FC30F09381D1}
 		{37FC77EA-AC44-4D08-B002-8EFF415C424A} = {64B2A28F-6D82-4F2B-B0BB-88DE5216DD2C}
+		{87D58D50-20D1-4091-88C5-8D88DCCC2DE3} = {6126DCE4-9692-4EE2-B240-C65743572995}
 	EndGlobalSection
 	GlobalSection(ExtensibilityGlobals) = postSolution
 		SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F}

+ 2 - 1
src/Components/Components.slnf

@@ -53,6 +53,7 @@
       "src\\Components\\test\\E2ETest\\Microsoft.AspNetCore.Components.E2ETests.csproj",
       "src\\Components\\test\\testassets\\BasicTestApp\\BasicTestApp.csproj",
       "src\\Components\\test\\testassets\\Components.TestServer\\Components.TestServer.csproj",
+      "src\\Components\\test\\testassets\\Components.WasmMinimal\\Components.WasmMinimal.csproj",
       "src\\Components\\test\\testassets\\ComponentsApp.App\\ComponentsApp.App.csproj",
       "src\\Components\\test\\testassets\\ComponentsApp.Server\\ComponentsApp.Server.csproj",
       "src\\Components\\test\\testassets\\GlobalizationWasmApp\\GlobalizationWasmApp.csproj",
@@ -148,4 +149,4 @@
       "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj"
     ]
   }
-}
+}

+ 18 - 0
src/Components/Endpoints/src/DependencyInjection/WebAssemblyComponentsEndpointOptions.cs

@@ -0,0 +1,18 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Http;
+
+namespace Microsoft.AspNetCore.Components.Endpoints;
+
+/// <summary>
+/// Options to configure interactive WebAssembly components.
+/// </summary>
+public sealed class WebAssemblyComponentsEndpointOptions
+{
+    /// <summary>
+    /// Gets or sets the <see cref="PathString"/> that indicates the prefix for Blazor WebAssembly assets.
+    /// This path must correspond to a referenced Blazor WebAssembly application project.
+    /// </summary>
+    public PathString PathPrefix { get; set; }
+}

+ 5 - 1
src/Components/Endpoints/src/PublicAPI.Unshipped.txt

@@ -76,6 +76,10 @@ Microsoft.AspNetCore.Components.Endpoints.RenderModeEndpointProvider.RenderModeE
 Microsoft.AspNetCore.Components.Endpoints.RootComponentMetadata
 Microsoft.AspNetCore.Components.Endpoints.RootComponentMetadata.RootComponentMetadata(System.Type! rootComponentType) -> void
 Microsoft.AspNetCore.Components.Endpoints.RootComponentMetadata.Type.get -> System.Type!
+Microsoft.AspNetCore.Components.Endpoints.WebAssemblyComponentsEndpointOptions
+Microsoft.AspNetCore.Components.Endpoints.WebAssemblyComponentsEndpointOptions.PathPrefix.get -> Microsoft.AspNetCore.Http.PathString
+Microsoft.AspNetCore.Components.Endpoints.WebAssemblyComponentsEndpointOptions.PathPrefix.set -> void
+Microsoft.AspNetCore.Components.Endpoints.WebAssemblyComponentsEndpointOptions.WebAssemblyComponentsEndpointOptions() -> void
 Microsoft.AspNetCore.Components.Infrastructure.RazorComponentApplicationAttribute
 Microsoft.AspNetCore.Components.Infrastructure.RazorComponentApplicationAttribute.RazorComponentApplicationAttribute() -> void
 Microsoft.AspNetCore.Components.PersistedStateSerializationMode
@@ -91,4 +95,4 @@ static Microsoft.AspNetCore.Builder.RazorComponentsEndpointRouteBuilderExtension
 static Microsoft.AspNetCore.Components.Discovery.ComponentApplicationBuilder.GetBuilder<TComponent>() -> Microsoft.AspNetCore.Components.Discovery.ComponentApplicationBuilder?
 static Microsoft.Extensions.DependencyInjection.RazorComponentsServiceCollectionExtensions.AddRazorComponents(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.AspNetCore.Components.Endpoints.IRazorComponentsBuilder!
 static readonly Microsoft.AspNetCore.Components.Endpoints.RazorComponentResultExecutor.DefaultContentType -> string!
-virtual Microsoft.AspNetCore.Components.Endpoints.RazorComponentResultExecutor.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext, Microsoft.AspNetCore.Components.Endpoints.RazorComponentResult! result) -> System.Threading.Tasks.Task!
+virtual Microsoft.AspNetCore.Components.Endpoints.RazorComponentResultExecutor.ExecuteAsync(Microsoft.AspNetCore.Http.HttpContext! httpContext, Microsoft.AspNetCore.Components.Endpoints.RazorComponentResult! result) -> System.Threading.Tasks.Task!

+ 1 - 0
src/Components/WebAssembly/Server/src/Microsoft.AspNetCore.Components.WebAssembly.Server.csproj

@@ -12,6 +12,7 @@
   </PropertyGroup>
 
   <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore.Components.Endpoints" />
     <Reference Include="Microsoft.AspNetCore.StaticFiles" />
     <Reference Include="Microsoft.NETCore.BrowserDebugHost.Transport" GeneratePathProperty="true" PrivateAssets="All" />
   </ItemGroup>

+ 2 - 0
src/Components/WebAssembly/Server/src/PublicAPI.Unshipped.txt

@@ -1,2 +1,4 @@
 #nullable enable
 Microsoft.AspNetCore.Components.WebAssembly.Server.TargetPickerUi.DisplayFirefox(Microsoft.AspNetCore.Http.HttpContext! context) -> System.Threading.Tasks.Task!
+Microsoft.Extensions.DependencyInjection.RazorComponentsBuilderExtensions
+static Microsoft.Extensions.DependencyInjection.RazorComponentsBuilderExtensions.AddWebAssemblyComponents(this Microsoft.AspNetCore.Components.Endpoints.IRazorComponentsBuilder! builder, System.Action<Microsoft.AspNetCore.Components.Endpoints.WebAssemblyComponentsEndpointOptions!>? configure = null) -> Microsoft.AspNetCore.Components.Endpoints.IRazorComponentsBuilder!

+ 112 - 0
src/Components/WebAssembly/Server/src/RazorComponentsBuilderExtensions.cs

@@ -0,0 +1,112 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Endpoints;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+/// <summary>
+/// Extension methods to configure an <see cref="IServiceCollection"/> for WebAssembly components.
+/// </summary>
+public static class RazorComponentsBuilderExtensions
+{
+    /// <summary>
+    /// Adds services to support rendering interactive WebAssembly components.
+    /// </summary>
+    /// <param name="builder">The <see cref="IRazorComponentsBuilder"/>.</param>
+    /// <param name="configure">A callback to configure <see cref="WebAssemblyComponentsEndpointOptions"/>.</param>
+    /// <returns>An <see cref="IRazorComponentsBuilder"/> that can be used to further customize the configuration.</returns>
+    public static IRazorComponentsBuilder AddWebAssemblyComponents(this IRazorComponentsBuilder builder, Action<WebAssemblyComponentsEndpointOptions>? configure = null)
+    {
+        ArgumentNullException.ThrowIfNull(builder, nameof(builder));
+
+        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<RenderModeEndpointProvider, WebAssemblyEndpointProvider>());
+
+        if (configure is not null)
+        {
+            builder.Services.Configure(configure);
+        }
+
+        return builder;
+    }
+
+    private class WebAssemblyEndpointProvider : RenderModeEndpointProvider
+    {
+        private readonly IServiceProvider _services;
+        private readonly WebAssemblyComponentsEndpointOptions _options;
+
+        public WebAssemblyEndpointProvider(IServiceProvider services, IOptions<WebAssemblyComponentsEndpointOptions> options)
+        {
+            _services = services;
+            _options = options.Value;
+        }
+
+        public override IEnumerable<RouteEndpointBuilder> GetEndpointBuilders(IComponentRenderMode renderMode, IApplicationBuilder applicationBuilder)
+        {
+            var endpointRouteBuilder = new EndpointRouteBuilder(_services, applicationBuilder);
+            var pathPrefix = _options.PathPrefix;
+
+            applicationBuilder.UseBlazorFrameworkFiles(pathPrefix);
+            var app = applicationBuilder.Build();
+
+            endpointRouteBuilder.Map($"{pathPrefix}/_framework/{{*path}}", context =>
+            {
+                // Set endpoint to null so the static files middleware will handle the request.
+                context.SetEndpoint(null);
+
+                return app(context);
+            });
+
+            return endpointRouteBuilder.GetEndpoints();
+        }
+
+        public override bool Supports(IComponentRenderMode renderMode)
+            => renderMode is WebAssemblyRenderMode or AutoRenderMode;
+
+        private class EndpointRouteBuilder : IEndpointRouteBuilder
+        {
+            private readonly IApplicationBuilder _applicationBuilder;
+
+            public EndpointRouteBuilder(IServiceProvider serviceProvider, IApplicationBuilder applicationBuilder)
+            {
+                ServiceProvider = serviceProvider;
+                _applicationBuilder = applicationBuilder;
+            }
+
+            public IServiceProvider ServiceProvider { get; }
+
+            public ICollection<EndpointDataSource> DataSources { get; } = new List<EndpointDataSource>() { };
+
+            public IApplicationBuilder CreateApplicationBuilder()
+            {
+                return _applicationBuilder.New();
+            }
+
+            internal IEnumerable<RouteEndpointBuilder> GetEndpoints()
+            {
+                foreach (var ds in DataSources)
+                {
+                    foreach (var endpoint in ds.Endpoints)
+                    {
+                        var routeEndpoint = (RouteEndpoint)endpoint;
+                        var builder = new RouteEndpointBuilder(endpoint.RequestDelegate, routeEndpoint.RoutePattern, routeEndpoint.Order);
+                        for (var i = 0; i < routeEndpoint.Metadata.Count; i++)
+                        {
+                            var metadata = routeEndpoint.Metadata[i];
+                            builder.Metadata.Add(metadata);
+                        }
+
+                        yield return builder;
+                    }
+                }
+            }
+        }
+    }
+}

+ 86 - 0
src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs

@@ -0,0 +1,86 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Components.TestServer.RazorComponents;
+using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
+using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
+using Microsoft.AspNetCore.E2ETesting;
+using OpenQA.Selenium;
+using TestServer;
+using Xunit.Abstractions;
+
+namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests;
+
+public class InteractivityTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>
+{
+    public InteractivityTest(
+        BrowserFixture browserFixture,
+        BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
+        ITestOutputHelper output)
+        : base(browserFixture, serverFixture, output)
+    {
+    }
+
+    public override Task InitializeAsync()
+        => InitializeAsync(BrowserFixture.StreamingContext);
+
+    [Fact]
+    public void CanRenderInteractiveServerComponent()
+    {
+        // '2' configures the increment amount.
+        Navigate($"{ServerPathBase}/interactive?server=2");
+
+        Browser.Equal("0", () => Browser.FindElement(By.Id("count-server")).Text);
+        Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-server")).Text);
+
+        Browser.Click(By.Id("increment-server"));
+
+        Browser.Equal("2", () => Browser.FindElement(By.Id("count-server")).Text);
+    }
+
+    [Fact]
+    public void CanRenderInteractiveServerComponentFromRazorClassLibrary()
+    {
+        // '3' configures the increment amount.
+        Navigate($"{ServerPathBase}/interactive?server-shared=3");
+
+        Browser.Equal("0", () => Browser.FindElement(By.Id("count-server-shared")).Text);
+        Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-server-shared")).Text);
+
+        Browser.Click(By.Id("increment-server-shared"));
+
+        Browser.Equal("3", () => Browser.FindElement(By.Id("count-server-shared")).Text);
+    }
+
+    [Fact]
+    public void CanRenderInteractiveWebAssemblyComponentFromRazorClassLibrary()
+    {
+        // '4' configures the increment amount.
+        Navigate($"{ServerPathBase}/interactive?wasm-shared=4");
+
+        Browser.Equal("0", () => Browser.FindElement(By.Id("count-wasm-shared")).Text);
+        Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-wasm-shared")).Text);
+
+        Browser.Click(By.Id("increment-wasm-shared"));
+
+        Browser.Equal("4", () => Browser.FindElement(By.Id("count-wasm-shared")).Text);
+    }
+
+    [Fact]
+    public void CanRenderInteractiveServerAndWebAssemblyComponentsAtTheSameTime()
+    {
+        // '3' and '5' configure the increment amounts.
+        Navigate($"{ServerPathBase}/interactive?server-shared=3&wasm-shared=5");
+
+        Browser.Equal("0", () => Browser.FindElement(By.Id("count-server-shared")).Text);
+        Browser.Equal("0", () => Browser.FindElement(By.Id("count-wasm-shared")).Text);
+        Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-server-shared")).Text);
+        Browser.Equal("True", () => Browser.FindElement(By.Id("is-interactive-wasm-shared")).Text);
+
+        Browser.Click(By.Id("increment-server-shared"));
+        Browser.Click(By.Id("increment-wasm-shared"));
+
+        Browser.Equal("3", () => Browser.FindElement(By.Id("count-server-shared")).Text);
+        Browser.Equal("5", () => Browser.FindElement(By.Id("count-wasm-shared")).Text);
+    }
+}

+ 1 - 0
src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj

@@ -27,6 +27,7 @@
 
   <ItemGroup>
     <ProjectReference Include="..\BasicTestApp\BasicTestApp.csproj" />
+    <ProjectReference Include="..\Components.WasmMinimal\Components.WasmMinimal.csproj" />
   </ItemGroup>
 
   <ItemGroup>

+ 10 - 2
src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs

@@ -22,7 +22,12 @@ public class RazorComponentEndpointsStartup<TRootComponent>
     // This method gets called by the runtime. Use this method to add services to the container.
     public void ConfigureServices(IServiceCollection services)
     {
-        services.AddRazorComponents();
+        services.AddRazorComponents()
+            .AddServerComponents()
+            .AddWebAssemblyComponents(options =>
+            {
+                options.PathPrefix = "/WasmMinimal";
+            });
         services.AddHttpContextAccessor();
         services.AddSingleton<AsyncOperationService>();
     }
@@ -41,10 +46,13 @@ public class RazorComponentEndpointsStartup<TRootComponent>
 
         app.Map("/subdir", app =>
         {
+            app.UseStaticFiles();
             app.UseRouting();
             app.UseEndpoints(endpoints =>
             {
-                endpoints.MapRazorComponents<TRootComponent>();
+                endpoints.MapRazorComponents<TRootComponent>()
+                    .AddServerRenderMode()
+                    .AddWebAssemblyRenderMode();
 
                 StreamingRendering.MapEndpoints(endpoints);
                 StreamingRenderingForm.MapEndpoints(endpoints);

+ 10 - 1
src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor

@@ -20,6 +20,15 @@
             </LayoutView>
         </NotFound>
     </Router>
-    <script src="_framework/blazor.web.js" suppress-error="BL9992"></script>
+    <script src="_framework/blazor.web.js" autostart="false" suppress-error="BL9992"></script>
+    <script suppress-error="BL9992">
+        Blazor.start({
+            webAssembly: {
+                loadBootResource: function (type, name, defaultUri, integrity) {
+                    return `WasmMinimal/_framework/${name}`;
+                },
+            },
+        });
+    </script>
 </body>
 </html>

+ 9 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ServerInteractiveCounter.razor

@@ -0,0 +1,9 @@
+@attribute [RenderModeServer]
+
+<strong>Server counter</strong>
+<TestContentPackage.Counter IncrementAmount="IncrementAmount" IdSuffix="server" />
+
+@code {
+    [Parameter]
+    public int IncrementAmount { get; set; } = 1;
+}

+ 32 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/InteractiveComponents.razor

@@ -0,0 +1,32 @@
+@page "/interactive"
+
+<h1>Interactive components</h1>
+
+@if (ServerIncrementAmount.HasValue)
+{
+    <ServerInteractiveCounter IncrementAmount="@ServerIncrementAmount.Value" />
+    <hr />
+}
+
+@if (ServerSharedIncrementAmount.HasValue)
+{
+    <TestContentPackage.ServerInteractiveCounter IncrementAmount="@ServerSharedIncrementAmount.Value" />
+    <hr />
+}
+
+@if (SharedWebAssemblyIncrementAmount.HasValue)
+{
+    <TestContentPackage.WebAssemblyInteractiveCounter IncrementAmount="@SharedWebAssemblyIncrementAmount.Value" />
+    <hr />
+}
+
+@code {
+    [Parameter, SupplyParameterFromQuery(Name = "server")]
+    public int? ServerIncrementAmount { get; set; }
+
+    [Parameter, SupplyParameterFromQuery(Name = "server-shared")]
+    public int? ServerSharedIncrementAmount { get; set; }
+
+    [Parameter, SupplyParameterFromQuery(Name = "wasm-shared")]
+    public int? SharedWebAssemblyIncrementAmount { get; set; }
+}

+ 18 - 0
src/Components/test/testassets/Components.WasmMinimal/Components.WasmMinimal.csproj

@@ -0,0 +1,18 @@
+<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
+
+  <PropertyGroup>
+    <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <StaticWebAssetBasePath>WasmMinimal</StaticWebAssetBasePath>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore.Components.WebAssembly" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <ProjectReference Include="..\TestContentPackage\TestContentPackage.csproj" />
+  </ItemGroup>
+
+</Project>

+ 10 - 0
src/Components/test/testassets/Components.WasmMinimal/Program.cs

@@ -0,0 +1,10 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Reflection;
+using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
+
+Assembly.Load(nameof(TestContentPackage));
+
+var builder = WebAssemblyHostBuilder.CreateDefault(args);
+await builder.Build().RunAsync();

+ 30 - 0
src/Components/test/testassets/TestContentPackage/Counter.razor

@@ -0,0 +1,30 @@
+<p>Increment amount: @IncrementAmount</p>
+<p>Interactive: <span id="is-interactive-@IdSuffix">@_isInteractive</span></p>
+<p>Current count: <span id="count-@IdSuffix">@_currentCount</span></p>
+
+<button id="increment-@IdSuffix" @onclick="IncrementCount">Click me</button>
+
+@code {
+    private int _currentCount = 0;
+    private bool _isInteractive = false;
+
+    [Parameter, EditorRequired]
+    public int IncrementAmount { get; set; }
+
+    [Parameter, EditorRequired]
+    public string IdSuffix { get; set; }
+
+    private void IncrementCount()
+    {
+        _currentCount += IncrementAmount;
+    }
+
+    protected override void OnAfterRender(bool firstRender)
+    {
+        if (firstRender)
+        {
+            _isInteractive = true;
+            StateHasChanged();
+        }
+    }
+}

+ 9 - 0
src/Components/test/testassets/TestContentPackage/ServerInteractiveCounter.razor

@@ -0,0 +1,9 @@
+@attribute [RenderModeServer]
+
+<strong>Server counter from RCL</strong>
+<Counter IncrementAmount="IncrementAmount" IdSuffix="server-shared" />
+
+@code {
+    [Parameter]
+    public int IncrementAmount { get; set; } = 1;
+}

+ 9 - 0
src/Components/test/testassets/TestContentPackage/WebAssemblyInteractiveCounter.razor

@@ -0,0 +1,9 @@
+@attribute [RenderModeWebAssembly]
+
+<strong>WebAssembly counter from RCL</strong>
+<Counter IncrementAmount="IncrementAmount" IdSuffix="wasm-shared" />
+
+@code {
+    [Parameter]
+    public int IncrementAmount { get; set; } = 1;
+}

+ 2 - 1
src/Framework/Framework.slnf

@@ -10,6 +10,7 @@
       "src\\Components\\Forms\\src\\Microsoft.AspNetCore.Components.Forms.csproj",
       "src\\Components\\Server\\src\\Microsoft.AspNetCore.Components.Server.csproj",
       "src\\Components\\Web\\src\\Microsoft.AspNetCore.Components.Web.csproj",
+      "src\\Components\\WebAssembly\\Server\\src\\Microsoft.AspNetCore.Components.WebAssembly.Server.csproj",
       "src\\Configuration.KeyPerFile\\src\\Microsoft.Extensions.Configuration.KeyPerFile.csproj",
       "src\\DataProtection\\Abstractions\\src\\Microsoft.AspNetCore.DataProtection.Abstractions.csproj",
       "src\\DataProtection\\Cryptography.Internal\\src\\Microsoft.AspNetCore.Cryptography.Internal.csproj",
@@ -110,4 +111,4 @@
       "src\\WebEncoders\\src\\Microsoft.Extensions.WebEncoders.csproj"
     ]
   }
-}
+}