瀏覽代碼

Streaming SSR: DOM updating (#47605)

* Merge HtmlComponentWriter into StaticHtmlRenderer so that subclasses will be able to customize HTML rendering

* Rename files

* Move most of the streaming concerns into EndpointHtmlRenderer so it can vary its output based on this

* Emit streaming boundary markers, but only for streaming components

* Tests for streaming boundary markers

* Can now simplify by removing HtmlComponentBase and changing HtmlComponent to a readonly struct

* Further clarify by renaming it to HtmlRootComponent

* Move StreamRenderingAttribute to .Web namespace

* Make HeadOutlet stream so that page title (etc) can be updated from streaming components

* Add blazor.web.js and embed/serve it using the same patterns we have for blazor.server.js

* Serve sourcemaps (but in debug builds only)

We used to serve sourcemaps when working on this repo. Not sure when that got broken.

* Render only the minimal subset of updated components in each streaming render batch (to avoid duplicated HTML)

* Move StreamRenderingAttribute into M.A.Components so we can support streaming output into SectionOutlet

This automatically deals with HeadOutlet as a special case

* Fix VS complaining that SignalR was not referenced in tests even though it actually is already (transitively). This did not affect CLI builds.

* Fix longstanding build issue whereby VS always thinks M.A.C.WebAssembly.Authentication needs to be rebuilt

* Adding a Blazor United flavour of TestServer

* When starting E2E tests manually, don't randomize the port numbers

It was so inconvenient not being able to just reload the existing browser tab

* Fix response content type

* Beginning content for new test app

* Rename TestServer directory to match project name. Fixes longstanding issues with launching browser.

* Update CodeCheck.ps1

* Add/update prebuilt JS files

* Add streaming rendering E2E test app code

* Flush response after each streaming update so we can send multiple batches

* Fix sln following rename

* E2E tests

* Fix slnf
Steve Sanderson 2 年之前
父節點
當前提交
a419b2204d
共有 96 個文件被更改,包括 883 次插入321 次删除
  1. 1 1
      AspNetCore.sln
  2. 1 1
      eng/scripts/CodeCheck.ps1
  3. 1 1
      src/Components/Components.slnf
  4. 4 0
      src/Components/Components/src/PublicAPI.Unshipped.txt
  5. 8 0
      src/Components/Components/src/RenderTree/Renderer.cs
  6. 1 0
      src/Components/Components/src/Sections/SectionOutlet.cs
  7. 0 0
      src/Components/Components/src/StreamRenderingAttribute.cs
  8. 1 1
      src/Components/ComponentsNoDeps.slnf
  9. 35 0
      src/Components/Endpoints/src/Builder/RazorComponentsEndpointRouteBuilderExtensions.cs
  10. 6 0
      src/Components/Endpoints/src/Directory.Build.targets
  11. 49 0
      src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj
  12. 7 66
      src/Components/Endpoints/src/RazorComponentEndpoint.cs
  13. 4 4
      src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs
  14. 0 0
      src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs
  15. 184 0
      src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs
  16. 14 35
      src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs
  17. 68 18
      src/Components/Endpoints/test/RazorComponentEndpointTest.cs
  18. 6 0
      src/Components/Endpoints/test/TestComponents/StreamingComponentChild.razor
  19. 9 0
      src/Components/Endpoints/test/TestComponents/StreamingComponentWithChild.razor
  20. 2 0
      src/Components/Samples/BlazorUnitedApp/Shared/MainLayout.razor
  21. 7 0
      src/Components/Server/src/Builder/ComponentEndpointRouteBuilderExtensions.cs
  22. 2 0
      src/Components/Web.JS/Microsoft.AspNetCore.Components.Web.JS.npmproj
  23. 0 0
      src/Components/Web.JS/dist/Release/blazor.server.js
  24. 1 0
      src/Components/Web.JS/dist/Release/blazor.web.js
  25. 26 0
      src/Components/Web.JS/src/Boot.Web.ts
  26. 74 0
      src/Components/Web.JS/src/Rendering/StreamingRendering.ts
  27. 1 0
      src/Components/Web.JS/src/webpack.config.js
  28. 0 33
      src/Components/Web/src/HtmlRendering/HtmlComponent.cs
  29. 24 24
      src/Components/Web/src/HtmlRendering/HtmlRenderer.cs
  30. 12 21
      src/Components/Web/src/HtmlRendering/HtmlRootComponent.cs
  31. 48 59
      src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.HtmlWriting.cs
  32. 3 3
      src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.cs
  33. 16 20
      src/Components/Web/src/PublicAPI.Unshipped.txt
  34. 2 2
      src/Components/Web/test/HtmlRendering/HtmlRendererTest.cs
  35. 1 1
      src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs
  36. 1 1
      src/Components/WebAssembly/WebAssembly.Authentication/src/Microsoft.AspNetCore.Components.WebAssembly.Authentication.csproj
  37. 1 1
      src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj
  38. 67 0
      src/Components/test/E2ETest/ServerExecutionTests/StreamingRenderingTest.cs
  39. 0 0
      src/Components/test/testassets/Components.TestServer/.gitignore
  40. 0 0
      src/Components/test/testassets/Components.TestServer/AuthenticationStartup.cs
  41. 0 0
      src/Components/test/testassets/Components.TestServer/ChatHub.cs
  42. 0 0
      src/Components/test/testassets/Components.TestServer/CircuitContextComponent.razor
  43. 0 0
      src/Components/test/testassets/Components.TestServer/ClientStartup.cs
  44. 2 1
      src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj
  45. 0 0
      src/Components/test/testassets/Components.TestServer/Controllers/CookieController.cs
  46. 0 0
      src/Components/test/testassets/Components.TestServer/Controllers/CultureController.cs
  47. 0 0
      src/Components/test/testassets/Components.TestServer/Controllers/DataController.cs
  48. 0 0
      src/Components/test/testassets/Components.TestServer/Controllers/DownloadController.cs
  49. 0 0
      src/Components/test/testassets/Components.TestServer/Controllers/GreetingController.cs
  50. 0 0
      src/Components/test/testassets/Components.TestServer/Controllers/PersonController.cs
  51. 0 0
      src/Components/test/testassets/Components.TestServer/Controllers/ReloadController.cs
  52. 0 0
      src/Components/test/testassets/Components.TestServer/Controllers/UserController.cs
  53. 0 0
      src/Components/test/testassets/Components.TestServer/CorsStartup.cs
  54. 0 0
      src/Components/test/testassets/Components.TestServer/DeferredComponentContentStartup.cs
  55. 0 0
      src/Components/test/testassets/Components.TestServer/HotReloadStartup.cs
  56. 0 0
      src/Components/test/testassets/Components.TestServer/InternationalizationStartup.cs
  57. 0 0
      src/Components/test/testassets/Components.TestServer/LockedNavigationStartup.cs
  58. 0 0
      src/Components/test/testassets/Components.TestServer/MultipleComponents.cs
  59. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/Authentication.cshtml
  60. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/Client/MultipleComponents.cshtml
  61. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/Client/MultipleComponentsLayout.cshtml
  62. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/ComponentWithParameters.cshtml
  63. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/DeferredComponentContentHost.cshtml
  64. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/DeferredComponentContentLayout.cshtml
  65. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/LockedNavigationHost.cshtml
  66. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/MultipleComponents.cshtml
  67. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/MultipleComponentsLayout.cshtml
  68. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/PrerenderedHost.cshtml
  69. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/SaveState.cshtml
  70. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/Transports.cshtml
  71. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml
  72. 0 0
      src/Components/test/testassets/Components.TestServer/Pages/_ViewImports.cshtml
  73. 0 0
      src/Components/test/testassets/Components.TestServer/PrerenderedStartup.cs
  74. 17 1
      src/Components/test/testassets/Components.TestServer/Program.cs
  75. 17 0
      src/Components/test/testassets/Components.TestServer/Properties/launchSettings.json
  76. 0 0
      src/Components/test/testassets/Components.TestServer/ProtectedBrowserStorageInjectionComponent.razor
  77. 0 0
      src/Components/test/testassets/Components.TestServer/ProtectedBrowserStorageUsageComponent.razor
  78. 48 0
      src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs
  79. 7 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Index.razor
  80. 74 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering.razor
  81. 1 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/_Imports.razor
  82. 14 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/RazorComponentsLayout.razor
  83. 9 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/RazorComponentsRoot.razor
  84. 0 0
      src/Components/test/testassets/Components.TestServer/ResourceRequestLog.cs
  85. 0 0
      src/Components/test/testassets/Components.TestServer/SaveState.cs
  86. 0 0
      src/Components/test/testassets/Components.TestServer/ServerStartup.cs
  87. 0 0
      src/Components/test/testassets/Components.TestServer/Startup.cs
  88. 0 0
      src/Components/test/testassets/Components.TestServer/StartupWithMapFallbackToClientSideBlazor.cs
  89. 0 0
      src/Components/test/testassets/Components.TestServer/TestAppInfo.cs
  90. 0 0
      src/Components/test/testassets/Components.TestServer/TestCircuitContextAccessor.cs
  91. 0 0
      src/Components/test/testassets/Components.TestServer/TransportsServerStartup.cs
  92. 0 0
      src/Components/test/testassets/Components.TestServer/_Imports.razor
  93. 0 0
      src/Components/test/testassets/Components.TestServer/appsettings.Development.json
  94. 0 0
      src/Components/test/testassets/Components.TestServer/appsettings.json
  95. 0 27
      src/Components/test/testassets/TestServer/Properties/launchSettings.json
  96. 7 0
      src/Shared/E2ETesting/BrowserFixture.cs

+ 1 - 1
AspNetCore.sln

@@ -1292,7 +1292,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LazyTestContentPackage", "s
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestContentPackage", "src\Components\test\testassets\TestContentPackage\TestContentPackage.csproj", "{3D3C7D9B-E356-4DC6-80B1-3F6D7F15EE31}"
 EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Components.TestServer", "src\Components\test\testassets\TestServer\Components.TestServer.csproj", "{8A59AF88-4A82-46ED-977D-D909001F8107}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Components.TestServer", "src\Components\test\testassets\Components.TestServer\Components.TestServer.csproj", "{8A59AF88-4A82-46ED-977D-D909001F8107}"
 EndProject
 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ObjectPool", "ObjectPool", "{E235DAAD-FE73-469E-B16F-F2B8E872E217}"
 EndProject

+ 1 - 1
eng/scripts/CodeCheck.ps1

@@ -218,7 +218,7 @@ try {
 
     # Temporary: Disable check for blazor js file and nuget.config (updated automatically for
     # internal builds)
-    $changedFilesExclusions = @("src/Components/Web.JS/dist/Release/blazor.server.js", "NuGet.config")
+    $changedFilesExclusions = @("src/Components/Web.JS/dist/Release/blazor.server.js", "src/Components/Web.JS/dist/Release/blazor.web.js", "NuGet.config")
 
     if ($changedFiles) {
         foreach ($file in $changedFiles) {

+ 1 - 1
src/Components/Components.slnf

@@ -52,12 +52,12 @@
       "src\\Components\\benchmarkapps\\Wasm.Performance\\TestApp\\Wasm.Performance.TestApp.csproj",
       "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\\ComponentsApp.App\\ComponentsApp.App.csproj",
       "src\\Components\\test\\testassets\\ComponentsApp.Server\\ComponentsApp.Server.csproj",
       "src\\Components\\test\\testassets\\GlobalizationWasmApp\\GlobalizationWasmApp.csproj",
       "src\\Components\\test\\testassets\\LazyTestContentPackage\\LazyTestContentPackage.csproj",
       "src\\Components\\test\\testassets\\TestContentPackage\\TestContentPackage.csproj",
-      "src\\Components\\test\\testassets\\TestServer\\Components.TestServer.csproj",
       "src\\DataProtection\\Abstractions\\src\\Microsoft.AspNetCore.DataProtection.Abstractions.csproj",
       "src\\DataProtection\\Cryptography.Internal\\src\\Microsoft.AspNetCore.Cryptography.Internal.csproj",
       "src\\DataProtection\\Cryptography.KeyDerivation\\src\\Microsoft.AspNetCore.Cryptography.KeyDerivation.csproj",

+ 4 - 0
src/Components/Components/src/PublicAPI.Unshipped.txt

@@ -12,6 +12,7 @@ Microsoft.AspNetCore.Components.Rendering.ComponentState.ComponentId.get -> int
 Microsoft.AspNetCore.Components.Rendering.ComponentState.ComponentState(Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer, int componentId, Microsoft.AspNetCore.Components.IComponent! component, Microsoft.AspNetCore.Components.Rendering.ComponentState? parentComponentState) -> void
 Microsoft.AspNetCore.Components.Rendering.ComponentState.Dispose() -> void
 Microsoft.AspNetCore.Components.Rendering.ComponentState.ParentComponentState.get -> Microsoft.AspNetCore.Components.Rendering.ComponentState?
+Microsoft.AspNetCore.Components.RenderTree.Renderer.GetComponentState(int componentId) -> Microsoft.AspNetCore.Components.Rendering.ComponentState!
 Microsoft.AspNetCore.Components.Sections.SectionContent
 Microsoft.AspNetCore.Components.Sections.SectionContent.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment?
 Microsoft.AspNetCore.Components.Sections.SectionContent.ChildContent.set -> void
@@ -29,6 +30,9 @@ Microsoft.AspNetCore.Components.Sections.SectionOutlet.SectionName.get -> string
 Microsoft.AspNetCore.Components.Sections.SectionOutlet.SectionName.set -> void
 Microsoft.AspNetCore.Components.Sections.SectionOutlet.SectionOutlet() -> void
 Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.AddComponentParameter(int sequence, string! name, object? value) -> void
+Microsoft.AspNetCore.Components.StreamRenderingAttribute
+Microsoft.AspNetCore.Components.StreamRenderingAttribute.Enabled.get -> bool
+Microsoft.AspNetCore.Components.StreamRenderingAttribute.StreamRenderingAttribute(bool enabled) -> void
 override Microsoft.AspNetCore.Components.EventCallback.GetHashCode() -> int
 override Microsoft.AspNetCore.Components.EventCallback.Equals(object? obj) -> bool
 override Microsoft.AspNetCore.Components.EventCallback<TValue>.GetHashCode() -> int

+ 8 - 0
src/Components/Components/src/RenderTree/Renderer.cs

@@ -118,6 +118,14 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable
     /// </summary>
     internal bool Disposed => _rendererIsDisposed;
 
+    /// <summary>
+    /// Gets the <see cref="ComponentState"/> associated with the specified component.
+    /// </summary>
+    /// <param name="componentId">The component ID</param>
+    /// <returns>The corresponding <see cref="ComponentState"/>.</returns>
+    protected ComponentState GetComponentState(int componentId)
+        => GetRequiredComponentState(componentId);
+
     private async void RenderRootComponentsOnHotReload()
     {
         // Before re-rendering the root component, also clear any well-known caches in the framework

+ 1 - 0
src/Components/Components/src/Sections/SectionOutlet.cs

@@ -6,6 +6,7 @@ namespace Microsoft.AspNetCore.Components.Sections;
 /// <summary>
 /// Renders content provided by <see cref="SectionContent"/> components with matching <see cref="SectionId"/>s.
 /// </summary>
+[StreamRendering(true)] // Because the content may be provided by a streaming component
 public sealed class SectionOutlet : ISectionContentSubscriber, IComponent, IDisposable
 {
     private static readonly RenderFragment _emptyRenderFragment = _ => { };

+ 0 - 0
src/Components/Web/src/StreamRenderingAttribute.cs → src/Components/Components/src/StreamRenderingAttribute.cs


+ 1 - 1
src/Components/ComponentsNoDeps.slnf

@@ -55,7 +55,7 @@
       "src\\Components\\test\\testassets\\GlobalizationWasmApp\\GlobalizationWasmApp.csproj",
       "src\\Components\\test\\testassets\\LazyTestContentPackage\\LazyTestContentPackage.csproj",
       "src\\Components\\test\\testassets\\TestContentPackage\\TestContentPackage.csproj",
-      "src\\Components\\test\\testassets\\TestServer\\Components.TestServer.csproj"
+      "src\\Components\\test\\testassets\\Components.TestServer\\Components.TestServer.csproj"
     ]
   }
 }

+ 35 - 0
src/Components/Endpoints/src/Builder/RazorComponentsEndpointRouteBuilderExtensions.cs

@@ -5,8 +5,11 @@ using System.Linq;
 using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Components.Endpoints;
 using Microsoft.AspNetCore.Components.Infrastructure;
+using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Routing;
+using Microsoft.AspNetCore.StaticFiles;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.FileProviders;
 
 namespace Microsoft.AspNetCore.Builder;
 
@@ -26,10 +29,42 @@ public static class RazorComponentsEndpointRouteBuilderExtensions
         ArgumentNullException.ThrowIfNull(endpoints);
 
         EnsureRazorComponentServices(endpoints);
+        AddBlazorWebJsEndpoint(endpoints);
 
         return GetOrCreateDataSource<TRootComponent>(endpoints).DefaultBuilder;
     }
 
+    private static void AddBlazorWebJsEndpoint(IEndpointRouteBuilder endpoints)
+    {
+        var options = new StaticFileOptions
+        {
+            FileProvider = new ManifestEmbeddedFileProvider(typeof(RazorComponentsEndpointRouteBuilderExtensions).Assembly),
+            OnPrepareResponse = CacheHeaderSettings.SetCacheHeaders
+        };
+
+        var app = endpoints.CreateApplicationBuilder();
+        app.Use(next => context =>
+        {
+            // Set endpoint to null so the static files middleware will handle the request.
+            context.SetEndpoint(null);
+
+            return next(context);
+        });
+        app.UseStaticFiles(options);
+
+        var blazorEndpoint = endpoints.Map("/_framework/blazor.web.js", app.Build())
+            .WithDisplayName("Blazor web static files");
+
+        blazorEndpoint.Add((builder) => ((RouteEndpointBuilder)builder).Order = int.MinValue);
+
+#if DEBUG
+        // We only need to serve the sourcemap when working on the framework, not in the distributed packages
+        endpoints.Map("/_framework/blazor.web.js.map", app.Build())
+            .WithDisplayName("Blazor web static files sourcemap")
+            .Add((builder) => ((RouteEndpointBuilder)builder).Order = int.MinValue);
+#endif
+    }
+
     private static RazorComponentEndpointDataSource<TRootComponent> GetOrCreateDataSource<TRootComponent>(IEndpointRouteBuilder endpoints)
         where TRootComponent : IRazorComponentApplication<TRootComponent>
     {

+ 6 - 0
src/Components/Endpoints/src/Directory.Build.targets

@@ -0,0 +1,6 @@
+<Project>
+  <Import Project="$([MSBuild]::GetDirectoryNameOfFileAbove($(MSBuildThisFileDirectory)..\, Directory.Build.targets))\Directory.Build.targets" />
+  <!-- Workaround target import for project references to Microsoft.Extensions.FileProviders.Embedded -->
+  <Import
+    Project="$(RepoRoot)src\FileProviders\Embedded\src\build\netstandard2.0\Microsoft.Extensions.FileProviders.Embedded.targets" />
+</Project>

+ 49 - 0
src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj

@@ -3,12 +3,26 @@
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
     <Description>Server side rendering for ASP.NET Core Components.</Description>
     <IsAspNetCoreApp>true</IsAspNetCoreApp>
+    <GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
     <GenerateDocumentationFile>true</GenerateDocumentationFile>
     <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
     <NoWarn>$(NoWarn);CS0436</NoWarn>
     <IsPackable>false</IsPackable>
+    <EmbeddedFilesManifestFileName>Microsoft.Extensions.FileProviders.Embedded.Manifest.xml</EmbeddedFilesManifestFileName>
     <Nullable>enable</Nullable>
   </PropertyGroup>
+
+  <!-- This workaround is required when referencing FileProviders.Embedded as a project -->
+  <PropertyGroup>
+    <_FileProviderTaskAssembly>$(ArtifactsDir)bin\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task\$(Configuration)\netstandard2.0\Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.dll</_FileProviderTaskAssembly>
+  </PropertyGroup>
+
+  <UsingTask AssemblyFile="$(_FileProviderTaskAssembly)" TaskName="Microsoft.Extensions.FileProviders.Embedded.Manifest.Task.GenerateEmbeddedResourcesManifest" />
+
+  <ItemGroup>
+    <EmbeddedResource Update="Resources.resx" ExcludeFromManifest="true" />
+  </ItemGroup>
+
   <ItemGroup>
     <Compile Include="$(SharedSourceRoot)Components\WebAssemblyComponentSerializationSettings.cs" LinkBase="DependencyInjection" />
     <Compile Include="$(SharedSourceRoot)Components\ServerComponentSerializationSettings.cs" LinkBase="DependencyInjection" />
@@ -18,8 +32,18 @@
     <Compile Include="$(RepoRoot)src\Shared\Components\ComponentParameter.cs" LinkBase="DependencyInjection" />
     <Compile Include="$(RepoRoot)src\Shared\Components\PrerenderComponentApplicationStore.cs" LinkBase="DependencyInjection" />
     <Compile Include="$(RepoRoot)src\Shared\Components\ProtectedPrerenderComponentApplicationStore.cs" LinkBase="DependencyInjection" />
+    <Compile Include="$(ComponentsSharedSourceRoot)src\CacheHeaderSettings.cs" Link="Shared\CacheHeaderSettings.cs" />
 
     <Compile Include="$(SharedSourceRoot)PropertyHelper\**\*.cs" />
+
+    <!-- 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="..\..\Web.JS\Microsoft.AspNetCore.Components.Web.JS.npmproj"
+      Condition="'$(BuildNodeJS)' != 'false' and '$(BuildingInsideVisualStudio)' != 'true'"
+      Private="false"
+      ReferenceOutputAssembly="false"
+      SkipGetTargetFrameworkProperties="true"
+      UndefineProperties="TargetFramework" />
   </ItemGroup>
 
   <ItemGroup>
@@ -28,9 +52,34 @@
     <Reference Include="Microsoft.AspNetCore.DataProtection.Extensions" />
     <Reference Include="Microsoft.AspNetCore.Hosting.Abstractions" />
     <Reference Include="Microsoft.AspNetCore.Html.Abstractions" />
+    <Reference Include="Microsoft.AspNetCore.Http.Abstractions" />
     <Reference Include="Microsoft.AspNetCore.Routing" />
+    <Reference Include="Microsoft.AspNetCore.StaticFiles" />
+    <Reference Include="Microsoft.Extensions.FileProviders.Embedded" />
   </ItemGroup>
 
+  <PropertyGroup>
+    <BlazorWebJSFilename>blazor.web.js</BlazorWebJSFilename>
+    <!-- Microsoft.AspNetCore.Components.Web.JS.npmproj always capitalizes the directory name. -->
+    <BlazorWebJSFile Condition=" '$(Configuration)' == 'Debug' ">..\..\Web.JS\dist\Debug\$(BlazorWebJSFilename)</BlazorWebJSFile>
+    <BlazorWebJSFile Condition=" '$(Configuration)' != 'Debug' ">..\..\Web.JS\dist\Release\$(BlazorWebJSFilename)</BlazorWebJSFile>
+  </PropertyGroup>
+
+  <!-- blazor.web.js should exist after Microsoft.AspNetCore.Components.Web.JS.npmproj builds. Fall back if not. -->
+  <Target Name="_CheckBlazorWebJSPath" AfterTargets="ResolveProjectReferences" Condition=" !EXISTS('$(BlazorWebJSFile)') ">
+    <Warning Text="'$(BlazorWebJSFile)' does not exist. Falling back to checked-in copy." />
+    <PropertyGroup>
+      <BlazorWebJSFile>..\..\Web.JS\dist\Release\$(BlazorWebJSFilename)</BlazorWebJSFile>
+    </PropertyGroup>
+  </Target>
+
+  <Target Name="_AddEmbeddedBlazorWebJS" AfterTargets="_CheckBlazorWebJSPath">
+    <ItemGroup>
+      <EmbeddedResource Include="$(BlazorWebJSFile)" LogicalName="_framework/$(BlazorWebJSFilename)" />
+      <EmbeddedResource Include="$(BlazorWebJSFile).map" LogicalName="_framework/$(BlazorWebJSFilename).map" Condition="Exists('$(BlazorWebJSFile).map')" />
+    </ItemGroup>
+  </Target>
+
   <ItemGroup>
     <InternalsVisibleTo Include="Microsoft.AspNetCore.Components.Endpoints.Tests" />
     <InternalsVisibleTo Include="Microsoft.AspNetCore.Mvc.TagHelpers.Test" />

+ 7 - 66
src/Components/Endpoints/src/RazorComponentEndpoint.cs

@@ -4,13 +4,9 @@
 using System.Buffers;
 using System.Text;
 using System.Text.Encodings.Web;
-using Microsoft.AspNetCore.Components.Web.HtmlRendering;
-using Microsoft.AspNetCore.Hosting;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.WebUtilities;
 using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Options;
 
 namespace Microsoft.AspNetCore.Components.Endpoints;
 
@@ -19,7 +15,10 @@ internal static class RazorComponentEndpoint
     public static RequestDelegate CreateRouteDelegate(Type componentType)
     {
         return httpContext =>
-            RenderComponentToResponse(httpContext, RenderMode.Static, componentType, componentParameters: null, preventStreamingRendering: false);
+        {
+            httpContext.Response.ContentType = RazorComponentResultExecutor.DefaultContentType;
+            return RenderComponentToResponse(httpContext, RenderMode.Static, componentType, componentParameters: null, preventStreamingRendering: false);
+        };
     }
 
     internal static Task RenderComponentToResponse(
@@ -55,32 +54,13 @@ internal static class RazorComponentEndpoint
 
             // Importantly, we must not yield this thread (which holds exclusive access to the renderer sync context)
             // in between the first call to htmlContent.WriteTo and the point where we start listening for subsequent
-            // streaming SSR batches. Otherwise some other code might dispatch to the renderer sync context and cause
-            // a batch that would get missed.
+            // streaming SSR batches (inside SendStreamingUpdatesAsync). Otherwise some other code might dispatch to the
+            // renderer sync context and cause a batch that would get missed.
             htmlContent.WriteTo(writer, HtmlEncoder.Default); // Don't use WriteToAsync, as per the comment above
 
             if (!htmlContent.QuiescenceTask.IsCompleted)
             {
-                endpointHtmlRenderer.OnContentUpdated(htmlComponents =>
-                    EmitStreamingRenderingUpdate(htmlComponents, writer));
-
-                try
-                {
-                    await writer.FlushAsync(); // Make sure the initial HTML was sent
-                    await htmlContent.QuiescenceTask;
-                }
-                catch (NavigationException navigationException)
-                {
-                    HandleNavigationAfterResponseStarted(writer, navigationException.Location);
-                }
-                catch (Exception ex)
-                {
-                    HandleExceptionAfterResponseStarted(httpContext, writer, ex);
-
-                    // The rest of the pipeline can treat this as a regular unhandled exception
-                    // TODO: Is this really right? I think we'll terminate the response in an invalid way.
-                    throw;
-                }
+                await endpointHtmlRenderer.SendStreamingUpdatesAsync(httpContext, htmlContent.QuiescenceTask, writer);
             }
 
             // Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying
@@ -90,45 +70,6 @@ internal static class RazorComponentEndpoint
         });
     }
 
-    private static void EmitStreamingRenderingUpdate(IEnumerable<HtmlComponentBase> htmlComponents, TextWriter writer)
-    {
-        writer.Write("<blazor-ssr>");
-        foreach (var entry in htmlComponents)
-        {
-            // This relies on the component producing well-formed markup (i.e., it can't have a closing
-            // </template> at the top level without a preceding matching <template>). Alternatively we
-            // could look at using a custom TextWriter that does some extra encoding of all the content
-            // as it is being written out.
-            writer.Write($"<template blazor-component-id=\"{entry.ComponentId}\">");
-            entry.WriteHtmlTo(writer);
-            writer.Write("</template>");
-        }
-        writer.Write("</blazor-ssr>");
-    }
-
-    private static void HandleExceptionAfterResponseStarted(HttpContext httpContext, TextWriter writer, Exception exception)
-    {
-        // We already started the response so we have no choice but to return a 200 with HTML and will
-        // have to communicate the error information within that
-        var env = httpContext.RequestServices.GetRequiredService<IWebHostEnvironment>();
-        var options = httpContext.RequestServices.GetRequiredService<IOptions<RazorComponentsEndpointsOptions>>();
-        var showDetailedErrors = env.IsDevelopment() || options.Value.DetailedErrors;
-        var message = showDetailedErrors
-            ? exception.ToString()
-            : "There was an unhandled exception on the current request. For more details turn on detailed exceptions by setting 'DetailedErrors: true' in 'appSettings.Development.json'";
-
-        writer.Write("<template blazor-type=\"exception\">");
-        writer.Write(message);
-        writer.Write("</template>");
-    }
-
-    private static void HandleNavigationAfterResponseStarted(TextWriter writer, string destinationUrl)
-    {
-        writer.Write("<template blazor-type=\"redirection\">");
-        writer.Write(destinationUrl);
-        writer.Write("</template>");
-    }
-
     private static TextWriter CreateResponseWriter(Stream bodyStream)
     {
         // Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize

+ 4 - 4
src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderComponents.cs → src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs

@@ -164,16 +164,16 @@ internal sealed partial class EndpointHtmlRenderer
     public class PrerenderedComponentHtmlContent : IHtmlAsyncContent
     {
         private readonly Dispatcher? _dispatcher;
-        private readonly HtmlComponent? _htmlToEmitOrNull;
+        private readonly HtmlRootComponent? _htmlToEmitOrNull;
         private readonly ServerComponentMarker? _serverMarker;
         private readonly WebAssemblyComponentMarker? _webAssemblyMarker;
 
         public static PrerenderedComponentHtmlContent Empty { get; }
-            = new PrerenderedComponentHtmlContent(null, HtmlComponent.Empty, null, null);
+            = new PrerenderedComponentHtmlContent(null, default, null, null);
 
         public PrerenderedComponentHtmlContent(
             Dispatcher? dispatcher, // If null, we're only emitting the markers
-            HtmlComponent? htmlToEmitOrNull, // If null, we're only emitting the markers
+            HtmlRootComponent? htmlToEmitOrNull, // If null, we're only emitting the markers
             ServerComponentMarker? serverMarker,
             WebAssemblyComponentMarker? webAssemblyMarker)
         {
@@ -222,6 +222,6 @@ internal sealed partial class EndpointHtmlRenderer
         }
 
         public Task QuiescenceTask =>
-            _htmlToEmitOrNull is null ? Task.CompletedTask : _htmlToEmitOrNull.QuiescenceTask;
+            _htmlToEmitOrNull.HasValue ? _htmlToEmitOrNull.Value.QuiescenceTask : Task.CompletedTask;
     }
 }

+ 0 - 0
src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderState.cs → src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs


+ 184 - 0
src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Streaming.cs

@@ -0,0 +1,184 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Runtime.InteropServices;
+using Microsoft.AspNetCore.Components.RenderTree;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.Components.Endpoints;
+
+internal partial class EndpointHtmlRenderer
+{
+    private TextWriter? _streamingUpdatesWriter;
+    private HashSet<int>? _visitedComponentIdsInCurrentStreamingBatch;
+
+    public async Task SendStreamingUpdatesAsync(HttpContext httpContext, Task untilTaskCompleted, TextWriter writer)
+    {
+        if (_streamingUpdatesWriter is not null)
+        {
+            // The framework is the only caller, so it's OK to have a nonobvious restriction like this
+            throw new InvalidOperationException($"{nameof(SendStreamingUpdatesAsync)} can only be called once.");
+        }
+
+        _streamingUpdatesWriter = writer;
+
+        try
+        {
+            await writer.FlushAsync(); // Make sure the initial HTML was sent
+            await untilTaskCompleted;
+        }
+        catch (NavigationException navigationException)
+        {
+            HandleNavigationAfterResponseStarted(writer, navigationException.Location);
+        }
+        catch (Exception ex)
+        {
+            HandleExceptionAfterResponseStarted(httpContext, writer, ex);
+
+            // The rest of the pipeline can treat this as a regular unhandled exception
+            // TODO: Is this really right? I think we'll terminate the response in an invalid way.
+            throw;
+        }
+    }
+
+    private void SendBatchAsStreamingUpdate(in RenderBatch renderBatch, TextWriter writer)
+    {
+        var count = renderBatch.UpdatedComponents.Count;
+        if (count > 0)
+        {
+            writer.Write("<blazor-ssr>");
+
+            // Each time we transmit the HTML for a component, we also transmit the HTML for its descendants.
+            // So, if we transmitted *every* component in the batch separately, there would be a lot of duplication.
+            // The subtrees projected from each component would overlap a lot.
+            //
+            // To avoid duplicated HTML transmission and unnecessary work on the client, we want to pick a subset
+            // of updated components such that, when we transmit that subset with their descendants, it includes
+            // every updated component without any duplication.
+            //
+            // This is quite easy if we first sort the list into depth order. As long as we process parents before
+            // their descendants, we can keep a log of the descendants we rendered, and then skip over those if we
+            // see them later in the list. This also implicitly handles the case where a batch contains the same
+            // root component multiple times (we only want to emit its HTML once).
+
+            // First, get a list of updated components in depth order
+            // We'll stackalloc a buffer if it's small, otherwise take a buffer on the heap
+            var bufSizeRequired = count * Marshal.SizeOf<ComponentIdAndDepth>();
+            var componentIdsInDepthOrder = bufSizeRequired < 1024
+                ? MemoryMarshal.Cast<byte, ComponentIdAndDepth>(stackalloc byte[bufSizeRequired])
+                : new ComponentIdAndDepth[count];
+            for (var i = 0; i < count; i++)
+            {
+                var componentId = renderBatch.UpdatedComponents.Array[i].ComponentId;
+                componentIdsInDepthOrder[i] = new(componentId, GetComponentDepth(componentId));
+            }
+            MemoryExtensions.Sort(componentIdsInDepthOrder, static (left, right) => left.Depth - right.Depth);
+
+            // Reset the component rendering tracker. This is safe to share as an instance field because batch-rendering
+            // is synchronous only one batch can be rendered at a time.
+            if (_visitedComponentIdsInCurrentStreamingBatch is null)
+            {
+                _visitedComponentIdsInCurrentStreamingBatch = new();
+            }
+            else
+            {
+                _visitedComponentIdsInCurrentStreamingBatch.Clear();
+            }
+
+            // Now process the list, skipping any we've already visited in an earlier iteration
+            for (var i = 0; i < componentIdsInDepthOrder.Length; i++)
+            {
+                var componentId = componentIdsInDepthOrder[i].ComponentId;
+                if (_visitedComponentIdsInCurrentStreamingBatch.Contains(componentId))
+                {
+                    continue;
+                }
+
+                // This format relies on the component producing well-formed markup (i.e., it can't have a
+                // </template> at the top level without a preceding matching <template>). Alternatively we
+                // could look at using a custom TextWriter that does some extra encoding of all the content
+                // as it is being written out.
+                writer.Write($"<template blazor-component-id=\"");
+                writer.Write(componentId);
+                writer.Write("\">");
+
+                // We don't need boundary markers at the top-level since the info is on the <template> anyway.
+                WriteComponentHtml(componentId, writer, allowBoundaryMarkers: false);
+
+                writer.Write("</template>");
+            }
+
+            writer.Write("</blazor-ssr>");
+        }
+    }
+
+    private int GetComponentDepth(int componentId)
+    {
+        // Regard root components as depth 0, their immediate children as 1, etc.
+        var componentState = GetComponentState(componentId);
+        var depth = 0;
+        while (componentState.ParentComponentState is { } parentComponentState)
+        {
+            depth++;
+            componentState = parentComponentState;
+        }
+
+        return depth;
+    }
+
+    private static void HandleExceptionAfterResponseStarted(HttpContext httpContext, TextWriter writer, Exception exception)
+    {
+        // We already started the response so we have no choice but to return a 200 with HTML and will
+        // have to communicate the error information within that
+        var env = httpContext.RequestServices.GetRequiredService<IWebHostEnvironment>();
+        var options = httpContext.RequestServices.GetRequiredService<IOptions<RazorComponentsEndpointsOptions>>();
+        var showDetailedErrors = env.IsDevelopment() || options.Value.DetailedErrors;
+        var message = showDetailedErrors
+            ? exception.ToString()
+            : "There was an unhandled exception on the current request. For more details turn on detailed exceptions by setting 'DetailedErrors: true' in 'appSettings.Development.json'";
+
+        writer.Write("<template blazor-type=\"exception\">");
+        writer.Write(message);
+        writer.Write("</template>");
+    }
+
+    private static void HandleNavigationAfterResponseStarted(TextWriter writer, string destinationUrl)
+    {
+        writer.Write("<template blazor-type=\"redirection\">");
+        writer.Write(destinationUrl);
+        writer.Write("</template>");
+    }
+
+    protected override void WriteComponentHtml(int componentId, TextWriter output)
+        => WriteComponentHtml(componentId, output, allowBoundaryMarkers: true);
+
+    private void WriteComponentHtml(int componentId, TextWriter output, bool allowBoundaryMarkers)
+    {
+        _visitedComponentIdsInCurrentStreamingBatch?.Add(componentId);
+
+        var renderBoundaryMarkers = allowBoundaryMarkers
+            && ((EndpointComponentState)GetComponentState(componentId)).StreamRendering;
+
+        if (renderBoundaryMarkers)
+        {
+            output.Write("<!--bl:");
+            output.Write(componentId);
+            output.Write("-->");
+        }
+
+        base.WriteComponentHtml(componentId, output);
+
+        if (renderBoundaryMarkers)
+        {
+            output.Write("<!--/bl:");
+            output.Write(componentId);
+            output.Write("-->");
+        }
+    }
+
+    private readonly record struct ComponentIdAndDepth(int ComponentId, int Depth);
+}

+ 14 - 35
src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs

@@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Components.Infrastructure;
 using Microsoft.AspNetCore.Components.Rendering;
 using Microsoft.AspNetCore.Components.RenderTree;
 using Microsoft.AspNetCore.Components.Routing;
-using Microsoft.AspNetCore.Components.Web.HtmlRendering;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http.Extensions;
 using Microsoft.Extensions.DependencyInjection;
@@ -26,16 +25,11 @@ namespace Microsoft.AspNetCore.Components.Endpoints;
 /// EndpointHtmlRenderer wraps the underlying <see cref="Web.HtmlRenderer"/> mechanism, annotating the
 /// output with prerendering markers so the content can later switch into interactive mode when used with
 /// blazor.*.js. It also deals with initializing the standard component DI services once per request.
-///
-/// Note that EndpointHtmlRenderer doesn't deal with streaming SSR since that's not applicable to Html.RenderComponentAsync
-/// or ComponentTagHelper, because they don't control the entire response. Streaming SSR is a layer around this implemented
-/// only inside RazorComponentResult/RazorComponentEndpoint.
 /// </summary>
 internal sealed partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrerenderer
 {
     private readonly IServiceProvider _services;
     private Task? _servicesInitializedTask;
-    private Action<IEnumerable<HtmlComponentBase>>? _onContentUpdatedCallback;
 
     // The underlying Renderer always tracks the pending tasks representing *full* quiescence, i.e.,
     // when everything (regardless of streaming SSR) is fully complete. In this subclass we also track
@@ -67,17 +61,6 @@ internal sealed partial class EndpointHtmlRenderer : StaticHtmlRenderer, ICompon
         await componentApplicationLifetime.RestoreStateAsync(new PrerenderComponentApplicationStore());
     }
 
-    public void OnContentUpdated(Action<IEnumerable<HtmlComponentBase>> callback)
-    {
-        if (_onContentUpdatedCallback is not null)
-        {
-            // The framework is the only user of this internal API, so it's OK to have an arbitrary limit like this
-            throw new InvalidOperationException($"{nameof(OnContentUpdated)} can only be called once.");
-        }
-
-        _onContentUpdatedCallback = callback;
-    }
-
     protected override ComponentState CreateComponentState(int componentId, IComponent component, ComponentState? parentComponentState)
         => new EndpointComponentState(this, componentId, component, parentComponentState);
 
@@ -101,27 +84,23 @@ internal sealed partial class EndpointHtmlRenderer : StaticHtmlRenderer, ICompon
 
     protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
     {
-        var count = renderBatch.UpdatedComponents.Count;
-        if (count > 0 && _onContentUpdatedCallback is not null)
+        if (_streamingUpdatesWriter is { } writer)
+        {
+            SendBatchAsStreamingUpdate(renderBatch, writer);
+            return FlushThenComplete(writer, base.UpdateDisplayAsync(renderBatch));
+        }
+        else
         {
-            // We deduplicate the set of components in the batch because we're sending their entire current rendered
-            // state, not just an intermediate diff (so there's never a reason to include the same component output
-            // more than once in this callback)
-            var htmlComponents = new Dictionary<int, HtmlComponentBase>(count);
-            for (var i = 0; i < count; i++)
-            {
-                ref var diff = ref renderBatch.UpdatedComponents.Array[i];
-                var componentId = diff.ComponentId;
-                if (!htmlComponents.ContainsKey(componentId))
-                {
-                    htmlComponents.Add(componentId, new HtmlComponentBase(this, componentId));
-                }
-            }
-
-            _onContentUpdatedCallback(htmlComponents.Values);
+            return base.UpdateDisplayAsync(renderBatch);
         }
 
-        return base.UpdateDisplayAsync(renderBatch);
+        // Workaround for methods with "in" parameters not being allowed to be async
+        // We resolve the "result" first and then combine it with the FlushAsync task here
+        static async Task FlushThenComplete(TextWriter writerToFlush, Task completion)
+        {
+            await writerToFlush.FlushAsync();
+            await completion;
+        }
     }
 
     private static string GetFullUri(HttpRequest request)

+ 68 - 18
src/Components/Endpoints/test/RazorComponentEndpointTest.cs

@@ -55,7 +55,9 @@ public class RazorComponentEndpointTest
             typeof(StreamingAsyncLoadingComponent),
             PropertyHelper.ObjectToDictionary(new { LoadingTask = tcs.Task }).AsReadOnly(),
             preventStreamingRendering: false);
-        Assert.Equal("Loading task status: WaitingForActivation", GetStringContent(responseBody));
+        Assert.Equal(
+            "<!--bl:X-->Loading task status: WaitingForActivation<!--/bl:X-->",
+            MaskComponentIds(GetStringContent(responseBody)));
 
         // Assert 2: Result task remains incomplete for as long as the component's loading operation remains in flight
         // This keeps the HTTP response open
@@ -66,12 +68,12 @@ public class RazorComponentEndpointTest
         tcs.SetResult();
         await completionTask;
         Assert.Equal(
-            "Loading task status: WaitingForActivation<blazor-ssr><template blazor-component-id=\"X\">Loading task status: RanToCompletion</template></blazor-ssr>",
+            "<!--bl:X-->Loading task status: WaitingForActivation<!--/bl:X--><blazor-ssr><template blazor-component-id=\"X\">Loading task status: RanToCompletion</template></blazor-ssr>",
             MaskComponentIds(GetStringContent(responseBody)));
     }
 
     [Fact]
-    public async Task EmitsEachComponentOnlyOncePerStreamingUpdate()
+    public async Task EmitsEachComponentOnlyOncePerStreamingUpdate_WhenAComponentRendersTwice()
     {
         // Arrange
         var tcs = new TaskCompletionSource();
@@ -86,14 +88,50 @@ public class RazorComponentEndpointTest
             typeof(DoubleRenderingStreamingAsyncComponent),
             PropertyHelper.ObjectToDictionary(new { WaitFor = tcs.Task }).AsReadOnly(),
             preventStreamingRendering: false);
-        Assert.Equal("Loading...", GetStringContent(responseBody));
+        Assert.Equal(
+            "<!--bl:X-->Loading...<!--/bl:X-->",
+            MaskComponentIds(GetStringContent(responseBody)));
 
         // Act/Assert 2: When loading completes, it emits a streaming batch update with only one copy of the final output,
         // despite the RenderBatch containing two diffs from the component
         tcs.SetResult();
         await completionTask;
         Assert.Equal(
-            "Loading...<blazor-ssr><template blazor-component-id=\"X\">Loaded</template></blazor-ssr>",
+            "<!--bl:X-->Loading...<!--/bl:X--><blazor-ssr><template blazor-component-id=\"X\">Loaded</template></blazor-ssr>",
+            MaskComponentIds(GetStringContent(responseBody)));
+    }
+
+    [Fact]
+    public async Task EmitsEachComponentOnlyOncePerStreamingUpdate_WhenAnAncestorAlsoUpdated()
+    {
+        // Since the HTML rendered for each component also includes all its descendants, we don't
+        // want to render output for any component that also has an ancestor in the set of updates
+        // (as it would then be output twice)
+
+        // Arrange
+        var tcs = new TaskCompletionSource();
+        var httpContext = GetTestHttpContext();
+        var responseBody = new MemoryStream();
+        httpContext.Response.Body = responseBody;
+
+        // Act/Assert 1: Emits the initial pre-quiescent output to the response
+        var completionTask = RazorComponentEndpoint.RenderComponentToResponse(
+            httpContext,
+            RenderMode.Static,
+            typeof(StreamingComponentWithChild),
+            PropertyHelper.ObjectToDictionary(new { LoadingTask = tcs.Task }).AsReadOnly(),
+            preventStreamingRendering: false);
+        var expectedInitialHtml = "<!--bl:X-->[LoadingTask: WaitingForActivation]\n<!--bl:X-->[Child render: 1]\n<!--/bl:X--><!--/bl:X-->";
+        Assert.Equal(
+            expectedInitialHtml,
+            MaskComponentIds(GetStringContent(responseBody)));
+
+        // Act/Assert 2: When loading completes, it emits a streaming batch update in which the
+        // child is present only within the parent markup, not as a separate entry
+        tcs.SetResult();
+        await completionTask;
+        Assert.Equal(
+            $"{expectedInitialHtml}<blazor-ssr><template blazor-component-id=\"X\">[LoadingTask: RanToCompletion]\n<!--bl:X-->[Child render: 2]\n<!--/bl:X--></template></blazor-ssr>",
             MaskComponentIds(GetStringContent(responseBody)));
     }
 
@@ -119,7 +157,9 @@ public class RazorComponentEndpointTest
         // Act/Assert: Does complete when loading finishes
         tcs.SetResult();
         await completionTask;
-        Assert.Equal("Loading task status: RanToCompletion", GetStringContent(responseBody));
+        Assert.Equal(
+            "<!--bl:X-->Loading task status: RanToCompletion<!--/bl:X-->",
+            MaskComponentIds(GetStringContent(responseBody)));
     }
 
     [Fact]
@@ -188,8 +228,8 @@ public class RazorComponentEndpointTest
 
         // Assert
         Assert.Equal(
-            $"Some output\n<template blazor-type=\"redirection\">https://test/somewhere/else</template>",
-            GetStringContent(responseBody));
+            $"<!--bl:X-->Some output\n<!--/bl:X--><template blazor-type=\"redirection\">https://test/somewhere/else</template>",
+            MaskComponentIds(GetStringContent(responseBody)));
     }
 
     [Fact]
@@ -244,8 +284,8 @@ public class RazorComponentEndpointTest
         // Assert
         Assert.Contains("Test message", ex.Message);
         Assert.Contains(
-            $"Some output\n<template blazor-type=\"exception\">{expectedResponseExceptionInfo}",
-            GetStringContent(responseBody));
+            $"<!--bl:X-->Some output\n<!--/bl:X--><template blazor-type=\"exception\">{expectedResponseExceptionInfo}",
+            MaskComponentIds(GetStringContent(responseBody)));
     }
 
     [Fact]
@@ -261,12 +301,12 @@ public class RazorComponentEndpointTest
         await Task.Yield(); // Just to show it's still not completed after
         Assert.False(initialOutputTask.IsCompleted);
 
-        // Act/Assert: Produce initial output
+        // Act/Assert: Produce initial output, noting absence of streaming markers at top level
         testContext.TopLevelComponentTask.SetResult();
         await initialOutputTask;
-        var html = GetStringContent(testContext.ResponseBody);
-        Assert.Contains("[Top level component: Loaded]", html);
-        Assert.Contains("[Within streaming region: Loading...]", html);
+        var html = MaskComponentIds(GetStringContent(testContext.ResponseBody));
+        Assert.StartsWith("[Top level component: Loaded]", html);
+        Assert.Contains("[Within streaming region: <!--bl:X-->Loading...<!--/bl:X-->]", html);
         Assert.Contains("[Within nested nonstreaming region: Loaded]", html);
         Assert.DoesNotContain("blazor-ssr", html);
 
@@ -295,11 +335,13 @@ public class RazorComponentEndpointTest
         // Act/Assert: Does produce output when nonstreaming subtree is quiescent
         testContext.WithinNestedNonstreamingRegionTask.SetResult();
         await initialOutputTask;
-        var html = GetStringContent(testContext.ResponseBody);
+        var html = MaskComponentIds(GetStringContent(testContext.ResponseBody));
+        Assert.Contains("[Within streaming region: <!--bl:X-->Loading...<!--/bl:X-->]", html);
+        Assert.DoesNotContain("blazor-ssr", html);
+
+        // Assert: No boundary markers around nonstreaming components, even if they are nested in a streaming region
         Assert.Contains("[Top level component: Loaded]", html);
-        Assert.Contains("[Within streaming region: Loading...]", html);
         Assert.Contains("[Within nested nonstreaming region: Loaded]", html);
-        Assert.DoesNotContain("blazor-ssr", html);
 
         // Act/Assert: Complete the streaming
         testContext.WithinStreamingRegionTask.SetResult();
@@ -311,8 +353,16 @@ public class RazorComponentEndpointTest
     }
 
     // We don't want these tests to be hardcoded for specific component ID numbers, so replace them all with X for assertions
+    private static readonly Regex TemplateElementComponentIdRegex = new Regex("blazor-component-id=\"\\d+\"");
+    private static readonly Regex OpenBoundaryMarkerRegex = new Regex("<!--bl:\\d+-->");
+    private static readonly Regex CloseBoundaryMarkerRegex = new Regex("<!--/bl:\\d+-->");
     private static string MaskComponentIds(string html)
-        => new Regex("blazor-component-id=\"\\d+\"").Replace(html, "blazor-component-id=\"X\"");
+    {
+        html = TemplateElementComponentIdRegex.Replace(html, "blazor-component-id=\"X\"");
+        html = OpenBoundaryMarkerRegex.Replace(html, "<!--bl:X-->");
+        html = CloseBoundaryMarkerRegex.Replace(html, "<!--/bl:X-->");
+        return html;
+    }
 
     private VaryStreamingScenariosContext PrepareVaryStreamingScenariosTests()
     {

+ 6 - 0
src/Components/Endpoints/test/TestComponents/StreamingComponentChild.razor

@@ -0,0 +1,6 @@
+[Child render: @(++renderCount)]
+@code {
+    int renderCount;
+
+    [Parameter] public object SomeParameterToEnsureRerendering { get; set; }
+}

+ 9 - 0
src/Components/Endpoints/test/TestComponents/StreamingComponentWithChild.razor

@@ -0,0 +1,9 @@
+@attribute [StreamRendering(true)]
+[LoadingTask: @LoadingTask.Status]
+<StreamingComponentChild SomeParameterToEnsureRerendering="@(new object())" />
+@code {
+    [Parameter] public Task LoadingTask { get; set; }
+
+    protected override Task OnInitializedAsync()
+        => LoadingTask;
+}

+ 2 - 0
src/Components/Samples/BlazorUnitedApp/Shared/MainLayout.razor

@@ -41,5 +41,7 @@
         <a href="" class="reload">Reload</a>
         <a class="dismiss">🗙</a>
     </div>
+
+    <script src="_framework/blazor.web.js" suppress-error="BL9992"></script>
 </body>
 </html>

+ 7 - 0
src/Components/Server/src/Builder/ComponentEndpointRouteBuilderExtensions.cs

@@ -115,6 +115,13 @@ public static class ComponentEndpointRouteBuilderExtensions
             .WithDisplayName("Blazor static files");
 
         blazorEndpoint.Add((builder) => ((RouteEndpointBuilder)builder).Order = int.MinValue);
+        
+#if DEBUG
+        // We only need to serve the sourcemap when working on the framework, not in the distributed packages
+        endpoints.Map("/_framework/blazor.server.js.map", app.Build())
+            .WithDisplayName("Blazor static files sourcemap")
+            .Add((builder) => ((RouteEndpointBuilder)builder).Order = int.MinValue);
+#endif
 
         return blazorEndpoint;
     }

+ 2 - 0
src/Components/Web.JS/Microsoft.AspNetCore.Components.Web.JS.npmproj

@@ -13,6 +13,8 @@
     -->
     <BuildOutputFiles Condition=" '$(Configuration)' == 'Debug' " Include="dist\Debug\blazor.server.js" />
     <BuildOutputFiles Condition=" '$(Configuration)' != 'Debug' " Include="dist\Release\blazor.server.js" />
+    <BuildOutputFiles Condition=" '$(Configuration)' == 'Debug' " Include="dist\Debug\blazor.web.js" />
+    <BuildOutputFiles Condition=" '$(Configuration)' != 'Debug' " Include="dist\Release\blazor.web.js" />
     <BuildOutputFiles Condition=" '$(Configuration)' == 'Debug' " Include="dist\Debug\blazor.webassembly.js" />
     <BuildOutputFiles Condition=" '$(Configuration)' != 'Debug' " Include="dist\Release\blazor.webassembly.js" />
     <BuildOutputFiles Condition=" '$(Configuration)' == 'Debug' " Include="dist\Debug\blazor.webview.js" />

文件差異過大導致無法顯示
+ 0 - 0
src/Components/Web.JS/dist/Release/blazor.server.js


+ 1 - 0
src/Components/Web.JS/dist/Release/blazor.web.js

@@ -0,0 +1 @@
+(()=>{"use strict";class t extends HTMLElement{connectedCallback(){var t;null===(t=this.parentNode)||void 0===t||t.removeChild(this);const e=this.attachShadow({mode:"open"}),n=document.createElement("slot");e.appendChild(n),n.addEventListener("slotchange",(t=>{this.childNodes.forEach((t=>{if(t instanceof HTMLTemplateElement){const e=t.getAttribute("blazor-component-id");e&&function(t,e){const n=function(t){const e=`bl:${t}`,n=document.createNodeIterator(document,NodeFilter.SHOW_COMMENT);let o=null;for(;(o=n.nextNode())&&o.textContent!==e;);if(!o)return null;const r=`/bl:${t}`;let a=null;for(;(a=n.nextNode())&&a.textContent!==r;);return a?{startMarker:o,endMarker:a}:null}(t);if(n){const{startMarker:t,endMarker:o}=n,r=new Range;for(r.setStart(t,t.textContent.length),r.setEnd(o,0),r.deleteContents();e.childNodes[0];)o.parentNode.insertBefore(e.childNodes[0],o)}}(e,t.content)}}))}))}}let e=!1;async function n(){if(e)throw new Error("Blazor has already started.");e=!0,customElements.define("blazor-ssr",t)}window.Blazor={start:n},document&&document.currentScript&&"false"!==document.currentScript.getAttribute("autostart")&&n()})();

+ 26 - 0
src/Components/Web.JS/src/Boot.Web.ts

@@ -0,0 +1,26 @@
+// Currently this only deals with inserting streaming content into the DOM.
+// Later this will be expanded to include:
+//  - Progressive enhancement of navigation and form posting
+//  - Preserving existing DOM elements in all the above
+//  - The capabilities of Boot.Server.ts and Boot.WebAssembly.ts to handle insertion
+//    of interactive components
+
+import { shouldAutoStart } from './BootCommon';
+import { attachStreamingRenderingListener } from './Rendering/StreamingRendering';
+
+let started = false;
+
+async function boot(): Promise<void> {
+  if (started) {
+    throw new Error('Blazor has already started.');
+  }
+  started = true;
+
+  attachStreamingRenderingListener();
+}
+
+window['Blazor'] = { start: boot }; // Temporary API stub until we include interactive features
+
+if (shouldAutoStart()) {
+  boot();
+}

+ 74 - 0
src/Components/Web.JS/src/Rendering/StreamingRendering.ts

@@ -0,0 +1,74 @@
+export function attachStreamingRenderingListener() {
+  customElements.define("blazor-ssr", BlazorStreamingUpdate);
+}
+
+class BlazorStreamingUpdate extends HTMLElement {
+  connectedCallback() {
+    // Synchronously remove this from the DOM to minimize our chance of affecting anything else
+    this.parentNode?.removeChild(this);
+
+    // The <blazor-ssr> element might not yet be populated since connectedCallback runs before
+    // the child markup is parsed. The most immediate way to get a notification when the child
+    // markup is added is to define a slot.
+    const shadowRoot = this.attachShadow({ mode: 'open' });
+    const slot = document.createElement('slot');
+    shadowRoot.appendChild(slot);
+
+    // When this element receives content, if it's <template blazor-component-id="...">...</template>,
+    // insert the template content into the DOM
+    slot.addEventListener('slotchange', _ => {
+      this.childNodes.forEach(node => {
+        if (node instanceof HTMLTemplateElement) {
+          const componentId = node.getAttribute('blazor-component-id');
+          if (componentId) {
+            insertStreamingContentIntoDocument(componentId, node.content);
+          }
+        }
+      });
+    });
+  }
+}
+
+function insertStreamingContentIntoDocument(componentIdAsString: string, docFrag: DocumentFragment): void {
+  const markers = findStreamingMarkers(componentIdAsString);
+  if (markers) {
+    const { startMarker, endMarker } = markers;
+
+    // Using Range for this is a bit weird, but this logic will go away anyway once we implement https://github.com/dotnet/aspnetcore/issues/47258
+    const existingContent = new Range();
+    existingContent.setStart(startMarker, startMarker.textContent!.length);
+    existingContent.setEnd(endMarker, 0);
+    existingContent.deleteContents();
+
+    while (docFrag.childNodes[0]) {
+      endMarker.parentNode!.insertBefore(docFrag.childNodes[0], endMarker);
+    }
+  }
+}
+
+function findStreamingMarkers(componentIdAsString: string): { startMarker: Comment, endMarker: Comment } | null {
+  // Find start marker
+  const expectedStartText = `bl:${componentIdAsString}`;
+  const iterator = document.createNodeIterator(document, NodeFilter.SHOW_COMMENT);
+  let startMarker: Comment | null = null;
+  while (startMarker = iterator.nextNode() as Comment | null) {
+    if (startMarker.textContent === expectedStartText) {
+      break;
+    }
+  }
+
+  if (!startMarker) {
+    return null;
+  }
+
+  // Find end marker
+  const expectedEndText = `/bl:${componentIdAsString}`;
+  let endMarker: Comment | null = null;
+  while (endMarker = iterator.nextNode() as Comment | null) {
+    if (endMarker.textContent === expectedEndText) {
+      break;
+    }
+  }
+
+  return endMarker ? { startMarker, endMarker } : null;
+}

+ 1 - 0
src/Components/Web.JS/src/webpack.config.js

@@ -16,6 +16,7 @@ module.exports = (env, args) => ({
     },
     entry: {
         'blazor.server': './Boot.Server.ts',
+        'blazor.web': './Boot.Web.ts',
         'blazor.webassembly': './Boot.WebAssembly.ts',
         'blazor.webview': './Boot.WebView.ts',
     },

+ 0 - 33
src/Components/Web/src/HtmlRendering/HtmlComponent.cs

@@ -1,33 +0,0 @@
-// 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.Components.HtmlRendering.Infrastructure;
-
-namespace Microsoft.AspNetCore.Components.Web.HtmlRendering;
-
-/// <summary>
-/// Represents the output of rendering a root component as HTML. The content can change if the component instance re-renders.
-/// </summary>
-public sealed class HtmlComponent : HtmlComponentBase
-{
-    /// <summary>
-    /// Gets an instance of <see cref="HtmlComponent"/> that produces no content.
-    /// </summary>
-    public static HtmlComponent Empty { get; } = new HtmlComponent();
-
-    internal HtmlComponent(StaticHtmlRenderer renderer, int componentId, Task quiescenceTask)
-        : base(renderer, componentId)
-    {
-        QuiescenceTask = quiescenceTask;
-    }
-
-    internal HtmlComponent() : base()
-    {
-        QuiescenceTask = Task.CompletedTask;
-    }
-
-    /// <summary>
-    /// Gets a <see cref="Task"/> that completes when the component hierarchy has completed asynchronous tasks such as loading.
-    /// </summary>
-    public Task QuiescenceTask { get; }
-}

+ 24 - 24
src/Components/Web/src/HtmlRendering/HtmlRenderer.cs

@@ -43,49 +43,49 @@ public sealed class HtmlRenderer : IDisposable, IAsyncDisposable
     /// <summary>
     /// Adds an instance of the specified component and instructs it to render. The resulting content represents the
     /// initial synchronous rendering output, which may later change. To wait for the component hierarchy to complete
-    /// any asynchronous operations such as loading, await <see cref="HtmlComponent.QuiescenceTask"/> before
-    /// reading content from the <see cref="HtmlComponent"/>.
+    /// any asynchronous operations such as loading, await <see cref="HtmlRootComponent.QuiescenceTask"/> before
+    /// reading content from the <see cref="HtmlRootComponent"/>.
     /// </summary>
     /// <typeparam name="TComponent">The component type.</typeparam>
-    /// <returns>An <see cref="HtmlComponent"/> instance representing the render output.</returns>
-    public HtmlComponent BeginRenderingComponent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>() where TComponent : IComponent
+    /// <returns>An <see cref="HtmlRootComponent"/> instance representing the render output.</returns>
+    public HtmlRootComponent BeginRenderingComponent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>() where TComponent : IComponent
         => _passiveHtmlRenderer.BeginRenderingComponent(typeof(TComponent), ParameterView.Empty);
 
     /// <summary>
     /// Adds an instance of the specified component and instructs it to render. The resulting content represents the
     /// initial synchronous rendering output, which may later change. To wait for the component hierarchy to complete
-    /// any asynchronous operations such as loading, await <see cref="HtmlComponent.QuiescenceTask"/> before
-    /// reading content from the <see cref="HtmlComponent"/>.
+    /// any asynchronous operations such as loading, await <see cref="HtmlRootComponent.QuiescenceTask"/> before
+    /// reading content from the <see cref="HtmlRootComponent"/>.
     /// </summary>
     /// <typeparam name="TComponent">The component type.</typeparam>
     /// <param name="parameters">Parameters for the component.</param>
-    /// <returns>An <see cref="HtmlComponent"/> instance representing the render output.</returns>
-    public HtmlComponent BeginRenderingComponent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>(
+    /// <returns>An <see cref="HtmlRootComponent"/> instance representing the render output.</returns>
+    public HtmlRootComponent BeginRenderingComponent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>(
         ParameterView parameters) where TComponent : IComponent
         => _passiveHtmlRenderer.BeginRenderingComponent(typeof(TComponent), parameters);
 
     /// <summary>
     /// Adds an instance of the specified component and instructs it to render. The resulting content represents the
     /// initial synchronous rendering output, which may later change. To wait for the component hierarchy to complete
-    /// any asynchronous operations such as loading, await <see cref="HtmlComponent.QuiescenceTask"/> before
-    /// reading content from the <see cref="HtmlComponent"/>.
+    /// any asynchronous operations such as loading, await <see cref="HtmlRootComponent.QuiescenceTask"/> before
+    /// reading content from the <see cref="HtmlRootComponent"/>.
     /// </summary>
     /// <param name="componentType">The component type. This must implement <see cref="IComponent"/>.</param>
-    /// <returns>An <see cref="HtmlComponent"/> instance representing the render output.</returns>
-    public HtmlComponent BeginRenderingComponent(
+    /// <returns>An <see cref="HtmlRootComponent"/> instance representing the render output.</returns>
+    public HtmlRootComponent BeginRenderingComponent(
         [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type componentType)
         => _passiveHtmlRenderer.BeginRenderingComponent(componentType, ParameterView.Empty);
 
     /// <summary>
     /// Adds an instance of the specified component and instructs it to render. The resulting content represents the
     /// initial synchronous rendering output, which may later change. To wait for the component hierarchy to complete
-    /// any asynchronous operations such as loading, await <see cref="HtmlComponent.QuiescenceTask"/> before
-    /// reading content from the <see cref="HtmlComponent"/>.
+    /// any asynchronous operations such as loading, await <see cref="HtmlRootComponent.QuiescenceTask"/> before
+    /// reading content from the <see cref="HtmlRootComponent"/>.
     /// </summary>
     /// <param name="componentType">The component type. This must implement <see cref="IComponent"/>.</param>
     /// <param name="parameters">Parameters for the component.</param>
-    /// <returns>An <see cref="HtmlComponent"/> instance representing the render output.</returns>
-    public HtmlComponent BeginRenderingComponent(
+    /// <returns>An <see cref="HtmlRootComponent"/> instance representing the render output.</returns>
+    public HtmlRootComponent BeginRenderingComponent(
         [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type componentType,
         ParameterView parameters)
         => _passiveHtmlRenderer.BeginRenderingComponent(componentType, parameters);
@@ -95,8 +95,8 @@ public sealed class HtmlRenderer : IDisposable, IAsyncDisposable
     /// for the component hierarchy to complete asynchronous tasks such as loading.
     /// </summary>
     /// <typeparam name="TComponent">The component type.</typeparam>
-    /// <returns>A task that completes with <see cref="HtmlComponent"/> once the component hierarchy has completed any asynchronous tasks such as loading.</returns>
-    public Task<HtmlComponent> RenderComponentAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>() where TComponent : IComponent
+    /// <returns>A task that completes with <see cref="HtmlRootComponent"/> once the component hierarchy has completed any asynchronous tasks such as loading.</returns>
+    public Task<HtmlRootComponent> RenderComponentAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>() where TComponent : IComponent
         => RenderComponentAsync<TComponent>(ParameterView.Empty);
 
     /// <summary>
@@ -104,8 +104,8 @@ public sealed class HtmlRenderer : IDisposable, IAsyncDisposable
     /// for the component hierarchy to complete asynchronous tasks such as loading.
     /// </summary>
     /// <param name="componentType">The component type. This must implement <see cref="IComponent"/>.</param>
-    /// <returns>A task that completes with <see cref="HtmlComponent"/> once the component hierarchy has completed any asynchronous tasks such as loading.</returns>
-    public Task<HtmlComponent> RenderComponentAsync(
+    /// <returns>A task that completes with <see cref="HtmlRootComponent"/> once the component hierarchy has completed any asynchronous tasks such as loading.</returns>
+    public Task<HtmlRootComponent> RenderComponentAsync(
         [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type componentType)
         => RenderComponentAsync(componentType, ParameterView.Empty);
 
@@ -115,8 +115,8 @@ public sealed class HtmlRenderer : IDisposable, IAsyncDisposable
     /// </summary>
     /// <typeparam name="TComponent">The component type.</typeparam>
     /// <param name="parameters">Parameters for the component.</param>
-    /// <returns>A task that completes with <see cref="HtmlComponent"/> once the component hierarchy has completed any asynchronous tasks such as loading.</returns>
-    public Task<HtmlComponent> RenderComponentAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>(
+    /// <returns>A task that completes with <see cref="HtmlRootComponent"/> once the component hierarchy has completed any asynchronous tasks such as loading.</returns>
+    public Task<HtmlRootComponent> RenderComponentAsync<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TComponent>(
         ParameterView parameters) where TComponent : IComponent
         => RenderComponentAsync(typeof (TComponent), parameters);
 
@@ -126,8 +126,8 @@ public sealed class HtmlRenderer : IDisposable, IAsyncDisposable
     /// </summary>
     /// <param name="componentType">The component type. This must implement <see cref="IComponent"/>.</param>
     /// <param name="parameters">Parameters for the component.</param>
-    /// <returns>A task that completes with <see cref="HtmlComponent"/> once the component hierarchy has completed any asynchronous tasks such as loading.</returns>
-    public async Task<HtmlComponent> RenderComponentAsync(
+    /// <returns>A task that completes with <see cref="HtmlRootComponent"/> once the component hierarchy has completed any asynchronous tasks such as loading.</returns>
+    public async Task<HtmlRootComponent> RenderComponentAsync(
         [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type componentType,
         ParameterView parameters)
     {

+ 12 - 21
src/Components/Web/src/HtmlRendering/HtmlComponentBase.cs → src/Components/Web/src/HtmlRendering/HtmlRootComponent.cs

@@ -6,32 +6,28 @@ using Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure;
 namespace Microsoft.AspNetCore.Components.Web.HtmlRendering;
 
 /// <summary>
-/// Represents the output of rendering a component as HTML. The content can change if the component instance re-renders.
+/// Represents the output of rendering a root component as HTML. The content can change if the component instance re-renders.
 /// </summary>
-public class HtmlComponentBase
+public readonly struct HtmlRootComponent
 {
     private readonly StaticHtmlRenderer? _renderer;
-    private readonly int _componentId;
 
-    /// <summary>
-    /// Constructs an instance of <see cref="HtmlComponentBase"/>.
-    /// </summary>
-    /// <param name="renderer">The renderer of the component.</param>
-    /// <param name="componentId">The ID of the component.</param>
-    public HtmlComponentBase(StaticHtmlRenderer renderer, int componentId)
+    internal HtmlRootComponent(StaticHtmlRenderer renderer, int componentId, Task quiescenceTask)
     {
         _renderer = renderer;
-        _componentId = componentId;
-    }
-
-    internal HtmlComponentBase()
-    {
+        ComponentId = componentId;
+        QuiescenceTask = quiescenceTask;
     }
 
     /// <summary>
     /// Gets the component ID.
     /// </summary>
-    public int ComponentId => _componentId;
+    public int ComponentId { get; }
+
+    /// <summary>
+    /// Gets a <see cref="Task"/> that completes when the component hierarchy has completed asynchronous tasks such as loading.
+    /// </summary>
+    public Task QuiescenceTask { get; } = Task.CompletedTask;
 
     /// <summary>
     /// Returns an HTML string representation of the component's latest output.
@@ -54,10 +50,5 @@ public class HtmlComponentBase
     /// </summary>
     /// <param name="output">The output destination.</param>
     public void WriteHtmlTo(TextWriter output)
-    {
-        if (_renderer is not null)
-        {
-            HtmlComponentWriter.Write(_renderer, _componentId, output);
-        }
-    }
+        => _renderer?.WriteComponentHtml(ComponentId, output);
 }

+ 48 - 59
src/Components/Web/src/HtmlRendering/HtmlComponentWriter.cs → src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.HtmlWriting.cs

@@ -3,14 +3,11 @@
 
 using System.Diagnostics;
 using System.Text.Encodings.Web;
-using Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure;
 using Microsoft.AspNetCore.Components.RenderTree;
 
-namespace Microsoft.AspNetCore.Components.Web.HtmlRendering;
+namespace Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure;
 
-// This is OK to be a struct because it never gets passed around anywhere. Other code can't even get an instance
-// of it. It just keeps track of some contextual information during a single synchronous HTML output operation.
-internal ref struct HtmlComponentWriter
+public partial class StaticHtmlRenderer
 {
     private static readonly HashSet<string> SelfClosingElements = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
     {
@@ -18,33 +15,30 @@ internal ref struct HtmlComponentWriter
     };
 
     private static readonly HtmlEncoder _htmlEncoder = HtmlEncoder.Default;
-    private readonly StaticHtmlRenderer _renderer;
-    private readonly TextWriter _output;
     private string? _closestSelectValueAsString;
 
-    public static void Write(StaticHtmlRenderer renderer, int componentId, TextWriter output)
+    /// <summary>
+    /// Renders the specified component as HTML to the output.
+    /// </summary>
+    /// <param name="componentId">The ID of the component whose current HTML state is to be rendered.</param>
+    /// <param name="output">The output destination.</param>
+    protected internal virtual void WriteComponentHtml(int componentId, TextWriter output)
     {
         // We're about to walk over some buffers inside the renderer that can be mutated during rendering.
         // So, we require exclusive access to the renderer during this synchronous process.
-        renderer.Dispatcher.AssertAccess();
+        Dispatcher.AssertAccess();
 
-        var context = new HtmlComponentWriter(renderer, output);
-        context.RenderComponent(componentId);
+        var frames = GetCurrentRenderTreeFrames(componentId);
+        RenderFrames(output, frames, 0, frames.Count);
     }
 
-    private HtmlComponentWriter(StaticHtmlRenderer renderer, TextWriter output)
-    {
-        _renderer = renderer;
-        _output = output;
-    }
-
-    private int RenderFrames(ArrayRange<RenderTreeFrame> frames, int position, int maxElements)
+    private int RenderFrames(TextWriter output, ArrayRange<RenderTreeFrame> frames, int position, int maxElements)
     {
         var nextPosition = position;
         var endPosition = position + maxElements;
         while (position < endPosition)
         {
-            nextPosition = RenderCore(frames, position);
+            nextPosition = RenderCore(output, frames, position);
             if (position == nextPosition)
             {
                 throw new InvalidOperationException("We didn't consume any input.");
@@ -56,6 +50,7 @@ internal ref struct HtmlComponentWriter
     }
 
     private int RenderCore(
+        TextWriter output,
         ArrayRange<RenderTreeFrame> frames,
         int position)
     {
@@ -63,19 +58,19 @@ internal ref struct HtmlComponentWriter
         switch (frame.FrameType)
         {
             case RenderTreeFrameType.Element:
-                return RenderElement(frames, position);
+                return RenderElement(output, frames, position);
             case RenderTreeFrameType.Attribute:
                 throw new InvalidOperationException($"Attributes should only be encountered within {nameof(RenderElement)}");
             case RenderTreeFrameType.Text:
-                _htmlEncoder.Encode(_output, frame.TextContent);
+                _htmlEncoder.Encode(output, frame.TextContent);
                 return ++position;
             case RenderTreeFrameType.Markup:
-                _output.Write(frame.MarkupContent);
+                output.Write(frame.MarkupContent);
                 return ++position;
             case RenderTreeFrameType.Component:
-                return RenderChildComponent(frames, position);
+                return RenderChildComponent(output, frames, position);
             case RenderTreeFrameType.Region:
-                return RenderFrames(frames, position + 1, frame.RegionSubtreeLength - 1);
+                return RenderFrames(output, frames, position + 1, frame.RegionSubtreeLength - 1);
             case RenderTreeFrameType.ElementReferenceCapture:
             case RenderTreeFrameType.ComponentReferenceCapture:
                 return ++position;
@@ -84,15 +79,15 @@ internal ref struct HtmlComponentWriter
         }
     }
 
-    private int RenderElement(ArrayRange<RenderTreeFrame> frames, int position)
+    private int RenderElement(TextWriter output, ArrayRange<RenderTreeFrame> frames, int position)
     {
         ref var frame = ref frames.Array[position];
-        _output.Write('<');
-        _output.Write(frame.ElementName);
+        output.Write('<');
+        output.Write(frame.ElementName);
         int afterElement;
         var isTextArea = string.Equals(frame.ElementName, "textarea", StringComparison.OrdinalIgnoreCase);
         // We don't want to include value attribute of textarea element.
-        var afterAttributes = RenderAttributes(frames, position + 1, frame.ElementSubtreeLength - 1, !isTextArea, out var capturedValueAttribute);
+        var afterAttributes = RenderAttributes(output, frames, position + 1, frame.ElementSubtreeLength - 1, !isTextArea, out var capturedValueAttribute);
 
         // When we see an <option> as a descendant of a <select>, and the option's "value" attribute matches the
         // "value" attribute on the <select>, then we auto-add the "selected" attribute to that option. This is
@@ -101,13 +96,13 @@ internal ref struct HtmlComponentWriter
             && string.Equals(frame.ElementName, "option", StringComparison.OrdinalIgnoreCase)
             && string.Equals(capturedValueAttribute, _closestSelectValueAsString, StringComparison.Ordinal))
         {
-            _output.Write(" selected");
+            output.Write(" selected");
         }
 
         var remainingElements = frame.ElementSubtreeLength + position - afterAttributes;
         if (remainingElements > 0 || isTextArea)
         {
-            _output.Write('>');
+            output.Write('>');
 
             var isSelect = string.Equals(frame.ElementName, "select", StringComparison.OrdinalIgnoreCase);
             if (isSelect)
@@ -119,12 +114,12 @@ internal ref struct HtmlComponentWriter
             {
                 // Textarea is a special type of form field where the value is given as text content instead of a 'value' attribute
                 // So, if we captured a value attribute, use that instead of any child content
-                _htmlEncoder.Encode(_output, capturedValueAttribute);
+                _htmlEncoder.Encode(output, capturedValueAttribute);
                 afterElement = position + frame.ElementSubtreeLength; // Skip descendants
             }
             else
             {
-                afterElement = RenderChildren(frames, afterAttributes, remainingElements);
+                afterElement = RenderChildren(output, frames, afterAttributes, remainingElements);
             }
 
             if (isSelect)
@@ -134,9 +129,9 @@ internal ref struct HtmlComponentWriter
                 _closestSelectValueAsString = null;
             }
 
-            _output.Write("</");
-            _output.Write(frame.ElementName);
-            _output.Write('>');
+            output.Write("</");
+            output.Write(frame.ElementName);
+            output.Write('>');
             Debug.Assert(afterElement == position + frame.ElementSubtreeLength);
             return afterElement;
         }
@@ -144,21 +139,21 @@ internal ref struct HtmlComponentWriter
         {
             if (SelfClosingElements.Contains(frame.ElementName))
             {
-                _output.Write(" />");
+                output.Write(" />");
             }
             else
             {
-                _output.Write("></");
-                _output.Write(frame.ElementName);
-                _output.Write('>');
+                output.Write("></");
+                output.Write(frame.ElementName);
+                output.Write('>');
             }
             Debug.Assert(afterAttributes == position + frame.ElementSubtreeLength);
             return afterAttributes;
         }
     }
 
-    private int RenderAttributes(
-        ArrayRange<RenderTreeFrame> frames, int position, int maxElements, bool includeValueAttribute, out string? capturedValueAttribute)
+    private static int RenderAttributes(
+        TextWriter output, ArrayRange<RenderTreeFrame> frames, int position, int maxElements, bool includeValueAttribute, out string? capturedValueAttribute)
     {
         capturedValueAttribute = null;
 
@@ -195,16 +190,16 @@ internal ref struct HtmlComponentWriter
             switch (frame.AttributeValue)
             {
                 case bool flag when flag:
-                    _output.Write(' ');
-                    _output.Write(frame.AttributeName);
+                    output.Write(' ');
+                    output.Write(frame.AttributeName);
                     break;
                 case string value:
-                    _output.Write(' ');
-                    _output.Write(frame.AttributeName);
-                    _output.Write('=');
-                    _output.Write('\"');
-                    _htmlEncoder.Encode(_output, value);
-                    _output.Write('\"');
+                    output.Write(' ');
+                    output.Write(frame.AttributeName);
+                    output.Write('=');
+                    output.Write('\"');
+                    _htmlEncoder.Encode(output, value);
+                    output.Write('\"');
                     break;
                 default:
                     break;
@@ -214,27 +209,21 @@ internal ref struct HtmlComponentWriter
         return position + maxElements;
     }
 
-    private int RenderChildren(ArrayRange<RenderTreeFrame> frames, int position, int maxElements)
+    private int RenderChildren(TextWriter output, ArrayRange<RenderTreeFrame> frames, int position, int maxElements)
     {
         if (maxElements == 0)
         {
             return position;
         }
 
-        return RenderFrames(frames, position, maxElements);
-    }
-
-    private void RenderComponent(int componentId)
-    {
-        var frames = _renderer.GetCurrentRenderTreeFrames(componentId);
-        RenderFrames(frames, 0, frames.Count);
+        return RenderFrames(output, frames, position, maxElements);
     }
 
-    private int RenderChildComponent(ArrayRange<RenderTreeFrame> frames, int position)
+    private int RenderChildComponent(TextWriter output, ArrayRange<RenderTreeFrame> frames, int position)
     {
         ref var frame = ref frames.Array[position];
 
-        RenderComponent(frame.ComponentId);
+        WriteComponentHtml(frame.ComponentId, output);
 
         return position + frame.ComponentSubtreeLength;
     }

+ 3 - 3
src/Components/Web/src/HtmlRendering/StaticHtmlRenderer.cs

@@ -15,7 +15,7 @@ namespace Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure;
 /// developers should not normally use this class directly. Instead, use
 /// <see cref="HtmlRenderer"/> for a more convenient API.
 /// </summary>
-public class StaticHtmlRenderer : Renderer
+public partial class StaticHtmlRenderer : Renderer
 {
     private static readonly Task CanceledRenderTask = Task.FromCanceled(new CancellationToken(canceled: true));
 
@@ -33,7 +33,7 @@ public class StaticHtmlRenderer : Renderer
     public override Dispatcher Dispatcher { get; } = Dispatcher.CreateDefault();
 
     /// <inheritdoc/>
-    public HtmlComponent BeginRenderingComponent(
+    public HtmlRootComponent BeginRenderingComponent(
         [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type componentType,
         ParameterView initialParameters)
     {
@@ -46,7 +46,7 @@ public class StaticHtmlRenderer : Renderer
             ExceptionDispatchInfo.Capture(quiescenceTask.Exception.InnerException ?? quiescenceTask.Exception).Throw();
         }
 
-        return new HtmlComponent(this, componentId, quiescenceTask);
+        return new HtmlRootComponent(this, componentId, quiescenceTask);
     }
 
     /// <inheritdoc/>

+ 16 - 20
src/Components/Web/src/PublicAPI.Unshipped.txt

@@ -1,32 +1,28 @@
 #nullable enable
 *REMOVED*override Microsoft.AspNetCore.Components.Forms.InputFile.OnInitialized() -> void
 Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer
-Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.BeginRenderingComponent(System.Type! componentType, Microsoft.AspNetCore.Components.ParameterView initialParameters) -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent!
+Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.BeginRenderingComponent(System.Type! componentType, Microsoft.AspNetCore.Components.ParameterView initialParameters) -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent
 Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.StaticHtmlRenderer(System.IServiceProvider! serviceProvider, Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void
-Microsoft.AspNetCore.Components.StreamRenderingAttribute
-Microsoft.AspNetCore.Components.StreamRenderingAttribute.Enabled.get -> bool
-Microsoft.AspNetCore.Components.StreamRenderingAttribute.StreamRenderingAttribute(bool enabled) -> void
 Microsoft.AspNetCore.Components.Web.HtmlRenderer
-Microsoft.AspNetCore.Components.Web.HtmlRenderer.BeginRenderingComponent(System.Type! componentType) -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent!
-Microsoft.AspNetCore.Components.Web.HtmlRenderer.BeginRenderingComponent(System.Type! componentType, Microsoft.AspNetCore.Components.ParameterView parameters) -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent!
-Microsoft.AspNetCore.Components.Web.HtmlRenderer.BeginRenderingComponent<TComponent>() -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent!
-Microsoft.AspNetCore.Components.Web.HtmlRenderer.BeginRenderingComponent<TComponent>(Microsoft.AspNetCore.Components.ParameterView parameters) -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent!
+Microsoft.AspNetCore.Components.Web.HtmlRenderer.BeginRenderingComponent(System.Type! componentType) -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent
+Microsoft.AspNetCore.Components.Web.HtmlRenderer.BeginRenderingComponent(System.Type! componentType, Microsoft.AspNetCore.Components.ParameterView parameters) -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent
+Microsoft.AspNetCore.Components.Web.HtmlRenderer.BeginRenderingComponent<TComponent>() -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent
+Microsoft.AspNetCore.Components.Web.HtmlRenderer.BeginRenderingComponent<TComponent>(Microsoft.AspNetCore.Components.ParameterView parameters) -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent
 Microsoft.AspNetCore.Components.Web.HtmlRenderer.Dispatcher.get -> Microsoft.AspNetCore.Components.Dispatcher!
 Microsoft.AspNetCore.Components.Web.HtmlRenderer.Dispose() -> void
 Microsoft.AspNetCore.Components.Web.HtmlRenderer.DisposeAsync() -> System.Threading.Tasks.ValueTask
 Microsoft.AspNetCore.Components.Web.HtmlRenderer.HtmlRenderer(System.IServiceProvider! services, Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void
-Microsoft.AspNetCore.Components.Web.HtmlRenderer.RenderComponentAsync(System.Type! componentType) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent!>!
-Microsoft.AspNetCore.Components.Web.HtmlRenderer.RenderComponentAsync(System.Type! componentType, Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent!>!
-Microsoft.AspNetCore.Components.Web.HtmlRenderer.RenderComponentAsync<TComponent>() -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent!>!
-Microsoft.AspNetCore.Components.Web.HtmlRenderer.RenderComponentAsync<TComponent>(Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent!>!
-Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent
-Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent.QuiescenceTask.get -> System.Threading.Tasks.Task!
-Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponentBase
-Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponentBase.ComponentId.get -> int
-Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponentBase.HtmlComponentBase(Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer! renderer, int componentId) -> void
-Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponentBase.ToHtmlString() -> string!
-Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponentBase.WriteHtmlTo(System.IO.TextWriter! output) -> void
+Microsoft.AspNetCore.Components.Web.HtmlRenderer.RenderComponentAsync(System.Type! componentType) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent>!
+Microsoft.AspNetCore.Components.Web.HtmlRenderer.RenderComponentAsync(System.Type! componentType, Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent>!
+Microsoft.AspNetCore.Components.Web.HtmlRenderer.RenderComponentAsync<TComponent>() -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent>!
+Microsoft.AspNetCore.Components.Web.HtmlRenderer.RenderComponentAsync<TComponent>(Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent>!
+Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent
+Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent.ComponentId.get -> int
+Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent.HtmlRootComponent() -> void
+Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent.QuiescenceTask.get -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent.ToHtmlString() -> string!
+Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlRootComponent.WriteHtmlTo(System.IO.TextWriter! output) -> void
 override Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.Dispatcher.get -> Microsoft.AspNetCore.Components.Dispatcher!
 override Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.HandleException(System.Exception! exception) -> void
 override Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.UpdateDisplayAsync(in Microsoft.AspNetCore.Components.RenderTree.RenderBatch renderBatch) -> System.Threading.Tasks.Task!
-static Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent.Empty.get -> Microsoft.AspNetCore.Components.Web.HtmlRendering.HtmlComponent!
+virtual Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.WriteComponentHtml(int componentId, System.IO.TextWriter! output) -> void

+ 2 - 2
src/Components/Web/test/HtmlRendering/HtmlRendererTest.cs

@@ -983,10 +983,10 @@ public class HtmlRendererTest
         });
     }
 
-    void AssertHtmlContentEquals(IEnumerable<string> expected, HtmlComponent actual)
+    void AssertHtmlContentEquals(IEnumerable<string> expected, HtmlRootComponent actual)
         => AssertHtmlContentEquals(string.Join(string.Empty, expected), actual);
 
-    void AssertHtmlContentEquals(string expected, HtmlComponent actual)
+    void AssertHtmlContentEquals(string expected, HtmlRootComponent actual)
     {
         var actualHtml = actual.ToHtmlString();
         Assert.Equal(expected, actualHtml);

+ 1 - 1
src/Components/WebAssembly/Server/src/ComponentsWebAssemblyApplicationBuilderExtensions.cs

@@ -35,7 +35,7 @@ public static class ComponentsWebAssemblyApplicationBuilderExtensions
         var options = CreateStaticFilesOptions(webHostEnvironment.WebRootFileProvider);
 
         builder.MapWhen(ctx => ctx.Request.Path.StartsWithSegments(pathPrefix, out var rest) && rest.StartsWithSegments("/_framework") &&
-        !rest.StartsWithSegments("/_framework/blazor.server.js"),
+        !rest.StartsWithSegments("/_framework/blazor.server.js") && !rest.StartsWithSegments("/_framework/blazor.web.js"),
         subBuilder =>
         {
             subBuilder.Use(async (context, next) =>

+ 1 - 1
src/Components/WebAssembly/WebAssembly.Authentication/src/Microsoft.AspNetCore.Components.WebAssembly.Authentication.csproj

@@ -41,7 +41,7 @@
     <Content Remove="$(YarnWorkingDir)**" />
     <None Include="$(YarnWorkingDir)*" Exclude="$(YarnWorkingDir)node_modules\**" />
 
-    <UpToDateCheckInput Include="@(YarnInputs)" Set="StaticWebassets" />
+    <UpToDateCheckInput Include="@(YarnInputs)" Exclude="$(YarnWorkingDir)package.json" Set="StaticWebassets" />
     <UpToDateCheckOutput Include="@(YarnOutputs)" Set="StaticWebassets" />
   </ItemGroup>
 

+ 1 - 1
src/Components/test/E2ETest/Microsoft.AspNetCore.Components.E2ETests.csproj

@@ -48,7 +48,7 @@
   </ItemGroup>
 
   <ItemGroup>
-    <ProjectReference Include="..\testassets\TestServer\Components.TestServer.csproj" />
+    <ProjectReference Include="..\testassets\Components.TestServer\Components.TestServer.csproj" />
     <ProjectReference Include="..\..\WebAssembly\testassets\Wasm.Authentication.Server\Wasm.Authentication.Server.csproj" />
     <ProjectReference Include="..\..\WebAssembly\testassets\HostedInAspNet.Client\HostedInAspNet.Client.csproj" />
     <ProjectReference Include="..\..\WebAssembly\testassets\HostedInAspNet.Server\HostedInAspNet.Server.csproj" />

+ 67 - 0
src/Components/test/E2ETest/ServerExecutionTests/StreamingRenderingTest.cs

@@ -0,0 +1,67 @@
+// 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.Components.E2ETest.Infrastructure.ServerFixtures;
+using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
+using Microsoft.AspNetCore.E2ETesting;
+using TestServer;
+using Xunit.Abstractions;
+using OpenQA.Selenium;
+
+namespace Microsoft.AspNetCore.Components.E2ETests.ServerExecutionTests;
+
+public class StreamingRenderingTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup>>
+{
+    public StreamingRenderingTest(
+        BrowserFixture browserFixture,
+        BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup> serverFixture,
+        ITestOutputHelper output)
+        : base(browserFixture, serverFixture, output)
+    {
+    }
+
+    public override Task InitializeAsync()
+        => InitializeAsync(BrowserFixture.StreamingContext);
+
+    [Fact]
+    public void CanRenderNonstreamingPageWithoutInjectingStreamingMarkers()
+    {
+        Navigate(ServerPathBase);
+
+        Browser.Equal("Hello", () => Browser.Exists(By.TagName("h1")).Text);
+
+        Assert.DoesNotContain("<blazor-ssr", Browser.PageSource);
+    }
+
+    [Fact]
+    public void CanPerformStreamingRendering()
+    {
+        Navigate($"{ServerPathBase}/streaming");
+
+        // Initial "waiting" state
+        Browser.Equal("Streaming Rendering", () => Browser.Exists(By.TagName("h1")).Text);
+        var getStatusText = () => Browser.Exists(By.Id("status"));
+        var getDisplayedItems = () => Browser.FindElements(By.TagName("li"));
+        Assert.Equal("Waiting for more...", getStatusText().Text);
+        Assert.Empty(getDisplayedItems());
+
+        // Can add items
+        for (var i = 1; i <= 3; i++)
+        {
+            // Each time we click, there's another streaming render batch and the UI is updated
+            Browser.FindElement(By.Id("add-item-link")).Click();
+            Browser.Collection(getDisplayedItems, Enumerable.Range(1, i).Select<int, Action<IWebElement>>(index =>
+            {
+                return actualItem => Assert.Equal($"Item {index}", actualItem.Text);
+            }).ToArray());
+            Assert.Equal("Waiting for more...", getStatusText().Text);
+
+            // These are insta-removed so they don't pollute anything
+            Browser.DoesNotExist(By.TagName("blazor-ssr"));
+        }
+
+        // Can finish the response
+        Browser.FindElement(By.Id("end-response-link")).Click();
+        Browser.Equal("Finished", () => getStatusText().Text);
+    }
+}

+ 0 - 0
src/Components/test/testassets/TestServer/.gitignore → src/Components/test/testassets/Components.TestServer/.gitignore


+ 0 - 0
src/Components/test/testassets/TestServer/AuthenticationStartup.cs → src/Components/test/testassets/Components.TestServer/AuthenticationStartup.cs


+ 0 - 0
src/Components/test/testassets/TestServer/ChatHub.cs → src/Components/test/testassets/Components.TestServer/ChatHub.cs


+ 0 - 0
src/Components/test/testassets/TestServer/CircuitContextComponent.razor → src/Components/test/testassets/Components.TestServer/CircuitContextComponent.razor


+ 0 - 0
src/Components/test/testassets/TestServer/ClientStartup.cs → src/Components/test/testassets/Components.TestServer/ClientStartup.cs


+ 2 - 1
src/Components/test/testassets/TestServer/Components.TestServer.csproj → src/Components/test/testassets/Components.TestServer/Components.TestServer.csproj

@@ -1,4 +1,4 @@
-<Project Sdk="Microsoft.NET.Sdk.Web">
+<Project Sdk="Microsoft.NET.Sdk.Web">
 
   <PropertyGroup>
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
@@ -20,6 +20,7 @@
     <Reference Include="Microsoft.AspNetCore.Mvc" />
     <Reference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" />
     <Reference Include="Microsoft.AspNetCore.Mvc.ViewFeatures" />
+    <Reference Include="Microsoft.AspNetCore.SignalR" />
     <Reference Include="Microsoft.AspNetCore.Testing" />
     <Reference Include="Microsoft.Extensions.Hosting" />
   </ItemGroup>

+ 0 - 0
src/Components/test/testassets/TestServer/Controllers/CookieController.cs → src/Components/test/testassets/Components.TestServer/Controllers/CookieController.cs


+ 0 - 0
src/Components/test/testassets/TestServer/Controllers/CultureController.cs → src/Components/test/testassets/Components.TestServer/Controllers/CultureController.cs


+ 0 - 0
src/Components/test/testassets/TestServer/Controllers/DataController.cs → src/Components/test/testassets/Components.TestServer/Controllers/DataController.cs


+ 0 - 0
src/Components/test/testassets/TestServer/Controllers/DownloadController.cs → src/Components/test/testassets/Components.TestServer/Controllers/DownloadController.cs


+ 0 - 0
src/Components/test/testassets/TestServer/Controllers/GreetingController.cs → src/Components/test/testassets/Components.TestServer/Controllers/GreetingController.cs


+ 0 - 0
src/Components/test/testassets/TestServer/Controllers/PersonController.cs → src/Components/test/testassets/Components.TestServer/Controllers/PersonController.cs


+ 0 - 0
src/Components/test/testassets/TestServer/Controllers/ReloadController.cs → src/Components/test/testassets/Components.TestServer/Controllers/ReloadController.cs


+ 0 - 0
src/Components/test/testassets/TestServer/Controllers/UserController.cs → src/Components/test/testassets/Components.TestServer/Controllers/UserController.cs


+ 0 - 0
src/Components/test/testassets/TestServer/CorsStartup.cs → src/Components/test/testassets/Components.TestServer/CorsStartup.cs


+ 0 - 0
src/Components/test/testassets/TestServer/DeferredComponentContentStartup.cs → src/Components/test/testassets/Components.TestServer/DeferredComponentContentStartup.cs


+ 0 - 0
src/Components/test/testassets/TestServer/HotReloadStartup.cs → src/Components/test/testassets/Components.TestServer/HotReloadStartup.cs


+ 0 - 0
src/Components/test/testassets/TestServer/InternationalizationStartup.cs → src/Components/test/testassets/Components.TestServer/InternationalizationStartup.cs


+ 0 - 0
src/Components/test/testassets/TestServer/LockedNavigationStartup.cs → src/Components/test/testassets/Components.TestServer/LockedNavigationStartup.cs


+ 0 - 0
src/Components/test/testassets/TestServer/MultipleComponents.cs → src/Components/test/testassets/Components.TestServer/MultipleComponents.cs


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/Authentication.cshtml → src/Components/test/testassets/Components.TestServer/Pages/Authentication.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/Client/MultipleComponents.cshtml → src/Components/test/testassets/Components.TestServer/Pages/Client/MultipleComponents.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/Client/MultipleComponentsLayout.cshtml → src/Components/test/testassets/Components.TestServer/Pages/Client/MultipleComponentsLayout.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/ComponentWithParameters.cshtml → src/Components/test/testassets/Components.TestServer/Pages/ComponentWithParameters.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/DeferredComponentContentHost.cshtml → src/Components/test/testassets/Components.TestServer/Pages/DeferredComponentContentHost.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/DeferredComponentContentLayout.cshtml → src/Components/test/testassets/Components.TestServer/Pages/DeferredComponentContentLayout.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/LockedNavigationHost.cshtml → src/Components/test/testassets/Components.TestServer/Pages/LockedNavigationHost.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/MultipleComponents.cshtml → src/Components/test/testassets/Components.TestServer/Pages/MultipleComponents.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/MultipleComponentsLayout.cshtml → src/Components/test/testassets/Components.TestServer/Pages/MultipleComponentsLayout.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/PrerenderedHost.cshtml → src/Components/test/testassets/Components.TestServer/Pages/PrerenderedHost.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/SaveState.cshtml → src/Components/test/testassets/Components.TestServer/Pages/SaveState.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/Transports.cshtml → src/Components/test/testassets/Components.TestServer/Pages/Transports.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/_ServerHost.cshtml → src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/Pages/_ViewImports.cshtml → src/Components/test/testassets/Components.TestServer/Pages/_ViewImports.cshtml


+ 0 - 0
src/Components/test/testassets/TestServer/PrerenderedStartup.cs → src/Components/test/testassets/Components.TestServer/PrerenderedStartup.cs


+ 17 - 1
src/Components/test/testassets/TestServer/Program.cs → src/Components/test/testassets/Components.TestServer/Program.cs

@@ -11,6 +11,8 @@ namespace TestServer;
 
 public class Program
 {
+    private static int nextPortNumber = 5001;
+
     public static async Task Main(string[] args)
     {
         var createIndividualHosts = new Dictionary<string, (IHost host, string basePath)>
@@ -19,6 +21,7 @@ public class Program
             ["Server authentication"] = (BuildWebHost<ServerAuthenticationStartup>(CreateAdditionalArgs(args)), "/subdir"),
             ["CORS (WASM)"] = (BuildWebHost<CorsStartup>(CreateAdditionalArgs(args)), "/subdir"),
             ["Prerendering (Server-side)"] = (BuildWebHost<PrerenderedStartup>(CreateAdditionalArgs(args)), "/prerendered"),
+            ["Razor Component Endpoints"] = (BuildWebHost<RazorComponentEndpointsStartup>(CreateAdditionalArgs(args)), "/subdir"),
             ["Deferred component content (Server-side)"] = (BuildWebHost<DeferredComponentContentStartup>(CreateAdditionalArgs(args)), "/deferred-component-content"),
             ["Locked navigation (Server-side)"] = (BuildWebHost<LockedNavigationStartup>(CreateAdditionalArgs(args)), "/locked-navigation"),
             ["Client-side with fallback"] = (BuildWebHost<StartupWithMapFallbackToClientSideBlazor>(CreateAdditionalArgs(args)), "/fallback"),
@@ -61,7 +64,7 @@ public class Program
     }
 
     private static string[] CreateAdditionalArgs(string[] args) =>
-        args.Concat(new[] { "--urls", "http://127.0.0.1:0" }).ToArray();
+        args.Concat(new[] { "--urls", $"http://127.0.0.1:{GetNextChildAppPortNumber()}" }).ToArray();
 
     public static IHost BuildWebHost(string[] args) => BuildWebHost<Startup>(args);
 
@@ -82,4 +85,17 @@ public class Program
                 webHostBuilder.UseStaticWebAssets();
             })
             .Build();
+
+    private static int GetNextChildAppPortNumber()
+    {
+        if (string.Equals(Environment.GetEnvironmentVariable("TESTSERVER_USE_DETERMINISTIC_PORTS"), "true", StringComparison.OrdinalIgnoreCase))
+        {
+            return nextPortNumber++;
+        }
+        else
+        {
+            // Let the OS assign an available port
+            return 0;
+        }
+    }
 }

+ 17 - 0
src/Components/test/testassets/Components.TestServer/Properties/launchSettings.json

@@ -0,0 +1,17 @@
+{
+  "profiles": {
+    "Components.TestServer": {
+      "commandName": "Project",
+      "launchBrowser": true,
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development",
+
+        // When started manually (not by E2E tests), it's helpful to run each of the child apps
+        // on deterministic port numbers so that you don't have to keep rediscovering it every
+        // time you restart the test server
+        "TESTSERVER_USE_DETERMINISTIC_PORTS": "true"
+      },
+      "applicationUrl": "http://localhost:5000"
+    }
+  }
+}

+ 0 - 0
src/Components/test/testassets/TestServer/ProtectedBrowserStorageInjectionComponent.razor → src/Components/test/testassets/Components.TestServer/ProtectedBrowserStorageInjectionComponent.razor


+ 0 - 0
src/Components/test/testassets/TestServer/ProtectedBrowserStorageUsageComponent.razor → src/Components/test/testassets/Components.TestServer/ProtectedBrowserStorageUsageComponent.razor


+ 48 - 0
src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs

@@ -0,0 +1,48 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Globalization;
+using Components.TestServer.RazorComponents;
+using Components.TestServer.RazorComponents.Pages;
+
+namespace TestServer;
+
+public class RazorComponentEndpointsStartup
+{
+    public RazorComponentEndpointsStartup(IConfiguration configuration)
+    {
+        Configuration = configuration;
+    }
+
+    public IConfiguration Configuration { get; }
+
+    // This method gets called by the runtime. Use this method to add services to the container.
+    public void ConfigureServices(IServiceCollection services)
+    {
+        services.AddRazorComponents();
+    }
+
+    // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
+    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
+    {
+        var enUs = new CultureInfo("en-US");
+        CultureInfo.DefaultThreadCurrentCulture = enUs;
+        CultureInfo.DefaultThreadCurrentUICulture = enUs;
+
+        if (env.IsDevelopment())
+        {
+            app.UseDeveloperExceptionPage();
+        }
+
+        app.Map("/subdir", app =>
+        {
+            app.UseRouting();
+            app.UseEndpoints(endpoints =>
+            {
+                endpoints.MapRazorComponents<RazorComponentsRoot>();
+
+                StreamingRendering.MapEndpoints(endpoints);
+            });
+        });
+    }
+}

+ 7 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Index.razor

@@ -0,0 +1,7 @@
+@page "/"
+
+<PageTitle>Home</PageTitle>
+
+<h1>Hello</h1>
+
+<p>This is a Razor Component endpoint.</p>

+ 74 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/StreamingRendering.razor

@@ -0,0 +1,74 @@
+@page "/streaming"
+@using System.IO.Pipelines;
+@using System.Threading.Channels;
+@attribute [StreamRendering(true)]
+
+<h1>Streaming Rendering</h1>
+
+<p>
+    Every time you make a request to <a id="add-item-link" href="@AddItemUrl" target="_blank">add item</a>, we'll
+    add an item to this streaming response.
+</p>
+<p>
+    Complete the response by visiting <a id="end-response-link" href="@EndResponseUrl" target="_blank">end response</a>.
+</p>
+
+<ul>
+    @foreach (var item in items)
+    {
+        <li>@item</li>
+    }
+</ul>
+
+<p id="status">
+    @if (finished)
+    {
+        <text>Finished</text>
+    }
+    else
+    {
+        <text>Waiting for more...</text>
+    }
+</p>
+
+@code {
+    // Caution: Don't use statics like this in real apps. This is only for an E2E test. If you did this
+    // in production, different users/requests could interfere with each other.
+    static Channel<string> StreamingDataChannel;
+    static int StreamingDataChannelCount;
+
+    const string AddItemUrl = "streaming/add-item";
+    const string EndResponseUrl = "streaming/end-response";
+
+    bool finished;
+    List<string> items = new();
+
+    protected override async Task OnInitializedAsync()
+    {
+        StreamingDataChannel = Channel.CreateUnbounded<string>();
+        StreamingDataChannelCount = 0;
+
+        await foreach (var item in StreamingDataChannel.Reader.ReadAllAsync())
+        {
+            items.Add(item);
+            StateHasChanged();
+        }
+
+        finished = true;
+    }
+
+    public static void MapEndpoints(IEndpointRouteBuilder endpoints)
+    {
+        endpoints.MapGet(AddItemUrl, () =>
+        {
+            StreamingDataChannel.Writer.TryWrite($"Item {++StreamingDataChannelCount}");
+            return "Added item";
+        });
+
+        endpoints.MapGet(EndResponseUrl, () =>
+        {
+            StreamingDataChannel.Writer.Complete();
+            return "Response ended";
+        });
+    }
+}

+ 1 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/_Imports.razor

@@ -0,0 +1 @@
+@layout RazorComponentsLayout

+ 14 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/RazorComponentsLayout.razor

@@ -0,0 +1,14 @@
+@inherits LayoutComponentBase
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8" />
+    <base href="/subdir/" />
+    <HeadOutlet />
+</head>
+<body>
+    <PageTitle>Razor Components Endpoints App</PageTitle>
+    @Body
+    <script src="_framework/blazor.web.js" suppress-error="BL9992"></script>
+</body>
+</html>

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

@@ -0,0 +1,9 @@
+@implements IRazorComponentApplication<RazorComponentsRoot>
+
+<h3>TODO</h3>
+
+<p>
+    This should start acting as a real root component, rendering the router, etc.
+    once we change MapRazorComponents to render the specified root component instead
+    of directly rendering the page.
+</p>

+ 0 - 0
src/Components/test/testassets/TestServer/ResourceRequestLog.cs → src/Components/test/testassets/Components.TestServer/ResourceRequestLog.cs


+ 0 - 0
src/Components/test/testassets/TestServer/SaveState.cs → src/Components/test/testassets/Components.TestServer/SaveState.cs


+ 0 - 0
src/Components/test/testassets/TestServer/ServerStartup.cs → src/Components/test/testassets/Components.TestServer/ServerStartup.cs


+ 0 - 0
src/Components/test/testassets/TestServer/Startup.cs → src/Components/test/testassets/Components.TestServer/Startup.cs


+ 0 - 0
src/Components/test/testassets/TestServer/StartupWithMapFallbackToClientSideBlazor.cs → src/Components/test/testassets/Components.TestServer/StartupWithMapFallbackToClientSideBlazor.cs


+ 0 - 0
src/Components/test/testassets/TestServer/TestAppInfo.cs → src/Components/test/testassets/Components.TestServer/TestAppInfo.cs


+ 0 - 0
src/Components/test/testassets/TestServer/TestCircuitContextAccessor.cs → src/Components/test/testassets/Components.TestServer/TestCircuitContextAccessor.cs


+ 0 - 0
src/Components/test/testassets/TestServer/TransportsServerStartup.cs → src/Components/test/testassets/Components.TestServer/TransportsServerStartup.cs


+ 0 - 0
src/Components/test/testassets/TestServer/_Imports.razor → src/Components/test/testassets/Components.TestServer/_Imports.razor


+ 0 - 0
src/Components/test/testassets/TestServer/appsettings.Development.json → src/Components/test/testassets/Components.TestServer/appsettings.Development.json


+ 0 - 0
src/Components/test/testassets/TestServer/appsettings.json → src/Components/test/testassets/Components.TestServer/appsettings.json


+ 0 - 27
src/Components/test/testassets/TestServer/Properties/launchSettings.json

@@ -1,27 +0,0 @@
-{
-  "iisSettings": {
-    "windowsAuthentication": false,
-    "anonymousAuthentication": true,
-    "iisExpress": {
-      "applicationUrl": "http://localhost:54195/",
-      "sslPort": 44360
-    }
-  },
-  "profiles": {
-    "Components.TestServer": {
-      "commandName": "Project",
-      "launchBrowser": true,
-      "environmentVariables": {
-        "ASPNETCORE_ENVIRONMENT": "Development"
-      },
-      "applicationUrl": "https://localhost:5003;http://localhost:5002"
-    },
-    "IIS Express": {
-      "commandName": "IISExpress",
-      "launchBrowser": true,
-      "environmentVariables": {
-        "ASPNETCORE_ENVIRONMENT": "Development"
-      }
-    }
-  }
-}

+ 7 - 0
src/Shared/E2ETesting/BrowserFixture.cs

@@ -22,6 +22,7 @@ namespace Microsoft.AspNetCore.E2ETesting;
 
 public class BrowserFixture : IAsyncLifetime
 {
+    public static string StreamingContext { get; } = "streaming";
     private readonly ConcurrentDictionary<string, Task<(IWebDriver browser, ILogs log)>> _browsers = new ConcurrentDictionary<string, Task<(IWebDriver, ILogs)>>();
 
     public BrowserFixture(IMessageSink diagnosticsMessageSink)
@@ -141,6 +142,12 @@ public class BrowserFixture : IAsyncLifetime
     {
         var opts = new ChromeOptions();
 
+        if (string.Equals(context, StreamingContext, StringComparison.Ordinal))
+        {
+            // Tells Selenium not to wait until the page navigation has completed before continuing with the tests
+            opts.PageLoadStrategy = PageLoadStrategy.None;
+        }
+
         // Force language to english for tests
         opts.AddUserProfilePreference("intl.accept_languages", "en");
 

部分文件因文件數量過多而無法顯示