ソースを参照

[Blazor] Static Server Rendered Blazor forms support

* Adds support for posting forms with statically rendered apps.
* Forms must define an event name to bind.
* The event name must be unique across all forms and can't change
  after the first time we rendered a form. 
  * This is enforced at the time we dispatch the event.
* The event name is associated with the event handler that will
  process the form.
  * There is a "default" handler represented by the empty string.
  * Named handlers are defined using the "handler" parameter in
    the query string. With the handler name as the value.
* The handler name is defined by the hierarchy of cascading model
  binders. Each binder appends its name to the parent binder with
  a dot as separator.
* EditForm automatically understands the hierarchy of cascading
  model binders and sets the action and name for the form accordingly.
Javier Calvarro Nelson 2 年 前
コミット
442d656e92
55 ファイル変更2720 行追加138 行削除
  1. 1 1
      AspNetCore.sln
  2. 47 4
      src/Components/Authorization/test/AuthorizeRouteViewTest.cs
  3. 1 1
      src/Components/Components/perf/RenderTreeDiffBuilderBenchmark.cs
  4. 140 0
      src/Components/Components/src/Binding/CascadingModelBinder.cs
  5. 37 0
      src/Components/Components/src/Binding/ModelBindingContext.cs
  6. 1 1
      src/Components/Components/src/NavigationManagerExtensions.cs
  7. 15 0
      src/Components/Components/src/PublicAPI.Unshipped.txt
  8. 26 1
      src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs
  9. 50 0
      src/Components/Components/src/RenderTree/Renderer.cs
  10. 10 1
      src/Components/Components/src/Rendering/ComponentState.cs
  11. 8 0
      src/Components/Components/src/Rendering/RenderQueueEntry.cs
  12. 47 0
      src/Components/Components/src/Rendering/RenderTreeBuilder.cs
  13. 31 32
      src/Components/Components/src/RouteView.cs
  14. 332 0
      src/Components/Components/test/CascadingModelBinderTest.cs
  15. 39 0
      src/Components/Components/test/ModelBindingContextTest.cs
  16. 9 9
      src/Components/Components/test/RenderTreeDiffBuilderTest.cs
  17. 283 31
      src/Components/Components/test/RendererTest.cs
  18. 45 6
      src/Components/Components/test/RouteViewTest.cs
  19. 3 11
      src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs
  20. 59 9
      src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs
  21. 1 1
      src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs
  22. 1 1
      src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs
  23. 93 1
      src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs
  24. 314 3
      src/Components/Endpoints/test/EndpointHtmlRendererTest.cs
  25. 4 0
      src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj
  26. 4 2
      src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs
  27. 20 2
      src/Components/Samples/BlazorUnitedApp/Pages/Index.razor
  28. 14 0
      src/Components/Shared/test/TestRenderer.cs
  29. 0 0
      src/Components/Web.JS/dist/Release/blazor.server.js
  30. 0 0
      src/Components/Web.JS/dist/Release/blazor.webview.js
  31. 58 9
      src/Components/Web/src/Forms/EditForm.cs
  32. 4 0
      src/Components/Web/src/PublicAPI.Unshipped.txt
  33. 338 9
      src/Components/Web/test/Forms/EditFormTest.cs
  34. 279 0
      src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTest.cs
  35. 3 3
      src/Components/test/E2ETest/ServerRenderingTests/StreamingRenderingTest.cs
  36. 5 0
      src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs
  37. 14 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Components/ComponentWithFormInside.razor
  38. 1 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Components/_Imports.razor
  39. 22 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/AmbiguousForms.razor
  40. 20 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/AsyncRenderedForm.razor
  41. 18 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/DefaultForm.razor
  42. 25 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/DisappearingForm.razor
  43. 7 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/FormDefinedInsideComponent.razor
  44. 19 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/FormOutsideBindingContextNoOps.razor
  45. 42 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ModifyHttpContextForm.razor
  46. 18 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NamedForm.razor
  47. 19 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NamedFormContextNoFormContextLayout.razor
  48. 21 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NestedNamedForm.razor
  49. 41 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NonStreamingRenderingForm.razor
  50. 46 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/StreamingRenderingForm.razor
  51. 27 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/SwitchingDispatchedComponentsDoesNotBind.razor
  52. 2 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/_Imports.razor
  53. 17 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Shared/FormsLayout.razor
  54. 15 0
      src/Components/test/testassets/Components.TestServer/RazorComponents/Shared/NoFormContextLayout.razor
  55. 24 0
      src/Components/test/testassets/Components.TestServer/Services/AsyncOperationService.cs

+ 1 - 1
AspNetCore.sln

@@ -1778,7 +1778,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Compon
 EndProject
 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Components.Endpoints.Tests", "src\Components\Endpoints\test\Microsoft.AspNetCore.Components.Endpoints.Tests.csproj", "{5D438258-CB19-4282-814F-974ABBC71411}"
 EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorUnitedApp", "src\Components\Samples\BlazorUnitedApp\BlazorUnitedApp.csproj", "{F5AE525F-F435-40F9-A567-4D5EC3B50D6E}"
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorUnitedApp", "src\Components\Samples\BlazorUnitedApp\BlazorUnitedApp.csproj", "{F5AE525F-F435-40F9-A567-4D5EC3B50D6E}"
 EndProject
 Global
 	GlobalSection(SolutionConfigurationPlatforms) = preSolution

+ 47 - 4
src/Components/Authorization/test/AuthorizeRouteViewTest.cs

@@ -3,6 +3,7 @@
 
 using System.Security.Claims;
 using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Components.Binding;
 using Microsoft.AspNetCore.Components.Rendering;
 using Microsoft.AspNetCore.Components.RenderTree;
 using Microsoft.AspNetCore.Components.Test.Helpers;
@@ -33,8 +34,10 @@ public class AuthorizeRouteViewTest
         serviceCollection.AddSingleton<IAuthorizationService>(_testAuthorizationService);
         serviceCollection.AddSingleton<NavigationManager, TestNavigationManager>();
 
-        _renderer = new TestRenderer(serviceCollection.BuildServiceProvider());
-        _authorizeRouteViewComponent = new AuthorizeRouteView();
+        var services = serviceCollection.BuildServiceProvider();
+        _renderer = new TestRenderer(services);
+        var componentFactory = new ComponentFactory(new DefaultComponentActivator());
+        _authorizeRouteViewComponent = (AuthorizeRouteView)componentFactory.InstantiateComponent(services, typeof(AuthorizeRouteView));
         _authorizeRouteViewComponentId = _renderer.AssignRootComponentId(_authorizeRouteViewComponent);
     }
 
@@ -63,10 +66,26 @@ public class AuthorizeRouteViewTest
             edit =>
             {
                 Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
-                AssertFrame.Component<TestPageRequiringAuthorization>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
+                AssertFrame.Component<CascadingModelBinder>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
             },
             edit => AssertPrependText(batch, edit, "Layout ends here"));
 
+        var cascadingModelBinderDiff = batch.GetComponentDiffs<CascadingModelBinder>().Single();
+        Assert.Collection(cascadingModelBinderDiff.Edits,
+            edit =>
+            {
+                Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
+                AssertFrame.Component<CascadingValue<ModelBindingContext>>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
+            });
+
+        var cascadingValueDiff = batch.GetComponentDiffs<CascadingValue<ModelBindingContext>>().Single();
+        Assert.Collection(cascadingValueDiff.Edits,
+            edit =>
+            {
+                Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
+                AssertFrame.Component<TestPageRequiringAuthorization>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
+            });
+
         // Assert: renders page
         var pageDiff = batch.GetComponentDiffs<TestPageRequiringAuthorization>().Single();
         Assert.Collection(pageDiff.Edits,
@@ -100,10 +119,26 @@ public class AuthorizeRouteViewTest
             edit =>
             {
                 Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
-                AssertFrame.Component<TestPageRequiringAuthorization>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
+                AssertFrame.Component<CascadingModelBinder>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
             },
             edit => AssertPrependText(batch, edit, "Layout ends here"));
 
+        var cascadingModelBinderDiff = batch.GetComponentDiffs<CascadingModelBinder>().Single();
+        Assert.Collection(cascadingModelBinderDiff.Edits,
+            edit =>
+            {
+                Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
+                AssertFrame.Component<CascadingValue<ModelBindingContext>>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
+            });
+
+        var cascadingValueDiff = batch.GetComponentDiffs<CascadingValue<ModelBindingContext>>().Single();
+        Assert.Collection(cascadingValueDiff.Edits,
+            edit =>
+            {
+                Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
+                AssertFrame.Component<TestPageRequiringAuthorization>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
+            });
+
         // Assert: renders page
         var pageDiff = batch.GetComponentDiffs<TestPageRequiringAuthorization>().Single();
         Assert.Collection(pageDiff.Edits,
@@ -291,6 +326,8 @@ public class AuthorizeRouteViewTest
             component => Assert.IsType<CascadingValue<Task<AuthenticationState>>>(component),
             component => Assert.IsAssignableFrom<AuthorizeViewCore>(component),
             component => Assert.IsType<LayoutView>(component),
+            component => Assert.IsType<CascadingModelBinder>(component),
+            component => Assert.IsType<CascadingValue<ModelBindingContext>>(component),
             component => Assert.IsType<TestPageWithNoAuthorization>(component));
     }
 
@@ -322,6 +359,8 @@ public class AuthorizeRouteViewTest
             // further CascadingAuthenticationState
             component => Assert.IsAssignableFrom<AuthorizeViewCore>(component),
             component => Assert.IsType<LayoutView>(component),
+            component => Assert.IsType<CascadingModelBinder>(component),
+            component => Assert.IsType<CascadingValue<ModelBindingContext>>(component),
             component => Assert.IsType<TestPageWithNoAuthorization>(component));
     }
 
@@ -424,5 +463,9 @@ public class AuthorizeRouteViewTest
 
     class TestNavigationManager : NavigationManager
     {
+        public TestNavigationManager()
+        {
+            Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash");
+        }
     }
 }

+ 1 - 1
src/Components/Components/perf/RenderTreeDiffBuilderBenchmark.cs

@@ -79,7 +79,7 @@ public class RenderTreeDiffBuilderBenchmark
     public void ComputeDiff_SingleFormField()
     {
         builder.ClearStateForCurrentBatch();
-        var diff = RenderTreeDiffBuilder.ComputeDiff(renderer, builder, 0, original.GetFrames(), modified.GetFrames());
+        var diff = RenderTreeDiffBuilder.ComputeDiff(renderer, builder, 0, modified.GetFrames(), original.GetFrames(), original.GetNamedEvents());
         GC.KeepAlive(diff);
     }
 

+ 140 - 0
src/Components/Components/src/Binding/CascadingModelBinder.cs

@@ -0,0 +1,140 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Reflection.Metadata;
+using Microsoft.AspNetCore.Components.Binding;
+using Microsoft.AspNetCore.Components.Routing;
+
+namespace Microsoft.AspNetCore.Components;
+
+/// <summary>
+/// Defines the binding context for data bound from external sources.
+/// </summary>
+public sealed class CascadingModelBinder : IComponent, IDisposable
+{
+    private RenderHandle _handle;
+    private ModelBindingContext? _bindingContext;
+    private bool _hasPendingQueuedRender;
+
+    /// <summary>
+    /// The binding context name.
+    /// </summary>
+    [Parameter] public string Name { get; set; } = "";
+
+    /// <summary>
+    /// If true, indicates that <see cref="ModelBindingContext.BindingContextId"/> will not change.
+    /// This is a performance optimization that allows the framework to skip setting up
+    /// change notifications. Set this flag only if you will not change
+    /// <see cref="Name"/> of this context or its parents' context during the component's lifetime.
+    /// </summary>
+    [Parameter] public bool IsFixed { get; set; }
+
+    /// <summary>
+    /// Specifies the content to be rendered inside this <see cref="CascadingModelBinder"/>.
+    /// </summary>
+    [Parameter] public RenderFragment<ModelBindingContext> ChildContent { get; set; } = default!;
+
+    [CascadingParameter] ModelBindingContext? ParentContext { get; set; }
+
+    [Inject] private NavigationManager Navigation { get; set; } = null!;
+
+    void IComponent.Attach(RenderHandle renderHandle)
+    {
+        _handle = renderHandle;
+    }
+
+    Task IComponent.SetParametersAsync(ParameterView parameters)
+    {
+        if (_bindingContext == null)
+        {
+            // First render
+            Navigation.LocationChanged += HandleLocationChanged;
+        }
+
+        parameters.SetParameterProperties(this);
+        if (ParentContext != null && string.IsNullOrEmpty(Name))
+        {
+            throw new InvalidOperationException($"Nested binding contexts must define a Name. (Parent context) = '{ParentContext.Name}'.");
+        }
+
+        UpdateBindingInformation(Navigation.Uri);
+        Render();
+
+        return Task.CompletedTask;
+    }
+
+    private void Render()
+    {
+        if (_hasPendingQueuedRender)
+        {
+            return;
+        }
+        _hasPendingQueuedRender = true;
+        _handle.Render(builder =>
+        {
+            _hasPendingQueuedRender = false;
+            builder.OpenComponent<CascadingValue<ModelBindingContext>>(0);
+            builder.AddComponentParameter(1, nameof(CascadingValue<ModelBindingContext>.IsFixed), IsFixed);
+            builder.AddComponentParameter(2, nameof(CascadingValue<ModelBindingContext>.Value), _bindingContext);
+            builder.AddComponentParameter(3, nameof(CascadingValue<ModelBindingContext>.ChildContent), ChildContent?.Invoke(_bindingContext!));
+            builder.CloseComponent();
+        });
+    }
+
+    private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
+    {
+        var url = e.Location;
+        UpdateBindingInformation(url);
+        Render();
+    }
+
+    private void UpdateBindingInformation(string url)
+    {
+        // BindingContextId: action parameter used to define the handler
+        // Name: form name and context used to bind
+        // Cases:
+        // 1) No name ("")
+        // Name = "";
+        // BindingContextId = "";
+        // <form name="" action="" />
+        // 2) Name provided
+        // Name = "my-handler";
+        // BindingContextId = <<base-relative-uri>>((<<existing-query>>&)|?)handler=my-handler
+        // <form name="my-handler" action="relative/path?existing=value&handler=my-handler
+        // 3) Parent has a name "parent-name"
+        // Name = "parent-name.my-handler";
+        // BindingContextId = <<base-relative-uri>>((<<existing-query>>&)|?)handler=my-handler
+        var name = string.IsNullOrEmpty(ParentContext?.Name) ? Name : $"{ParentContext.Name}.{Name}";
+        var bindingId = string.IsNullOrEmpty(name) ? "" : GenerateBindingContextId(name);
+
+        var bindingContext = _bindingContext != null &&
+            string.Equals(_bindingContext.Name, Name, StringComparison.Ordinal) &&
+            string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal) ?
+            _bindingContext : new ModelBindingContext(name, bindingId);
+
+        // It doesn't matter that we don't check IsFixed, since the CascadingValue we are setting up will throw if the app changes.
+        if (IsFixed && _bindingContext != null && _bindingContext != bindingContext)
+        {
+            // Throw an exception if either the Name or the BindingContextId changed. Once a CascadingModelBinder has been initialized
+            // as fixed, it can't change it's name nor its BindingContextId. This can happen in several situations:
+            // * Component ParentContext hierarchy changes.
+            //   * Technically, the component won't be retained in this case and will be destroyed instead.
+            // * A parent changes Name.
+            throw new InvalidOperationException($"'{nameof(CascadingModelBinder)}' 'Name' can't change after initialized.");
+        }
+
+        _bindingContext = bindingContext;
+
+        string GenerateBindingContextId(string name)
+        {
+            var bindingId = Navigation.ToBaseRelativePath(Navigation.GetUriWithQueryParameter("handler", name));
+            var hashIndex = bindingId.IndexOf('#');
+            return hashIndex == -1 ? bindingId : new string(bindingId.AsSpan(0, hashIndex));
+        }
+    }
+
+    void IDisposable.Dispose()
+    {
+        Navigation.LocationChanged -= HandleLocationChanged;
+    }
+}

+ 37 - 0
src/Components/Components/src/Binding/ModelBindingContext.cs

@@ -0,0 +1,37 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components.Binding;
+
+/// <summary>
+/// The binding context associated with a given model binding operation.
+/// </summary>
+public sealed class ModelBindingContext
+{
+    internal ModelBindingContext(string name, string bindingContextId)
+    {
+        ArgumentNullException.ThrowIfNull(name);
+        ArgumentNullException.ThrowIfNull(bindingContextId);
+        // We are initializing the root context, that can be a "named" root context, or the default context.
+        // A named root context only provides a name, and that acts as the BindingId
+        // A "default" root context does not provide a name, and instead it provides an explicit Binding ID.
+        // The explicit binding ID matches that of the default handler, which is the URL Path.
+        if (string.IsNullOrEmpty(name) ^ string.IsNullOrEmpty(bindingContextId))
+        {
+            throw new InvalidOperationException("A root binding context needs to provide a name and explicit binding context id or none.");
+        }
+
+        Name = name;
+        BindingContextId = bindingContextId ?? name;
+    }
+
+    /// <summary>
+    /// The context name.
+    /// </summary>
+    public string Name { get; }
+
+    /// <summary>
+    /// The computed identifier used to determine what parts of the app can bind data.
+    /// </summary>
+    public string BindingContextId { get; }
+}

+ 1 - 1
src/Components/Components/src/NavigationManagerExtensions.cs

@@ -738,7 +738,7 @@ public static class NavigationManagerExtensions
         var hashStartIndex = uri.IndexOf('#');
         hash = hashStartIndex < 0 ? "" : uri.AsSpan(hashStartIndex);
 
-        var queryStartIndex = uri.IndexOf('?');
+        var queryStartIndex = (hashStartIndex > 0 ? uri.AsSpan(0, hashStartIndex) : uri).IndexOf('?');
 
         if (queryStartIndex < 0)
         {

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

@@ -1,9 +1,21 @@
 #nullable enable
+Microsoft.AspNetCore.Components.Binding.ModelBindingContext
+Microsoft.AspNetCore.Components.Binding.ModelBindingContext.BindingContextId.get -> string!
+Microsoft.AspNetCore.Components.Binding.ModelBindingContext.Name.get -> string!
+Microsoft.AspNetCore.Components.CascadingModelBinder
+Microsoft.AspNetCore.Components.CascadingModelBinder.CascadingModelBinder() -> void
+Microsoft.AspNetCore.Components.CascadingModelBinder.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.Binding.ModelBindingContext!>!
+Microsoft.AspNetCore.Components.CascadingModelBinder.ChildContent.set -> void
+Microsoft.AspNetCore.Components.CascadingModelBinder.IsFixed.get -> bool
+Microsoft.AspNetCore.Components.CascadingModelBinder.IsFixed.set -> void
+Microsoft.AspNetCore.Components.CascadingModelBinder.Name.get -> string!
+Microsoft.AspNetCore.Components.CascadingModelBinder.Name.set -> void
 Microsoft.AspNetCore.Components.ComponentBase.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
 Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.Dispatcher! dispatcher) -> System.Threading.Tasks.Task!
 Microsoft.AspNetCore.Components.RenderHandle.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
 *REMOVED*Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string! relativeUri) -> System.Uri!
 Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string? relativeUri) -> System.Uri!
+Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.SetEventHandlerName(string! eventHandlerName) -> void
 Microsoft.AspNetCore.Components.Routing.IScrollToLocationHash
 Microsoft.AspNetCore.Components.Routing.IScrollToLocationHash.RefreshScrollPositionForHash(string! locationAbsolute) -> System.Threading.Tasks.Task!
 Microsoft.AspNetCore.Components.Rendering.ComponentState
@@ -39,3 +51,6 @@ override Microsoft.AspNetCore.Components.EventCallback<TValue>.GetHashCode() ->
 override Microsoft.AspNetCore.Components.EventCallback<TValue>.Equals(object? obj) -> bool
 virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.AddPendingTask(Microsoft.AspNetCore.Components.Rendering.ComponentState? componentState, System.Threading.Tasks.Task! task) -> void
 virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.CreateComponentState(int componentId, Microsoft.AspNetCore.Components.IComponent! component, Microsoft.AspNetCore.Components.Rendering.ComponentState? parentComponentState) -> Microsoft.AspNetCore.Components.Rendering.ComponentState!
+virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo? fieldInfo, System.EventArgs! eventArgs, bool quiesce) -> System.Threading.Tasks.Task!
+virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.ShouldTrackNamedEventHandlers() -> bool
+virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.TrackNamedEventId(ulong eventHandlerId, int componentId, string! eventHandlerName) -> void

+ 26 - 1
src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs

@@ -23,7 +23,8 @@ internal static class RenderTreeDiffBuilder
         RenderBatchBuilder batchBuilder,
         int componentId,
         ArrayRange<RenderTreeFrame> oldTree,
-        ArrayRange<RenderTreeFrame> newTree)
+        ArrayRange<RenderTreeFrame> newTree,
+        Dictionary<string, int>? namedEventIndexes)
     {
         var editsBuffer = batchBuilder.EditsBuffer;
         var editsBufferStartLength = editsBuffer.Count;
@@ -33,6 +34,30 @@ internal static class RenderTreeDiffBuilder
 
         var editsSegment = editsBuffer.ToSegment(editsBufferStartLength, editsBuffer.Count);
         var result = new RenderTreeDiff(componentId, editsSegment);
+
+        // Named event handlers name must be unique globally and stable over the time period we are deciding where to
+        // dispatch a given named event.
+        // Once a component has defined a named event handler with a concrete name, no other component instance can
+        // define a named event handler with that name.
+        //
+        // At this stage, we only ensure that the named event handler is unique per component instance, as that,
+        // combined with the check that the EndpointRenderer does, is enough to ensure the uniqueness and the stability
+        // of the named event handler over time **globally**.
+        //
+        // Tracking and uniqueness are enforced when we are trying to dispatch an event to a named event handler, since in
+        // any other case we don't actually track the named event handlers. We do this because:
+        // 1) We don't want to break the user's app if we don't have to.
+        // 2) We don't have to pay the cost of continously tracking all events all the time to throw.
+        // That's why raising the error is delayed until we are forced to make a decission.
+        if (namedEventIndexes != null)
+        {
+            foreach (var (name, index) in namedEventIndexes)
+            {
+                ref var frame = ref newTree.Array[index];
+                renderer.TrackNamedEventId(frame.AttributeEventHandlerId, componentId, name);
+            }
+        }
+
         return result;
     }
 

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

@@ -372,6 +372,22 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable
     /// has completed.
     /// </returns>
     public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fieldInfo, EventArgs eventArgs)
+    {
+        return DispatchEventAsync(eventHandlerId, fieldInfo, eventArgs, quiesce: false);
+    }
+
+    /// <summary>
+    /// Notifies the renderer that an event has occurred.
+    /// </summary>
+    /// <param name="eventHandlerId">The <see cref="RenderTreeFrame.AttributeEventHandlerId"/> value from the original event attribute.</param>
+    /// <param name="eventArgs">Arguments to be passed to the event handler.</param>
+    /// <param name="fieldInfo">Information that the renderer can use to update the state of the existing render tree to match the UI.</param>
+    /// <param name="quiesce">Whether to wait for quiescence or not.</param>
+    /// <returns>
+    /// A <see cref="Task"/> which will complete once all asynchronous processing related to the event
+    /// has completed.
+    /// </returns>
+    public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fieldInfo, EventArgs eventArgs, bool quiesce)
     {
         Dispatcher.AssertAccess();
 
@@ -402,9 +418,22 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable
             _isBatchInProgress = true;
 
             task = callback.InvokeAsync(eventArgs);
+            if (quiesce)
+            {
+                // If we are waiting for quiescence, the quiescence task will capture any async exception.
+                // If the exception is thrown synchronously, we just want it to flow to the callers, and
+                // not go through the ErrorBoundary.
+                _pendingTasks ??= new();
+                AddPendingTask(receiverComponentState, task);
+            }
         }
         catch (Exception e)
         {
+            if (quiesce)
+            {
+                // Exception filters are not AoT friendly.
+                throw;
+            }
             HandleExceptionViaErrorBoundary(e, receiverComponentState);
             return Task.CompletedTask;
         }
@@ -417,6 +446,11 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable
             ProcessPendingRender();
         }
 
+        if (quiesce)
+        {
+            return WaitForQuiescence();
+        }
+
         // Task completed synchronously or is still running. We already processed all of the rendering
         // work that was queued so let our error handler deal with it.
         var result = GetErrorHandledTask(task, receiverComponentState);
@@ -564,6 +598,22 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable
         _eventHandlerIdReplacements.Add(oldEventHandlerId, newEventHandlerId);
     }
 
+    /// <summary>
+    /// Tracks named events defined during rendering.
+    /// </summary>
+    /// <param name="eventHandlerId">The event handler ID associated with the named event.</param>
+    /// <param name="componentId">The component ID defining the name.</param>
+    /// <param name="eventHandlerName">The event name.</param>
+    protected internal virtual void TrackNamedEventId(ulong eventHandlerId, int componentId, string eventHandlerName)
+    {
+    }
+
+    /// <summary>
+    /// Indicates whether named event handlers should be tracked.
+    /// </summary>
+    /// <returns><c>true</c> if named event handlers should be tracked; <c>false</c> otherwise.</returns>
+    protected internal virtual bool ShouldTrackNamedEventHandlers() => false;
+
     private EventCallback GetRequiredEventCallback(ulong eventHandlerId)
     {
         if (!_eventBindings.TryGetValue(eventHandlerId, out var callback))

+ 10 - 1
src/Components/Components/src/Rendering/ComponentState.cs

@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics;
 using System.Diagnostics.CodeAnalysis;
 using Microsoft.AspNetCore.Components.RenderTree;
 
@@ -11,6 +12,7 @@ namespace Microsoft.AspNetCore.Components.Rendering;
 /// within the context of a <see cref="Renderer"/>. This is an internal implementation
 /// detail of <see cref="Renderer"/>.
 /// </summary>
+[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
 public class ComponentState : IDisposable
 {
     private readonly Renderer _renderer;
@@ -74,6 +76,7 @@ public class ComponentState : IDisposable
         }
 
         _nextRenderTree.Clear();
+        _nextRenderTree.TrackNamedEventHandlers = _renderer.ShouldTrackNamedEventHandlers();
 
         try
         {
@@ -99,7 +102,8 @@ public class ComponentState : IDisposable
             batchBuilder,
             ComponentId,
             _nextRenderTree.GetFrames(),
-            CurrentRenderTree.GetFrames());
+            CurrentRenderTree.GetFrames(),
+            CurrentRenderTree.GetNamedEvents());
         batchBuilder.UpdatedComponentDiffs.Append(diff);
         batchBuilder.InvalidateParameterViews();
     }
@@ -297,4 +301,9 @@ public class ComponentState : IDisposable
             return Task.FromException(e);
         }
     }
+
+    private string GetDebuggerDisplay()
+    {
+        return $"{ComponentId} - {Component.GetType().Name} - Disposed: {_componentWasDisposed}";
+    }
 }

+ 8 - 0
src/Components/Components/src/Rendering/RenderQueueEntry.cs

@@ -1,8 +1,11 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics;
+
 namespace Microsoft.AspNetCore.Components.Rendering;
 
+[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
 internal readonly struct RenderQueueEntry
 {
     public readonly ComponentState ComponentState;
@@ -13,4 +16,9 @@ internal readonly struct RenderQueueEntry
         ComponentState = componentState;
         RenderFragment = renderFragment ?? throw new ArgumentNullException(nameof(renderFragment));
     }
+
+    private string GetDebuggerDisplay()
+    {
+        return $"{ComponentState.ComponentId} - {ComponentState.Component.GetType().Name}";
+    }
 }

+ 47 - 0
src/Components/Components/src/Rendering/RenderTreeBuilder.cs

@@ -27,6 +27,10 @@ public sealed class RenderTreeBuilder : IDisposable
     private RenderTreeFrameType? _lastNonAttributeFrameType;
     private bool _hasSeenAddMultipleAttributes;
     private Dictionary<string, int>? _seenAttributeNames;
+    private Dictionary<string, int>? _seenEventHandlerNames;
+
+    // Configure the render tree builder to capture the event handler names.
+    internal bool TrackNamedEventHandlers { get; set; }
 
     /// <summary>
     /// The reserved parameter name used for supplying child content.
@@ -482,6 +486,42 @@ public sealed class RenderTreeBuilder : IDisposable
         prevFrame.AttributeEventUpdatesAttributeNameField = updatesAttributeName;
     }
 
+    /// <summary>
+    /// <para>
+    /// Indicates that the preceding attribute represents a named event handler
+    /// with the given <paramref name="eventHandlerName"/>.
+    /// </para>
+    /// <para>
+    /// This information is used by the rendering system to support dispatching
+    /// external events by name.
+    /// </para>
+    /// </summary>
+    /// <param name="eventHandlerName">The name associated with this event handler.</param>
+    public void SetEventHandlerName(string eventHandlerName)
+    {
+        if (!TrackNamedEventHandlers)
+        {
+            return;
+        }
+
+        if (_entries.Count == 0)
+        {
+            throw new InvalidOperationException("No preceding attribute frame exists.");
+        }
+
+        ref var prevFrame = ref _entries.Buffer[_entries.Count - 1];
+        if (prevFrame.FrameTypeField != RenderTreeFrameType.Attribute && !(prevFrame.AttributeValue is MulticastDelegate or IEventCallback))
+        {
+            throw new InvalidOperationException($"The previous attribute is not an event handler.");
+        }
+
+        _seenEventHandlerNames ??= new();
+        if (!_seenEventHandlerNames.TryAdd(eventHandlerName, _entries.Count - 1))
+        {
+            throw new InvalidOperationException($"An event handler '{eventHandlerName}' is already defined in this component.");
+        }
+    }
+
     /// <summary>
     /// Appends a frame representing a child component.
     /// </summary>
@@ -690,6 +730,8 @@ public sealed class RenderTreeBuilder : IDisposable
         _lastNonAttributeFrameType = null;
         _hasSeenAddMultipleAttributes = false;
         _seenAttributeNames?.Clear();
+        _seenEventHandlerNames?.Clear();
+        TrackNamedEventHandlers = false;
     }
 
     // internal because this should only be used during the post-event tree patching logic
@@ -826,4 +868,9 @@ public sealed class RenderTreeBuilder : IDisposable
     {
         _entries.Dispose();
     }
+
+    internal Dictionary<string, int>? GetNamedEvents()
+    {
+        return _seenEventHandlerNames;
+    }
 }

+ 31 - 32
src/Components/Components/src/RouteView.cs

@@ -5,6 +5,7 @@
 
 using System.Diagnostics.CodeAnalysis;
 using System.Reflection;
+using Microsoft.AspNetCore.Components.Binding;
 using Microsoft.AspNetCore.Components.Rendering;
 using Microsoft.AspNetCore.Components.Routing;
 
@@ -16,8 +17,6 @@ namespace Microsoft.AspNetCore.Components;
 /// </summary>
 public class RouteView : IComponent
 {
-    private readonly RenderFragment _renderDelegate;
-    private readonly RenderFragment _renderPageWithParametersDelegate;
     private RenderHandle _renderHandle;
 
     [Inject]
@@ -39,16 +38,6 @@ public class RouteView : IComponent
     [Parameter]
     public Type DefaultLayout { get; set; }
 
-    /// <summary>
-    /// Initializes a new instance of <see cref="RouteView"/>.
-    /// </summary>
-    public RouteView()
-    {
-        // Cache the delegate instances
-        _renderDelegate = Render;
-        _renderPageWithParametersDelegate = RenderPageWithParameters;
-    }
-
     /// <inheritdoc />
     public void Attach(RenderHandle renderHandle)
     {
@@ -65,7 +54,7 @@ public class RouteView : IComponent
             throw new InvalidOperationException($"The {nameof(RouteView)} component requires a non-null value for the parameter {nameof(RouteData)}.");
         }
 
-        _renderHandle.Render(_renderDelegate);
+        _renderHandle.Render(Render);
         return Task.CompletedTask;
     }
 
@@ -82,35 +71,45 @@ public class RouteView : IComponent
 
         builder.OpenComponent<LayoutView>(0);
         builder.AddComponentParameter(1, nameof(LayoutView.Layout), pageLayoutType);
-        builder.AddComponentParameter(2, nameof(LayoutView.ChildContent), _renderPageWithParametersDelegate);
+        builder.AddComponentParameter(2, nameof(LayoutView.ChildContent), (RenderFragment)RenderPageWithParameters);
         builder.CloseComponent();
     }
 
     private void RenderPageWithParameters(RenderTreeBuilder builder)
     {
-        builder.OpenComponent(0, RouteData.PageType);
+        builder.OpenComponent<CascadingModelBinder>(0);
+        builder.AddComponentParameter(1, nameof(CascadingModelBinder.ChildContent), (RenderFragment<ModelBindingContext>)RenderPageWithContext);
+        builder.CloseComponent();
 
-        foreach (var kvp in RouteData.RouteValues)
-        {
-            builder.AddComponentParameter(1, kvp.Key, kvp.Value);
-        }
+        RenderFragment RenderPageWithContext(ModelBindingContext context) => RenderPageCore;
 
-        var queryParameterSupplier = QueryParameterValueSupplier.ForType(RouteData.PageType);
-        if (queryParameterSupplier is not null)
+        void RenderPageCore(RenderTreeBuilder builder)
         {
-            // Since this component does accept some parameters from query, we must supply values for all of them,
-            // even if the querystring in the URI is empty. So don't skip the following logic.
-            var url = NavigationManager.Uri;
-            ReadOnlyMemory<char> query = default;
-            var queryStartPos = url.IndexOf('?');
-            if (queryStartPos >= 0)
+            builder.OpenComponent(0, RouteData.PageType);
+
+            foreach (var kvp in RouteData.RouteValues)
             {
-                var queryEndPos = url.IndexOf('#', queryStartPos);
-                query = url.AsMemory(queryStartPos..(queryEndPos < 0 ? url.Length : queryEndPos));
+                builder.AddComponentParameter(1, kvp.Key, kvp.Value);
             }
-            queryParameterSupplier.RenderParametersFromQueryString(builder, query);
-        }
 
-        builder.CloseComponent();
+            var queryParameterSupplier = QueryParameterValueSupplier.ForType(RouteData.PageType);
+            if (queryParameterSupplier is not null)
+            {
+                // Since this component does accept some parameters from query, we must supply values for all of them,
+                // even if the querystring in the URI is empty. So don't skip the following logic.
+                var relativeUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
+                var url = NavigationManager.Uri;
+                ReadOnlyMemory<char> query = default;
+                var queryStartPos = url.IndexOf('?');
+                if (queryStartPos >= 0)
+                {
+                    var queryEndPos = url.IndexOf('#', queryStartPos);
+                    query = url.AsMemory(queryStartPos..(queryEndPos < 0 ? url.Length : queryEndPos));
+                }
+                queryParameterSupplier.RenderParametersFromQueryString(builder, query);
+            }
+
+            builder.CloseComponent();
+        }
     }
 }

+ 332 - 0
src/Components/Components/test/CascadingModelBinderTest.cs

@@ -0,0 +1,332 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Components.Binding;
+using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.AspNetCore.Components.Test.Helpers;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.AspNetCore.Components;
+
+public class CascadingModelBinderTest
+{
+    private readonly TestRenderer _renderer;
+    private TestNavigationManager _navigationManager;
+
+    public CascadingModelBinderTest()
+    {
+        var serviceCollection = new ServiceCollection();
+        _navigationManager = new TestNavigationManager();
+        serviceCollection.AddSingleton<NavigationManager>(_navigationManager);
+        var services = serviceCollection.BuildServiceProvider();
+        _renderer = new TestRenderer(services);
+    }
+
+    [Fact]
+    public void CascadingModelBinder_NoBindingContextId_ForDefaultName()
+    {
+        ModelBindingContext capturedContext = null;
+        RenderFragment<ModelBindingContext> contents = (ctx) => b => { capturedContext = ctx; };
+
+        var testComponent = new TestComponent(builder =>
+        {
+            builder.OpenComponent<CascadingModelBinder>(0);
+            builder.AddAttribute(1, nameof(CascadingModelBinder.ChildContent), contents);
+            builder.CloseComponent();
+        });
+        var id = _renderer.AssignRootComponentId(testComponent);
+
+        // Act
+        _renderer.RenderRootComponent(id);
+
+        // Assert
+        Assert.NotNull(capturedContext);
+        Assert.Empty(capturedContext.Name);
+        Assert.Empty(capturedContext.BindingContextId);
+    }
+
+    [Theory]
+    [InlineData("path", "path?handler=named-context")]
+    [InlineData("", "?handler=named-context")]
+    [InlineData("path/with/multiple/segments", "path/with/multiple/segments?handler=named-context")]
+    [InlineData("path/with/multiple/segments?and=query", "path/with/multiple/segments?and=query&handler=named-context")]
+    [InlineData("path/with/multiple/segments?and=query#hashtoo", "path/with/multiple/segments?and=query&handler=named-context")]
+    [InlineData("path/with/#multiple/segments?and=query#hashtoo", "path/with/?handler=named-context")]
+    [InlineData("path/with/multiple/segments#hashtoo?and=query", "path/with/multiple/segments?handler=named-context")]
+    public void GeneratesCorrect_BindingContextId_ForNamedBinders(string url, string expectedBindingContextId)
+    {
+        ModelBindingContext capturedContext = null;
+        RenderFragment<ModelBindingContext> contents = (ctx) => b => { capturedContext = ctx; };
+        _navigationManager.NavigateTo(_navigationManager.ToAbsoluteUri(url).ToString());
+
+        var testComponent = new TestComponent(builder =>
+        {
+            builder.OpenComponent<CascadingModelBinder>(0);
+            builder.AddAttribute(1, nameof(CascadingModelBinder.Name), "named-context");
+            builder.AddAttribute(2, nameof(CascadingModelBinder.ChildContent), contents);
+            builder.CloseComponent();
+        });
+        var id = _renderer.AssignRootComponentId(testComponent);
+
+        // Act
+        _renderer.RenderRootComponent(id);
+
+        // Assert
+        Assert.NotNull(capturedContext);
+        Assert.Equal(expectedBindingContextId, capturedContext.BindingContextId);
+    }
+
+    [Fact]
+    public void CascadingModelBinder_CanProvideName()
+    {
+        ModelBindingContext capturedContext = null;
+        RenderFragment<ModelBindingContext> contents = (ctx) => b => { capturedContext = ctx; };
+
+        var testComponent = new TestComponent(builder =>
+        {
+            builder.OpenComponent<CascadingModelBinder>(0);
+            builder.AddAttribute(1, nameof(CascadingModelBinder.Name), "named-context");
+            builder.AddAttribute(2, nameof(CascadingModelBinder.ChildContent), contents);
+            builder.CloseComponent();
+        });
+        var id = _renderer.AssignRootComponentId(testComponent);
+
+        // Act
+        _renderer.RenderRootComponent(id);
+
+        // Assert
+        Assert.NotNull(capturedContext);
+        Assert.Equal("named-context", capturedContext.Name);
+        Assert.Equal("path?query=value&handler=named-context", capturedContext.BindingContextId);
+    }
+
+    [Fact]
+    public void CascadingModelBinder_CanNestNamedContexts()
+    {
+        ModelBindingContext capturedContext = null;
+        RenderFragment<ModelBindingContext> contents = (ctx) => b => { capturedContext = ctx; };
+        RenderFragment<ModelBindingContext> nested = (ctx) => b =>
+        {
+            b.OpenComponent<CascadingModelBinder>(0);
+            b.AddAttribute(1, nameof(CascadingModelBinder.Name), "child-context");
+            b.AddAttribute(2, nameof(CascadingModelBinder.ChildContent), contents);
+            b.CloseComponent();
+        };
+
+        var testComponent = new TestComponent(builder =>
+        {
+            builder.OpenComponent<CascadingModelBinder>(0);
+            builder.AddAttribute(1, nameof(CascadingModelBinder.Name), "parent-context");
+            builder.AddAttribute(2, nameof(CascadingModelBinder.ChildContent), nested);
+            builder.CloseComponent();
+        });
+        var id = _renderer.AssignRootComponentId(testComponent);
+
+        // Act
+        _renderer.RenderRootComponent(id);
+
+        // Assert
+        Assert.NotNull(capturedContext);
+        Assert.Equal("parent-context.child-context", capturedContext.Name);
+        Assert.Equal("path?query=value&handler=parent-context.child-context", capturedContext.BindingContextId);
+    }
+
+    [Fact]
+    public void CascadingModelBinder_CanNestWithDefaultContext()
+    {
+        ModelBindingContext capturedContext = null;
+        RenderFragment<ModelBindingContext> contents = (ctx) => b => { capturedContext = ctx; };
+        RenderFragment<ModelBindingContext> nested = (ctx) => b =>
+        {
+            b.OpenComponent<CascadingModelBinder>(0);
+            b.AddAttribute(1, nameof(CascadingModelBinder.Name), "child-context");
+            b.AddAttribute(2, nameof(CascadingModelBinder.ChildContent), contents);
+            b.CloseComponent();
+        };
+
+        var testComponent = new TestComponent(builder =>
+        {
+            builder.OpenComponent<CascadingModelBinder>(0);
+            builder.AddAttribute(2, nameof(CascadingModelBinder.ChildContent), nested);
+            builder.CloseComponent();
+        });
+        var id = _renderer.AssignRootComponentId(testComponent);
+
+        // Act
+        _renderer.RenderRootComponent(id);
+
+        // Assert
+        Assert.NotNull(capturedContext);
+        Assert.Equal("child-context", capturedContext.Name);
+        Assert.Equal("path?query=value&handler=child-context", capturedContext.BindingContextId);
+    }
+
+    [Fact]
+    public void Throws_IfDefaultContextIsNotTheRoot()
+    {
+        ModelBindingContext capturedContext = null;
+        RenderFragment<ModelBindingContext> contents = (ctx) => b => { capturedContext = ctx; };
+        RenderFragment<ModelBindingContext> nested = (ctx) => b =>
+        {
+            b.OpenComponent<CascadingModelBinder>(0);
+            b.AddAttribute(1, nameof(CascadingModelBinder.ChildContent), contents);
+            b.CloseComponent();
+        };
+
+        var testComponent = new TestComponent(builder =>
+        {
+            builder.OpenComponent<CascadingModelBinder>(0);
+            builder.AddAttribute(1, nameof(CascadingModelBinder.Name), "parent-context");
+            builder.AddAttribute(2, nameof(CascadingModelBinder.ChildContent), nested);
+            builder.CloseComponent();
+        });
+        var id = _renderer.AssignRootComponentId(testComponent);
+
+        // Act
+        var exception = Assert.Throws<InvalidOperationException>(() => _renderer.RenderRootComponent(id));
+        Assert.Equal("Nested binding contexts must define a Name. (Parent context) = 'parent-context'.", exception.Message);
+    }
+
+    [Fact]
+    public void Throws_WhenIsFixedAndNameChanges()
+    {
+        ModelBindingContext capturedContext = null;
+        RenderFragment<ModelBindingContext> contents = (ctx) => b => { capturedContext = ctx; };
+        var contextName = "parent-context";
+
+        var testComponent = new TestComponent(builder =>
+        {
+            builder.OpenComponent<CascadingModelBinder>(0);
+            builder.AddAttribute(1, nameof(CascadingModelBinder.Name), contextName);
+            builder.AddAttribute(2, nameof(CascadingModelBinder.IsFixed), true);
+            builder.AddAttribute(3, nameof(CascadingModelBinder.ChildContent), contents);
+            builder.CloseComponent();
+        });
+        var id = _renderer.AssignRootComponentId(testComponent);
+        _renderer.RenderRootComponent(id);
+
+        // Act
+        contextName = "changed";
+        var exception = Assert.Throws<InvalidOperationException>(testComponent.TriggerRender);
+
+        Assert.Equal("'CascadingModelBinder' 'Name' can't change after initialized.", exception.Message);
+    }
+
+    [Theory]
+    [InlineData(true)]
+    [InlineData(false)]
+    public void Throws_WhenIsFixed_Changes(bool isFixed)
+    {
+        ModelBindingContext capturedContext = null;
+        RenderFragment<ModelBindingContext> contents = (ctx) => b => { capturedContext = ctx; };
+        var testComponent = new TestComponent(builder =>
+        {
+            builder.OpenComponent<CascadingModelBinder>(0);
+            builder.AddAttribute(1, nameof(CascadingModelBinder.IsFixed), isFixed);
+            builder.AddAttribute(2, nameof(CascadingModelBinder.ChildContent), contents);
+            builder.CloseComponent();
+        });
+        var id = _renderer.AssignRootComponentId(testComponent);
+        _renderer.RenderRootComponent(id);
+
+        // Act
+        isFixed = !isFixed;
+        var exception = Assert.Throws<InvalidOperationException>(testComponent.TriggerRender);
+
+        Assert.Equal("The value of IsFixed cannot be changed dynamically.", exception.Message);
+    }
+
+    [Fact]
+    public void CanChange_Name_WhenNotFixed()
+    {
+        ModelBindingContext capturedContext = null;
+        ModelBindingContext originalContext = null;
+        RenderFragment<ModelBindingContext> contents = (ctx) => b => { capturedContext = ctx; };
+        var contextName = "parent-context";
+
+        var testComponent = new TestComponent(builder =>
+        {
+            builder.OpenComponent<CascadingModelBinder>(0);
+            builder.AddAttribute(1, nameof(CascadingModelBinder.Name), contextName);
+            builder.AddAttribute(3, nameof(CascadingModelBinder.ChildContent), contents);
+            builder.CloseComponent();
+        });
+        var id = _renderer.AssignRootComponentId(testComponent);
+        _renderer.RenderRootComponent(id);
+
+        originalContext = capturedContext;
+        contextName = "changed";
+
+        // Act
+        testComponent.TriggerRender();
+
+        Assert.NotSame(capturedContext, originalContext);
+        Assert.Equal("changed", capturedContext.Name);
+    }
+
+    [Fact]
+    public void CanChange_BindingContextId_WhenNotFixed()
+    {
+        ModelBindingContext capturedContext = null;
+        ModelBindingContext originalContext = null;
+        RenderFragment<ModelBindingContext> contents = (ctx) => b => { capturedContext = ctx; };
+
+        var testComponent = new TestComponent(builder =>
+        {
+            builder.OpenComponent<CascadingModelBinder>(0);
+            builder.AddComponentParameter(1, nameof(CascadingModelBinder.Name), "context-name");
+            builder.AddComponentParameter(2, nameof(CascadingModelBinder.ChildContent), contents);
+            builder.CloseComponent();
+        });
+        var id = _renderer.AssignRootComponentId(testComponent);
+        _renderer.RenderRootComponent(id);
+
+        originalContext = capturedContext;
+
+        // Act
+        _navigationManager.NavigateTo(_navigationManager.ToAbsoluteUri("fetch-data/6").ToString());
+        testComponent.TriggerRender();
+
+        Assert.NotSame(capturedContext, originalContext);
+        Assert.Equal("fetch-data/6?handler=context-name", capturedContext.BindingContextId);
+    }
+
+    private class RouteViewTestNavigationManager : NavigationManager
+    {
+        public RouteViewTestNavigationManager() =>
+            Initialize("https://www.example.com/subdir/", "https://www.example.com/subdir/");
+
+        public void NotifyLocationChanged(string uri)
+        {
+            Uri = uri;
+            NotifyLocationChanged(false);
+        }
+    }
+
+    class TestNavigationManager : NavigationManager
+    {
+        public TestNavigationManager()
+        {
+            Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash");
+        }
+
+        protected override void NavigateToCore([StringSyntax("Uri")] string uri, NavigationOptions options)
+        {
+            Uri = uri;
+        }
+    }
+
+    class TestComponent : AutoRenderComponent
+    {
+        private readonly RenderFragment _renderFragment;
+
+        public TestComponent(RenderFragment renderFragment)
+        {
+            _renderFragment = renderFragment;
+        }
+
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+            => _renderFragment(builder);
+    }
+}

+ 39 - 0
src/Components/Components/test/ModelBindingContextTest.cs

@@ -0,0 +1,39 @@
+// 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.Binding;
+
+namespace Microsoft.AspNetCore.Components;
+
+public class ModelBindingContextTest
+{
+    [Fact]
+    public void CanCreate_BindingContext_WithDefaultName()
+    {
+        var context = new ModelBindingContext("", "");
+        Assert.Equal("", context.Name);
+        Assert.Equal("", context.BindingContextId);
+    }
+
+    [Fact]
+    public void CanCreate_BindingContext_WithName()
+    {
+        var context = new ModelBindingContext("name", "path?handler=name");
+        Assert.Equal("name", context.Name);
+        Assert.Equal("path?handler=name", context.BindingContextId);
+    }
+
+    [Fact]
+    public void Throws_WhenNameIsProvided_AndNoBindingContextId()
+    {
+        var exception = Assert.Throws<InvalidOperationException>(() => new ModelBindingContext("name", ""));
+        Assert.Equal("A root binding context needs to provide a name and explicit binding context id or none.", exception.Message);
+    }
+
+    [Fact]
+    public void Throws_WhenBindingContextId_IsProvidedForDefaultName()
+    {
+        var exception = Assert.Throws<InvalidOperationException>(() => new ModelBindingContext("", "context"));
+        Assert.Equal("A root binding context needs to provide a name and explicit binding context id or none.", exception.Message);
+    }
+}

+ 9 - 9
src/Components/Components/test/RenderTreeDiffBuilderTest.cs

@@ -816,7 +816,7 @@ public class RenderTreeDiffBuilderTest : IDisposable
         using var batchBuilder = new RenderBatchBuilder();
 
         // Act
-        var diff = RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, oldTree.GetFrames(), newTree.GetFrames());
+        var diff = RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, oldTree.GetFrames(), newTree.GetFrames(), newTree.GetNamedEvents());
 
         // Assert: We're going to dispose the old component and render the new one
         Assert.Equal(new[] { 0 }, batchBuilder.ComponentDisposalQueue);
@@ -1627,7 +1627,7 @@ public class RenderTreeDiffBuilderTest : IDisposable
 
         using var batchBuilder = new RenderBatchBuilder();
         using var renderTreeBuilder = new RenderTreeBuilder();
-        RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, renderTreeBuilder.GetFrames(), oldTree.GetFrames());
+        RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, renderTreeBuilder.GetFrames(), oldTree.GetFrames(), oldTree.GetNamedEvents());
         var originalFakeComponentInstance = oldTree.GetFrames().Array[2].Component;
         var originalFakeComponent2Instance = oldTree.GetFrames().Array[3].Component;
 
@@ -1713,7 +1713,7 @@ public class RenderTreeDiffBuilderTest : IDisposable
 
         using var batchBuilder = new RenderBatchBuilder();
         using var renderTree = new RenderTreeBuilder();
-        RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, renderTree.GetFrames(), oldTree.GetFrames());
+        RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, renderTree.GetFrames(), oldTree.GetFrames(), oldTree.GetNamedEvents());
         var originalComponentInstance = (FakeComponent)oldTree.GetFrames().Array[0].Component;
 
         // Act
@@ -1763,7 +1763,7 @@ public class RenderTreeDiffBuilderTest : IDisposable
 
         using var batchBuilder = new RenderBatchBuilder();
         using var renderTreeBuilder = new RenderTreeBuilder();
-        RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, renderTreeBuilder.GetFrames(), oldTree.GetFrames());
+        RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, renderTreeBuilder.GetFrames(), oldTree.GetFrames(), oldTree.GetNamedEvents());
         var originalComponentInstance = (CaptureSetParametersComponent)oldTree.GetFrames().Array[0].Component;
         Assert.Equal(1, originalComponentInstance.SetParametersCallCount);
 
@@ -1793,7 +1793,7 @@ public class RenderTreeDiffBuilderTest : IDisposable
 
         using var batchBuilder = new RenderBatchBuilder();
         using var renderTreeBuilder = new RenderTreeBuilder();
-        RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, renderTreeBuilder.GetFrames(), oldTree.GetFrames());
+        RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, renderTreeBuilder.GetFrames(), oldTree.GetFrames(), oldTree.GetNamedEvents());
         var componentInstance = (CaptureSetParametersComponent)oldTree.GetFrames().Array[0].Component;
         Assert.Equal(1, componentInstance.SetParametersCallCount);
 
@@ -1819,13 +1819,13 @@ public class RenderTreeDiffBuilderTest : IDisposable
 
         using var batchBuilder = new RenderBatchBuilder();
         using var renderTree = new RenderTreeBuilder();
-        RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, renderTree.GetFrames(), oldTree.GetFrames());
+        RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, renderTree.GetFrames(), oldTree.GetFrames(), oldTree.GetNamedEvents());
 
         // Act/Assert
         // Note that we track NonDisposableComponent was disposed even though it's not IDisposable,
         // because it's up to the upstream renderer to decide what "disposing" a component means
         Assert.Empty(batchBuilder.ComponentDisposalQueue);
-        RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, oldTree.GetFrames(), newTree.GetFrames());
+        RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, oldTree.GetFrames(), newTree.GetFrames(), newTree.GetNamedEvents());
         Assert.Equal(new[] { 0, 1 }, batchBuilder.ComponentDisposalQueue);
     }
 
@@ -2238,14 +2238,14 @@ public class RenderTreeDiffBuilderTest : IDisposable
             var emptyFrames = renderTreeBuilder.GetFrames();
             var oldFrames = from.GetFrames();
 
-            RenderTreeDiffBuilder.ComputeDiff(renderer, initializeBatchBuilder, 0, emptyFrames, oldFrames);
+            RenderTreeDiffBuilder.ComputeDiff(renderer, initializeBatchBuilder, 0, emptyFrames, oldFrames, from.GetNamedEvents());
         }
 
         batchBuilder?.Dispose();
         // This gets disposed as part of the test type's Dispose
         batchBuilder = new RenderBatchBuilder();
 
-        var diff = RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, from.GetFrames(), to.GetFrames());
+        var diff = RenderTreeDiffBuilder.ComputeDiff(renderer, batchBuilder, 0, from.GetFrames(), to.GetFrames(), to.GetNamedEvents());
         batchBuilder.UpdatedComponentDiffs.Append(diff);
         return batchBuilder.ToBatch();
     }

+ 283 - 31
src/Components/Components/test/RendererTest.cs

@@ -1333,8 +1333,9 @@ public class RendererTest
     {
         // Arrange
         var renderer = new TestRenderer();
-        var parentComponent = new OuterEventComponent();
-        parentComponent.RenderFragment = (builder) =>
+        var parentComponent = new OuterEventComponent
+        {
+            RenderFragment = (builder) =>
         {
             builder.OpenComponent<EventComponent>(0);
             builder.AddComponentParameter(1, nameof(EventComponent.OnClickAction), (Action)(() =>
@@ -1342,6 +1343,7 @@ public class RendererTest
                 // Do nothing.
             }));
             builder.CloseComponent();
+        }
         };
 
         var parentComponentId = renderer.AssignRootComponentId(parentComponent);
@@ -1432,8 +1434,9 @@ public class RendererTest
     {
         // Arrange
         var renderer = new TestRenderer();
-        var parentComponent = new OuterEventComponent();
-        parentComponent.RenderFragment = (builder) =>
+        var parentComponent = new OuterEventComponent
+        {
+            RenderFragment = (builder) =>
         {
             builder.OpenComponent<EventComponent>(0);
             builder.AddComponentParameter(1, nameof(EventComponent.OnClickAction), (Action)(() =>
@@ -1441,6 +1444,7 @@ public class RendererTest
                 throw new OperationCanceledException();
             }));
             builder.CloseComponent();
+        }
         };
 
         var parentComponentId = renderer.AssignRootComponentId(parentComponent);
@@ -1532,8 +1536,9 @@ public class RendererTest
     {
         // Arrange
         var renderer = new TestRenderer();
-        var parentComponent = new OuterEventComponent();
-        parentComponent.RenderFragment = (builder) =>
+        var parentComponent = new OuterEventComponent
+        {
+            RenderFragment = (builder) =>
         {
             builder.OpenComponent<EventComponent>(0);
             builder.AddComponentParameter(1, nameof(EventComponent.OnClickAction), (Action)(() =>
@@ -1541,6 +1546,7 @@ public class RendererTest
                 throw new InvalidTimeZoneException();
             }));
             builder.CloseComponent();
+        }
         };
 
         var parentComponentId = renderer.AssignRootComponentId(parentComponent);
@@ -1634,8 +1640,9 @@ public class RendererTest
         var tcs = new TaskCompletionSource();
 
         var renderer = new TestRenderer();
-        var parentComponent = new OuterEventComponent();
-        parentComponent.RenderFragment = (builder) =>
+        var parentComponent = new OuterEventComponent
+        {
+            RenderFragment = (builder) =>
         {
             builder.OpenComponent<EventComponent>(0);
             builder.AddComponentParameter(1, nameof(EventComponent.OnClickAsyncAction), (Func<Task>)(async () =>
@@ -1643,6 +1650,7 @@ public class RendererTest
                 await tcs.Task;
             }));
             builder.CloseComponent();
+        }
         };
 
         var parentComponentId = renderer.AssignRootComponentId(parentComponent);
@@ -1743,8 +1751,9 @@ public class RendererTest
         var tcs = new TaskCompletionSource();
 
         var renderer = new TestRenderer();
-        var parentComponent = new OuterEventComponent();
-        parentComponent.RenderFragment = (builder) =>
+        var parentComponent = new OuterEventComponent
+        {
+            RenderFragment = (builder) =>
         {
             builder.OpenComponent<EventComponent>(0);
             builder.AddComponentParameter(1, nameof(EventComponent.OnClickAsyncAction), (Func<Task>)(async () =>
@@ -1753,6 +1762,7 @@ public class RendererTest
                 throw new TaskCanceledException();
             }));
             builder.CloseComponent();
+        }
         };
 
         var parentComponentId = renderer.AssignRootComponentId(parentComponent);
@@ -1861,8 +1871,9 @@ public class RendererTest
         var tcs = new TaskCompletionSource();
 
         var renderer = new TestRenderer();
-        var parentComponent = new OuterEventComponent();
-        parentComponent.RenderFragment = (builder) =>
+        var parentComponent = new OuterEventComponent
+        {
+            RenderFragment = (builder) =>
         {
             builder.OpenComponent<EventComponent>(0);
             builder.AddComponentParameter(1, nameof(EventComponent.OnClickAsyncAction), (Func<Task>)(async () =>
@@ -1871,6 +1882,7 @@ public class RendererTest
                 throw new InvalidTimeZoneException();
             }));
             builder.CloseComponent();
+        }
         };
 
         var parentComponentId = renderer.AssignRootComponentId(parentComponent);
@@ -2365,8 +2377,11 @@ public class RendererTest
     {
         // Arrange
         var semaphore = new Semaphore(0, 1);
-        var renderer = new TestRenderer { ShouldHandleExceptions = true };
-        renderer.OnExceptionHandled = () => semaphore.Release();
+        var renderer = new TestRenderer
+        {
+            ShouldHandleExceptions = true,
+            OnExceptionHandled = () => semaphore.Release()
+        };
         var exception1 = new InvalidOperationException();
         var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
@@ -2407,8 +2422,11 @@ public class RendererTest
     {
         // Arrange
         var semaphore = new Semaphore(0, 1);
-        var renderer = new TestRenderer { ShouldHandleExceptions = true };
-        renderer.OnExceptionHandled = () => semaphore.Release();
+        var renderer = new TestRenderer
+        {
+            ShouldHandleExceptions = true,
+            OnExceptionHandled = () => semaphore.Release()
+        };
         var exception1 = new InvalidOperationException();
         var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
 
@@ -2855,7 +2873,7 @@ public class RendererTest
         var component = new TestComponent(builder => { });
 
         // Act/Assert
-        var ex = Assert.Throws<InvalidOperationException>(() => component.TriggerRender());
+        var ex = Assert.Throws<InvalidOperationException>(component.TriggerRender);
         Assert.Equal("The render handle is not yet assigned.", ex.Message);
     }
 
@@ -3272,7 +3290,7 @@ public class RendererTest
         var onAfterRenderCallCountLog = new List<int>();
         var component = new AsyncAfterRenderComponent(afterRenderTcs.Task)
         {
-            OnAfterRenderComplete = () => @event.Set(),
+            OnAfterRenderComplete = @event.Set,
         };
         var renderer = new AsyncUpdateTestRenderer()
         {
@@ -3301,7 +3319,7 @@ public class RendererTest
         var onAfterRenderCallCountLog = new List<int>();
         var component = new AsyncAfterRenderComponent(afterRenderTcs.Task)
         {
-            OnAfterRenderComplete = () => @event.Set(),
+            OnAfterRenderComplete = @event.Set,
         };
         var renderer = new AsyncUpdateTestRenderer()
         {
@@ -3639,7 +3657,7 @@ public class RendererTest
         var renderer = new TestRenderer()
         {
             ShouldHandleExceptions = true,
-            OnExceptionHandled = () => { @event.Set(); },
+            OnExceptionHandled = @event.Set,
         };
         var taskToAwait = Task.CompletedTask;
         var component = new TestComponent(builder =>
@@ -4357,14 +4375,7 @@ public class RendererTest
     {
         // Arrange
         var renderer = new InvalidRecursiveRenderer();
-        var component = new CallbackOnRenderComponent(() =>
-        {
-            // The renderer disallows one batch to be started inside another, because that
-            // would violate all kinds of state tracking invariants. It's not something that
-            // would ever happen except if you subclass the renderer and do something unsupported
-            // that commences batches from inside each other.
-            renderer.ProcessPendingRender();
-        });
+        var component = new CallbackOnRenderComponent(renderer.ProcessPendingRender);
         var componentId = renderer.AssignRootComponentId(component);
 
         // Act/Assert
@@ -4399,7 +4410,7 @@ public class RendererTest
         Assert.Throws<InvalidOperationException>(() => parameterView.GetEnumerator());
         Assert.Throws<InvalidOperationException>(() => parameterView.GetValueOrDefault<object>("anything"));
         Assert.Throws<InvalidOperationException>(() => parameterView.SetParameterProperties(new object()));
-        Assert.Throws<InvalidOperationException>(() => parameterView.ToDictionary());
+        Assert.Throws<InvalidOperationException>(parameterView.ToDictionary);
         var ex = Assert.Throws<InvalidOperationException>(() => parameterView.TryGetValue<object>("anything", out _));
 
         // It's enough to assert about one of the messages
@@ -4834,8 +4845,11 @@ public class RendererTest
     {
         // Arrange
         var autoResetEvent = new AutoResetEvent(false);
-        var renderer = new TestRenderer { ShouldHandleExceptions = true };
-        renderer.OnExceptionHandled = () => autoResetEvent.Set();
+        var renderer = new TestRenderer
+        {
+            ShouldHandleExceptions = true,
+            OnExceptionHandled = () => autoResetEvent.Set()
+        };
         var exception1 = new InvalidTimeZoneException();
         var exception2Tcs = new TaskCompletionSource();
         var rootComponent = new TestComponent(builder =>
@@ -4965,6 +4979,244 @@ public class RendererTest
         Assert.False(hotReloadManager.IsSubscribedTo);
     }
 
+    [Fact]
+    public void DoesNotTrackNamedEventHandlersWhenNotEnabled()
+    {
+        // Arrange
+        var renderer = new TestRenderer();
+        var namedEvents = new List<(ulong eventHandlerId, int componentId, string eventHandlerName)>();
+        renderer.OnNamedEvent = namedEvents.Add;
+
+        var component = new TestComponent(builder =>
+        {
+            builder.OpenElement(0, "form");
+            builder.AddAttribute(1, "onsubmit", () => { });
+            builder.SetEventHandlerName("MyFormSubmit");
+            builder.CloseElement();
+        });
+
+        // Act
+        var componentId = renderer.AssignRootComponentId(component);
+        component.TriggerRender();
+
+        // Assert
+        var batch = renderer.Batches.Single();
+        var diff = batch.DiffsByComponentId[componentId].Single();
+        Assert.Collection(diff.Edits,
+            edit =>
+            {
+                Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
+                Assert.Equal(0, edit.ReferenceFrameIndex);
+            });
+        AssertFrame.Element(batch.ReferenceFrames[0], "form", 2);
+        AssertFrame.Attribute(batch.ReferenceFrames[1], "onsubmit");
+
+        Assert.Empty(namedEvents);
+    }
+
+    [Fact]
+    public void CanCreateNamedEventHandlers()
+    {
+        // Arrange
+        var renderer = new TestRenderer
+        {
+            TrackNamedEventHandlers = true
+        };
+        var namedEvents = new List<(ulong eventHandlerId, int componentId, string eventHandlerName)>();
+        renderer.OnNamedEvent = namedEvents.Add;
+
+        var component = new TestComponent(builder =>
+        {
+            builder.OpenElement(0, "form");
+            builder.AddAttribute(1, "onsubmit", () => { });
+            builder.SetEventHandlerName("MyFormSubmit");
+            builder.CloseElement();
+        });
+
+        // Act
+        var componentId = renderer.AssignRootComponentId(component);
+        component.TriggerRender();
+
+        // Assert
+        var batch = renderer.Batches.Single();
+        var diff = batch.DiffsByComponentId[componentId].Single();
+        var evt = Assert.Single(namedEvents);
+        Assert.Collection(diff.Edits,
+            edit =>
+            {
+                Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
+                Assert.Equal(0, edit.ReferenceFrameIndex);
+            });
+        AssertFrame.Element(batch.ReferenceFrames[0], "form", 2);
+        AssertFrame.Attribute(batch.ReferenceFrames[1], "onsubmit");
+        Assert.Equal(batch.ReferenceFrames[1].AttributeEventHandlerId, evt.eventHandlerId);
+        Assert.Equal("MyFormSubmit", evt.eventHandlerName);
+        Assert.Equal(componentId, evt.componentId);
+    }
+
+    [Fact]
+    public void CanCreateMultipleNamedEventHandlersPerComponent()
+    {
+        // Arrange
+        var renderer = new TestRenderer
+        {
+            TrackNamedEventHandlers = true
+        };
+        var namedEvents = new List<(ulong eventHandlerId, int componentId, string eventHandlerName)>();
+        renderer.OnNamedEvent = namedEvents.Add;
+
+        var component = new TestComponent(builder =>
+        {
+            builder.OpenElement(0, "form");
+            builder.AddAttribute(1, "onsubmit", () => { });
+            builder.SetEventHandlerName("MyFormSubmit");
+            builder.CloseElement();
+            builder.OpenElement(2, "form");
+            builder.AddAttribute(3, "onsubmit", () => { });
+            builder.SetEventHandlerName("MyOtherFormSubmit");
+            builder.CloseElement();
+        });
+
+        // Act
+        var componentId = renderer.AssignRootComponentId(component);
+        component.TriggerRender();
+
+        // Assert
+        var batch = renderer.Batches.Single();
+        var diff = batch.DiffsByComponentId[componentId].Single();
+        Assert.Equal(2, namedEvents.Count);
+        Assert.Collection(diff.Edits,
+            edit =>
+            {
+                Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
+                Assert.Equal(0, edit.ReferenceFrameIndex);
+            },
+            edit =>
+            {
+                Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
+                Assert.Equal(2, edit.ReferenceFrameIndex);
+            });
+
+        AssertFrame.Element(batch.ReferenceFrames[0], "form", 2);
+        AssertFrame.Attribute(batch.ReferenceFrames[1], "onsubmit");
+        Assert.Equal(batch.ReferenceFrames[1].AttributeEventHandlerId, namedEvents[0].eventHandlerId);
+        Assert.Equal("MyFormSubmit", namedEvents[0].eventHandlerName);
+        Assert.Equal(componentId, namedEvents[0].componentId);
+
+        AssertFrame.Element(batch.ReferenceFrames[2], "form", 2);
+        AssertFrame.Attribute(batch.ReferenceFrames[3], "onsubmit");
+        Assert.Equal(batch.ReferenceFrames[3].AttributeEventHandlerId, namedEvents[1].eventHandlerId);
+        Assert.Equal("MyOtherFormSubmit", namedEvents[1].eventHandlerName);
+        Assert.Equal(componentId, namedEvents[1].componentId);
+    }
+
+    [Fact]
+    public void CanCreateMultipleNamedEventHandlersPerElement()
+    {
+        // Arrange
+        var renderer = new TestRenderer
+        {
+            TrackNamedEventHandlers = true
+        };
+        var namedEvents = new List<(ulong eventHandlerId, int componentId, string eventHandlerName)>();
+        renderer.OnNamedEvent = namedEvents.Add;
+
+        var component = new TestComponent(builder =>
+        {
+            builder.OpenElement(0, "form");
+            builder.AddAttribute(1, "onsubmit", () => { });
+            builder.SetEventHandlerName("MyFormSubmit");
+            builder.AddAttribute(2, "onclick", () => { });
+            builder.SetEventHandlerName("MyFormClick");
+            builder.CloseElement();
+        });
+
+        // Act
+        var componentId = renderer.AssignRootComponentId(component);
+        component.TriggerRender();
+
+        // Assert
+        var batch = renderer.Batches.Single();
+        var diff = batch.DiffsByComponentId[componentId].Single();
+        Assert.Equal(2, namedEvents.Count);
+        Assert.Collection(diff.Edits,
+            edit =>
+            {
+                Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
+                Assert.Equal(0, edit.ReferenceFrameIndex);
+            });
+        AssertFrame.Element(batch.ReferenceFrames[0], "form", 3);
+        AssertFrame.Attribute(batch.ReferenceFrames[1], "onsubmit");
+        AssertFrame.Attribute(batch.ReferenceFrames[2], "onclick");
+
+        Assert.Equal(batch.ReferenceFrames[1].AttributeEventHandlerId, namedEvents[0].eventHandlerId);
+        Assert.Equal("MyFormSubmit", namedEvents[0].eventHandlerName);
+        Assert.Equal(componentId, namedEvents[0].componentId);
+
+        Assert.Equal(batch.ReferenceFrames[2].AttributeEventHandlerId, namedEvents[1].eventHandlerId);
+        Assert.Equal("MyFormClick", namedEvents[1].eventHandlerName);
+        Assert.Equal(componentId, namedEvents[1].componentId);
+    }
+
+    [Fact]
+    public void DuplicateNamedEventHandlersOnComponentThrows()
+    {
+        // Arrange
+        var renderer = new TestRenderer
+        {
+            TrackNamedEventHandlers = true
+        };
+        var namedEvents = new List<(ulong eventHandlerId, int componentId, string eventHandlerName)>();
+        renderer.OnNamedEvent = namedEvents.Add;
+
+        var component = new TestComponent(builder =>
+        {
+            builder.OpenElement(0, "form");
+            builder.AddAttribute(1, "onsubmit", () => { });
+            builder.SetEventHandlerName("MyFormSubmit");
+            builder.CloseElement();
+            builder.OpenElement(2, "form");
+            builder.AddAttribute(3, "onsubmit", () => { });
+            builder.SetEventHandlerName("MyFormSubmit");
+            builder.CloseElement();
+        });
+
+        // Act
+        var componentId = renderer.AssignRootComponentId(component);
+
+        var exception = Assert.Throws<InvalidOperationException>(component.TriggerRender);
+        Assert.Equal("An event handler 'MyFormSubmit' is already defined in this component.", exception.Message);
+    }
+
+    [Fact]
+    public void DuplicateNamedEventHandlersOnElementThrows()
+    {
+        // Arrange
+        var renderer = new TestRenderer
+        {
+            TrackNamedEventHandlers = true
+        };
+        var namedEvents = new List<(ulong eventHandlerId, int componentId, string eventHandlerName)>();
+        renderer.OnNamedEvent = namedEvents.Add;
+
+        var component = new TestComponent(builder =>
+        {
+            builder.OpenElement(0, "form");
+            builder.AddAttribute(1, "onsubmit", () => { });
+            builder.SetEventHandlerName("MyFormSubmit");
+            builder.AddAttribute(2, "onclick", () => { });
+            builder.SetEventHandlerName("MyFormSubmit");
+            builder.CloseElement();
+        });
+
+        // Act
+        var componentId = renderer.AssignRootComponentId(component);
+
+        // Assert
+        var exception = Assert.Throws<InvalidOperationException>(component.TriggerRender);
+        Assert.Equal("An event handler 'MyFormSubmit' is already defined in this component.", exception.Message);
+    }
+
     private class TestComponentActivator<TResult> : IComponentActivator where TResult : IComponent, new()
     {
         public List<Type> RequestedComponentTypes { get; } = new List<Type>();

+ 45 - 6
src/Components/Components/test/RouteViewTest.cs

@@ -1,21 +1,31 @@
 // 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.Binding;
 using Microsoft.AspNetCore.Components.Rendering;
 using Microsoft.AspNetCore.Components.Test.Helpers;
+using Microsoft.Extensions.DependencyInjection;
 
 namespace Microsoft.AspNetCore.Components.Test;
 
 public class RouteViewTest
 {
     private readonly TestRenderer _renderer;
+    private readonly RouteViewTestNavigationManager _navigationManager;
     private readonly RouteView _routeViewComponent;
     private readonly int _routeViewComponentId;
 
     public RouteViewTest()
     {
-        _renderer = new TestRenderer();
-        _routeViewComponent = new RouteView();
+        var serviceCollection = new ServiceCollection();
+        _navigationManager = new RouteViewTestNavigationManager();
+        serviceCollection.AddSingleton<NavigationManager>(_navigationManager);
+        var services = serviceCollection.BuildServiceProvider();
+        _renderer = new TestRenderer(services);
+
+        var componentFactory = new ComponentFactory(new DefaultComponentActivator());
+        _routeViewComponent = (RouteView)componentFactory.InstantiateComponent(services, typeof(RouteView));
+
         _routeViewComponentId = _renderer.AssignRootComponentId(_routeViewComponent);
     }
 
@@ -62,16 +72,33 @@ public class RouteViewTest
             frame => AssertFrame.Component<TestLayout>(frame, subtreeLength: 2, sequence: 0),
             frame => AssertFrame.Attribute(frame, nameof(LayoutComponentBase.Body), sequence: 1));
 
-        // Assert: TestLayout renders page
+        // Assert: TestLayout renders cascading model binder
         var testLayoutComponentId = batch.GetComponentFrames<TestLayout>().Single().ComponentId;
         var testLayoutFrames = _renderer.GetCurrentRenderTreeFrames(testLayoutComponentId).AsEnumerable();
         Assert.Collection(testLayoutFrames,
             frame => AssertFrame.Text(frame, "Layout starts here", sequence: 0),
             frame => AssertFrame.Region(frame, subtreeLength: 3),
-            frame => AssertFrame.Component<ComponentWithLayout>(frame, sequence: 0, subtreeLength: 2),
-            frame => AssertFrame.Attribute(frame, nameof(ComponentWithLayout.Message), "Test message", sequence: 1),
+            frame => AssertFrame.Component<CascadingModelBinder>(frame, sequence: 0, subtreeLength: 2),
+            frame => AssertFrame.Attribute(frame, nameof(CascadingModelBinder.ChildContent), typeof(RenderFragment<ModelBindingContext>), sequence: 1),
             frame => AssertFrame.Text(frame, "Layout ends here", sequence: 2));
 
+        // Assert: Cascading model binder renders CascadingValue<ModelBindingContext>
+        var cascadingModelBinderComponentId = batch.GetComponentFrames<CascadingModelBinder>().Single().ComponentId;
+        var cascadingModelBinderFrames = _renderer.GetCurrentRenderTreeFrames(cascadingModelBinderComponentId).AsEnumerable();
+        Assert.Collection(cascadingModelBinderFrames,
+            frame => AssertFrame.Component<CascadingValue<ModelBindingContext>>(frame, sequence: 0, subtreeLength: 4),
+            frame => AssertFrame.Attribute(frame, nameof(CascadingValue<ModelBindingContext>.IsFixed), false, sequence: 1),
+            frame => AssertFrame.Attribute(frame, nameof(CascadingValue<ModelBindingContext>.Value), typeof(ModelBindingContext), sequence: 2),
+            frame => AssertFrame.Attribute(frame, nameof(CascadingValue<ModelBindingContext>.ChildContent), typeof(RenderFragment), sequence: 3));
+
+        // Assert: CascadingValue<ModelBindingContext> renders page
+        var cascadingValueComponentId = batch.GetComponentFrames<CascadingValue<ModelBindingContext>>().Single().ComponentId;
+        var cascadingValueFrames = _renderer.GetCurrentRenderTreeFrames(cascadingValueComponentId).AsEnumerable();
+        Assert.Collection(cascadingValueFrames,
+            frame => AssertFrame.Region(frame, sequence: 0, subtreeLength: 3),
+            frame => AssertFrame.Component<ComponentWithLayout>(frame, sequence: 0, subtreeLength: 2),
+            frame => AssertFrame.Attribute(frame, nameof(ComponentWithLayout.Message), "Test message", sequence: 1));
+
         // Assert: page itself is rendered, having received parameters from the original route data
         var pageComponentId = batch.GetComponentFrames<ComponentWithLayout>().Single().ComponentId;
         var pageFrames = _renderer.GetCurrentRenderTreeFrames(pageComponentId).AsEnumerable();
@@ -79,7 +106,7 @@ public class RouteViewTest
             frame => AssertFrame.Text(frame, "Hello from the page with message 'Test message'", sequence: 0));
 
         // Assert: nothing else was rendered
-        Assert.Equal(4, batch.DiffsInOrder.Count);
+        Assert.Equal(6, batch.DiffsInOrder.Count);
     }
 
     [Fact]
@@ -153,6 +180,18 @@ public class RouteViewTest
             frame => AssertFrame.Attribute(frame, nameof(LayoutView.ChildContent), sequence: 2));
     }
 
+    private class RouteViewTestNavigationManager : NavigationManager
+    {
+        public RouteViewTestNavigationManager() =>
+            Initialize("https://www.example.com/subdir/", "https://www.example.com/subdir/");
+
+        public void NotifyLocationChanged(string uri)
+        {
+            Uri = uri;
+            NotifyLocationChanged(false);
+        }
+    }
+
     private class ComponentWithoutLayout : AutoRenderComponent
     {
         [Parameter] public string Message { get; set; }

+ 3 - 11
src/Components/Endpoints/src/Builder/RazorComponentEndpointFactory.cs

@@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints;
 
 internal class RazorComponentEndpointFactory
 {
-    private static readonly HttpMethodMetadata HttpGet = new(new[] { HttpMethods.Get });
+    private static readonly HttpMethodMetadata HttpMethodsMetadata = new(new[] { HttpMethods.Get, HttpMethods.Post });
 
 #pragma warning disable CA1822 // It's a singleton
     internal void AddEndpoints(
@@ -36,7 +36,7 @@ internal class RazorComponentEndpointFactory
 
         // We do not support link generation, so explicitly opt-out.
         builder.Metadata.Add(new SuppressLinkGenerationMetadata());
-        builder.Metadata.Add(HttpGet);
+        builder.Metadata.Add(HttpMethodsMetadata);
         builder.Metadata.Add(new ComponentTypeMetadata(pageDefinition.Type));
 
         foreach (var convention in conventions)
@@ -55,16 +55,8 @@ internal class RazorComponentEndpointFactory
         // The display name is for debug purposes by endpoint routing.
         builder.DisplayName = $"{builder.RoutePattern.RawText} ({pageDefinition.DisplayName})";
 
-        builder.RequestDelegate = CreateRouteDelegate(pageDefinition.Type);
+        builder.RequestDelegate = httpContext => new RazorComponentEndpointInvoker(httpContext, pageDefinition.Type).RenderComponent();
 
         endpoints.Add(builder.Build());
     }
-
-    private static RequestDelegate CreateRouteDelegate(Type componentType)
-    {
-        return httpContext =>
-        {
-            return new RazorComponentEndpointInvoker(httpContext, componentType).RenderComponent();
-        };
-    }
 }

+ 59 - 9
src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs

@@ -1,4 +1,4 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Buffers;
@@ -32,14 +32,21 @@ internal class RazorComponentEndpointInvoker
     {
         _context.Response.ContentType = RazorComponentResultExecutor.DefaultContentType;
 
+        if (!await TryValidateRequestAsync(out var isPost))
+        {
+            // If the request is not valid we've already set the response to a 400 or similar
+            // and we can just exit early.
+            return;
+        }
+
         // We could pool these dictionary instances if we wanted, and possibly even the ParameterView
         // backing buffers could come from a pool like they do during rendering.
         var hostParameters = ParameterView.FromDictionary(new Dictionary<string, object?>
-                {
-                    { nameof(RazorComponentEndpointHost.RenderMode), RenderMode.Static },
-                    { nameof(RazorComponentEndpointHost.ComponentType), _componentType },
-                    { nameof(RazorComponentEndpointHost.ComponentParameters), null },
-                });
+        {
+            { nameof(RazorComponentEndpointHost.RenderMode), RenderMode.Static },
+            { nameof(RazorComponentEndpointHost.ComponentType), _componentType },
+            { nameof(RazorComponentEndpointHost.ComponentParameters), null },
+        });
 
         await using var writer = CreateResponseWriter(_context.Response.Body);
 
@@ -50,7 +57,19 @@ internal class RazorComponentEndpointInvoker
             _context,
             typeof(RazorComponentEndpointHost),
             hostParameters,
-            waitForQuiescence: false);
+            waitForQuiescence: isPost);
+
+        if (isPost && !_renderer.HasCapturedEvent())
+        {
+            _context.Response.StatusCode = StatusCodes.Status404NotFound;
+        }
+
+        var quiesceTask = isPost ? _renderer.DispatchCapturedEvent() : htmlContent.QuiescenceTask;
+
+        if (isPost)
+        {
+            await Task.WhenAll(_renderer.NonStreamingPendingTasks);
+        }
 
         // 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
@@ -58,9 +77,9 @@ internal class RazorComponentEndpointInvoker
         // 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)
+        if (!quiesceTask.IsCompleted)
         {
-            await _renderer.SendStreamingUpdatesAsync(_context, htmlContent.QuiescenceTask, writer);
+            await _renderer.SendStreamingUpdatesAsync(_context, quiesceTask, writer);
         }
 
         // Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying
@@ -69,6 +88,37 @@ internal class RazorComponentEndpointInvoker
         await writer.FlushAsync();
     }
 
+    private Task<bool> TryValidateRequestAsync(out bool isPost)
+    {
+        isPost = HttpMethods.IsPost(_context.Request.Method);
+        if (isPost)
+        {
+            return Task.FromResult(TrySetFormHandler());
+        }
+
+        return Task.FromResult(true);
+    }
+
+    private bool TrySetFormHandler()
+    {
+        var handler = "";
+        if (_context.Request.Query.TryGetValue("handler", out var value))
+        {
+            if (value.Count != 1)
+            {
+                _context.Response.StatusCode = StatusCodes.Status400BadRequest;
+                return false;
+            }
+            else
+            {
+                handler = value[0];
+            }
+        }
+
+        _renderer.SetFormHandlerName(handler!);
+        return true;
+    }
+
     private static TextWriter CreateResponseWriter(Stream bodyStream)
     {
         // Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize

+ 1 - 1
src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs

@@ -9,7 +9,7 @@ using Microsoft.Extensions.DependencyInjection;
 
 namespace Microsoft.AspNetCore.Components.Endpoints;
 
-internal sealed partial class EndpointHtmlRenderer
+internal partial class EndpointHtmlRenderer
 {
     private static readonly object ComponentSequenceKey = new object();
 

+ 1 - 1
src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.PrerenderingState.cs

@@ -10,7 +10,7 @@ using Microsoft.Extensions.DependencyInjection;
 
 namespace Microsoft.AspNetCore.Components.Endpoints;
 
-internal sealed partial class EndpointHtmlRenderer
+internal partial class EndpointHtmlRenderer
 {
     private static readonly object InvokedRenderModesKey = new object();
 

+ 93 - 1
src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs

@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Text;
 using Microsoft.AspNetCore.Components.Authorization;
 using Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure;
 using Microsoft.AspNetCore.Components.Infrastructure;
@@ -26,11 +27,14 @@ namespace Microsoft.AspNetCore.Components.Endpoints;
 /// 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.
 /// </summary>
-internal sealed partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrerenderer
+internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrerenderer
 {
     private readonly IServiceProvider _services;
     private Task? _servicesInitializedTask;
 
+    private string? _formHandler;
+    private NamedEvent _capturedNamedEvent;
+
     // 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
     // the subset of those that are from the non-streaming subtrees, since we want the response to
@@ -61,6 +65,92 @@ internal sealed partial class EndpointHtmlRenderer : StaticHtmlRenderer, ICompon
         await componentApplicationLifetime.RestoreStateAsync(new PrerenderComponentApplicationStore());
     }
 
+    internal void SetFormHandlerName(string name)
+    {
+        _formHandler = name;
+    }
+
+    protected override bool ShouldTrackNamedEventHandlers() => _formHandler != null;
+
+    protected override void TrackNamedEventId(ulong eventHandlerId, int componentId, string eventNameId)
+    {
+        if (_formHandler == null || !string.Equals(eventNameId, _formHandler, StringComparison.Ordinal))
+        {
+            // We only track event names when we are deciding how to dispatch an event and when the event
+            // matches the identifier for the event we are trying to dispatch.
+            return;
+        }
+
+        if (_capturedNamedEvent.EventNameId == null)
+        {
+            // This is the first time we see the event being tracked, we capture it.
+            _capturedNamedEvent = new(eventHandlerId, componentId, eventNameId);
+            return;
+        }
+
+        if (_capturedNamedEvent.ComponentId != componentId)
+        {
+            // At this point we have already seen this event once. Once a component instance defines a named
+            // event, that component is the owner of that event name until we dispatch the event.
+            // Dispatching the event happens after we've achieved quiesce.
+            // * No two separate components can define an event with the same name identifier.
+            // * No other component can define the same named event even if the existing registration
+            //   is no longer part of the set of rendered components.
+            //   * This gives customers a clear an easy rule about how forms need to be rendered when you
+            //     want to support handling POST requests.
+            //   * Note that this only affects receiving POST request, it does not impact interactive components
+            //     and it does not impact prerendering components.
+            try
+            {
+                // Two components are trying to simultaneously define the same name.
+                var state = GetComponentState(_capturedNamedEvent.ComponentId);
+                throw new InvalidOperationException(
+                    $@"Two different components are trying to define the same named event '{eventNameId}':
+'{GenerateComponentPath(state)}'
+'{GenerateComponentPath(GetComponentState(componentId))}'");
+            }
+            catch (ArgumentException)
+            {
+                // The component that originally defined the name was disposed.
+                throw new InvalidOperationException(
+                    $"The named event '{eventNameId}' was already defined earlier by a component with id '{_capturedNamedEvent.ComponentId}' but this " +
+                    $"component was removed before receiving the dispatched event.");
+            }
+        }
+    }
+
+    internal bool HasCapturedEvent() => _capturedNamedEvent != default;
+
+    internal Task DispatchCapturedEvent()
+    {
+        if (_capturedNamedEvent == default)
+        {
+            throw new InvalidOperationException($"No named event handler was captured for '{_formHandler}'.");
+        }
+
+        return DispatchEventAsync(_capturedNamedEvent.EventHandlerId, null, EventArgs.Empty, quiesce: true);
+    }
+
+    private static string GenerateComponentPath(ComponentState state)
+    {
+        // We are generating a path from the root component with component type names like:
+        // App > Router > RouteView > LayoutView > Index > PartA
+        // App > Router > RouteView > LayoutView > MainLayout > NavigationMenu
+        // To help developers identify when they have multiple forms with the same handler.
+        Stack<string> stack = new();
+
+        for (var current = state; current != null; current = current.ParentComponentState)
+        {
+            stack.Push(GetName(current));
+        }
+
+        var builder = new StringBuilder();
+        builder.AppendJoin(" > ", stack);
+        return builder.ToString();
+
+        static string GetName(ComponentState current) => current.Component.GetType().Name;
+    }
+
     protected override ComponentState CreateComponentState(int componentId, IComponent component, ComponentState? parentComponentState)
         => new EndpointComponentState(this, componentId, component, parentComponentState);
 
@@ -121,4 +211,6 @@ internal sealed partial class EndpointHtmlRenderer : StaticHtmlRenderer, ICompon
         // it has to end with a trailing slash
         return result.EndsWith('/') ? result : result += "/";
     }
+
+    private record struct NamedEvent(ulong EventHandlerId, int ComponentId, string EventNameId);
 }

+ 314 - 3
src/Components/Endpoints/test/EndpointHtmlRendererTest.cs

@@ -7,6 +7,7 @@ using System.Text.RegularExpressions;
 using Microsoft.AspNetCore.Components.Endpoints.Tests.TestComponents;
 using Microsoft.AspNetCore.Components.Infrastructure;
 using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.AspNetCore.Components.Test.Helpers;
 using Microsoft.AspNetCore.DataProtection;
 using Microsoft.AspNetCore.Html;
 using Microsoft.AspNetCore.Http;
@@ -29,7 +30,7 @@ public class EndpointHtmlRendererTest
     private static readonly IDataProtectionProvider _dataprotectorProvider = new EphemeralDataProtectionProvider();
 
     private readonly IServiceProvider _services = CreateDefaultServiceCollection().BuildServiceProvider();
-    private readonly EndpointHtmlRenderer renderer;
+    private readonly TestEndpointHtmlRenderer renderer;
 
     public EndpointHtmlRendererTest()
     {
@@ -810,6 +811,294 @@ public class EndpointHtmlRendererTest
         Assert.Equal("Loaded", content);
     }
 
+    [Fact]
+    public void Duplicate_NamedEventHandlers_AcrossComponents_Throws()
+    {
+        // Arrange
+        var expectedError = @"Two different components are trying to define the same named event 'default':
+'TestComponent > NamedEventHandlerComponent'
+'TestComponent > OtherNamedEventHandlerComponent'";
+
+        var renderer = GetEndpointHtmlRenderer();
+        renderer.SetFormHandlerName("default");
+
+        var component = new TestComponent(builder =>
+        {
+            builder.OpenComponent<NamedEventHandlerComponent>(0);
+            builder.CloseComponent();
+            builder.OpenComponent<OtherNamedEventHandlerComponent>(1);
+            builder.CloseComponent();
+        });
+
+        // Act
+        var componentId = renderer.TestAssignRootComponentId(component);
+
+        var exception = Assert.Throws<InvalidOperationException>(component.TriggerRender);
+        Assert.Equal(expectedError, exception.Message);
+    }
+
+    [Fact]
+    public void RecreatedComponent_AcrossDifferentBatches_WithNamedEventHandler_Throws()
+    {
+        // Arrange
+        var expectedError = "The named event 'default' was already defined earlier by a component with id '1' but this " +
+            "component was removed before receiving the dispatched event.";
+
+        var renderer = GetEndpointHtmlRenderer();
+        renderer.SetFormHandlerName("default");
+
+        RenderFragment addComponentRender = builder =>
+        {
+            builder.OpenComponent<NamedEventHandlerComponent>(0);
+            builder.CloseComponent();
+        };
+        RenderFragment removeComponentRender = builder =>
+        {
+        };
+
+        var currentRender = addComponentRender;
+        var component = new TestComponent(builder =>
+        {
+            currentRender(builder);
+        });
+
+        // Act
+        var componentId = renderer.TestAssignRootComponentId(component);
+        renderer.Dispatcher.InvokeAsync(component.TriggerRender);
+        currentRender = removeComponentRender;
+        renderer.Dispatcher.InvokeAsync(component.TriggerRender);
+        currentRender = addComponentRender;
+
+        var exception = Assert.Throws<InvalidOperationException>(component.TriggerRender);
+        Assert.Equal(expectedError, exception.Message);
+    }
+
+    [Fact]
+    public void SameComponent_WithNamedEvent_CanRenderSynchronously_MultipleTimes()
+    {
+        // Arrange
+        var renderer = GetEndpointHtmlRenderer();
+        renderer.SetFormHandlerName("default");
+
+        var component = new TestComponent(builder =>
+        {
+            builder.OpenComponent<MultiRenderNamedEventHandlerComponent>(0);
+            builder.CloseComponent();
+        });
+
+        // Act
+        var componentId = renderer.TestAssignRootComponentId(component);
+        renderer.Dispatcher.InvokeAsync(component.TriggerRender);
+
+        // Assert
+        Assert.Equal(2, renderer.TrackedNamedEvents.Count);
+    }
+
+    [Fact]
+    public async Task SameComponent_WithNamedEvent_CanRenderAsynchronously_MultipleTimes()
+    {
+        // Arrange
+        var renderer = GetEndpointHtmlRenderer();
+        renderer.SetFormHandlerName("default");
+        var continueTaskCompletion = new TaskCompletionSource();
+
+        // Act
+        var result = await renderer.Dispatcher.InvokeAsync(() => renderer.BeginRenderingComponent(
+            typeof(MultiAsyncRenderNamedEventHandlerComponent),
+            ParameterView.FromDictionary(new Dictionary<string, object>
+            {
+                [nameof(MultiAsyncRenderNamedEventHandlerComponent.Continue)] = continueTaskCompletion.Task
+            })));
+
+        // Assert
+        continueTaskCompletion.SetResult();
+        await result.QuiescenceTask;
+        Assert.Equal(2, renderer.TrackedNamedEvents.Count);
+    }
+
+    [Fact]
+    public async Task CanDispatchNamedEvent_ToComponent()
+    {
+        // Arrange
+        var renderer = GetEndpointHtmlRenderer();
+        renderer.SetFormHandlerName("default");
+        var continueTaskCompletion = new TaskCompletionSource();
+        var invoked = false;
+        Action handler = () => invoked = true;
+        await renderer.Dispatcher.InvokeAsync(async () =>
+        {
+            var result = renderer.BeginRenderingComponent(
+                typeof(NamedEventHandlerComponent),
+                ParameterView.FromDictionary(new Dictionary<string, object>
+                {
+                    [nameof(NamedEventHandlerComponent.Handler)] = handler
+                }));
+
+            await result.QuiescenceTask;
+
+            // Act
+            await renderer.DispatchCapturedEvent();
+        });
+
+        // Assert
+        Assert.True(invoked);
+    }
+
+    [Fact]
+    public async Task Dispatching_WhenEventHasNotBeenFound_Throws()
+    {
+        // Arrange
+        var renderer = GetEndpointHtmlRenderer();
+        renderer.SetFormHandlerName("other");
+
+        var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
+            renderer.Dispatcher.InvokeAsync(async () =>
+            {
+                var result = renderer.BeginRenderingComponent(typeof(NamedEventHandlerComponent), ParameterView.Empty);
+                await result.QuiescenceTask;
+
+                // Act
+                await renderer.DispatchCapturedEvent();
+            }));
+
+        Assert.Equal("No named event handler was captured for 'other'.", exception.Message);
+    }
+
+    [Fact]
+    public void NamedEventHandlers_DifferentComponents_SameNamedHandlerInDifferentBatches_Throws()
+    {
+        // Arrange
+        var expectedError = "The named event 'default' was already defined earlier by a component with id '1' but this " +
+            "component was removed before receiving the dispatched event.";
+
+        var renderer = GetEndpointHtmlRenderer();
+        renderer.SetFormHandlerName("default");
+
+        RenderFragment addComponentRender = builder =>
+        {
+            builder.OpenComponent<NamedEventHandlerComponent>(0);
+            builder.CloseComponent();
+        };
+
+        RenderFragment removeComponentRender = builder =>
+        {
+        };
+        RenderFragment thirdRender = builder =>
+        {
+            builder.OpenComponent<NamedEventHandlerComponent>(0);
+            builder.CloseComponent();
+        };
+
+        var currentRender = addComponentRender;
+        var component = new TestComponent(builder =>
+        {
+            currentRender(builder);
+        });
+
+        // Act
+        var componentId = renderer.TestAssignRootComponentId(component);
+        renderer.Dispatcher.InvokeAsync(component.TriggerRender);
+        currentRender = removeComponentRender;
+        renderer.Dispatcher.InvokeAsync(component.TriggerRender);
+        currentRender = thirdRender;
+
+        var exception = Assert.Throws<InvalidOperationException>(component.TriggerRender);
+        Assert.Equal(expectedError, exception.Message);
+    }
+
+    private class NamedEventHandlerComponent : ComponentBase
+    {
+        [Parameter]
+        public Action Handler { get; set; }
+
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            builder.OpenElement(0, "form");
+            builder.AddAttribute(1, "onsubmit", Handler ?? (() => { }));
+            builder.SetEventHandlerName("default");
+            builder.CloseElement();
+        }
+    }
+
+    private class MultiRenderNamedEventHandlerComponent : ComponentBase
+    {
+        private bool hasRendered;
+
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            builder.OpenElement(0, "form");
+            if (!hasRendered)
+            {
+                builder.AddAttribute(1, "onsubmit", () => { });
+                builder.SetEventHandlerName("default");
+            }
+            else
+            {
+                builder.AddAttribute(1, "onsubmit", () => { GC.KeepAlive(new object()); });
+                builder.SetEventHandlerName("default");
+            }
+            builder.CloseElement();
+            if (!hasRendered)
+            {
+                hasRendered = true;
+                StateHasChanged();
+            }
+        }
+    }
+
+    private class MultiAsyncRenderNamedEventHandlerComponent : ComponentBase
+    {
+        private bool hasRendered;
+
+        [Parameter] public Task Continue { get; set; }
+
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            builder.OpenElement(0, "form");
+            if (!hasRendered)
+            {
+                builder.AddAttribute(1, "onsubmit", () => { });
+                builder.SetEventHandlerName("default");
+            }
+            else
+            {
+                builder.AddAttribute(1, "onsubmit", () => { GC.KeepAlive(new object()); });
+                builder.SetEventHandlerName("default");
+            }
+            builder.CloseElement();
+        }
+
+        protected override async Task OnInitializedAsync()
+        {
+            await Continue;
+            hasRendered = true;
+        }
+    }
+
+    private class OtherNamedEventHandlerComponent : ComponentBase
+    {
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+        {
+            builder.OpenElement(0, "form");
+            builder.AddAttribute(1, "onsubmit", () => { });
+            builder.SetEventHandlerName("default");
+            builder.CloseElement();
+        }
+    }
+
+    class TestComponent : AutoRenderComponent
+    {
+        private readonly RenderFragment _renderFragment;
+
+        public TestComponent(RenderFragment renderFragment)
+        {
+            _renderFragment = renderFragment;
+        }
+
+        protected override void BuildRenderTree(RenderTreeBuilder builder)
+            => _renderFragment(builder);
+    }
+
     private static string HtmlContentToString(IHtmlAsyncContent result)
     {
         var writer = new StringWriter();
@@ -817,10 +1106,32 @@ public class EndpointHtmlRendererTest
         return writer.ToString();
     }
 
-    private EndpointHtmlRenderer GetEndpointHtmlRenderer(IServiceProvider services = null)
+    private TestEndpointHtmlRenderer GetEndpointHtmlRenderer(IServiceProvider services = null)
     {
         var effectiveServices = services ?? _services;
-        return new EndpointHtmlRenderer(effectiveServices, NullLoggerFactory.Instance);
+        return new TestEndpointHtmlRenderer(effectiveServices, NullLoggerFactory.Instance);
+    }
+
+    private class TestEndpointHtmlRenderer : EndpointHtmlRenderer
+    {
+        public TestEndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory) : base(serviceProvider, loggerFactory)
+        {
+        }
+
+        internal int TestAssignRootComponentId(IComponent component)
+        {
+            return base.AssignRootComponentId(component);
+        }
+
+        protected override void TrackNamedEventId(ulong eventHandlerId, int componentId, string eventNameId)
+        {
+            TrackedNamedEvents.Add(new TrackedNamedEvent(eventHandlerId, componentId, eventNameId));
+            base.TrackNamedEventId(eventHandlerId, componentId, eventNameId);
+        }
+
+        public List<TrackedNamedEvent> TrackedNamedEvents { get; set; } = new List<TrackedNamedEvent>();
+
+        public readonly record struct TrackedNamedEvent(ulong EventHandlerId, int ComponentId, string EventNameId);
     }
 
     private HttpContext GetHttpContext(HttpContext context = null)

+ 4 - 0
src/Components/Endpoints/test/Microsoft.AspNetCore.Components.Endpoints.Tests.csproj

@@ -4,6 +4,10 @@
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
   </PropertyGroup>
 
+  <ItemGroup>
+    <Compile Include="..\..\Shared\test\AutoRenderComponent.cs" Link="TestComponents\AutoRenderComponent.cs" />
+  </ItemGroup>
+
   <ItemGroup>
     <Reference Include="Microsoft.AspNetCore.Components.Endpoints" />
     <Reference Include="Microsoft.AspNetCore.Http" />

+ 4 - 2
src/Components/Endpoints/test/RazorComponentEndpointFactoryTest.cs

@@ -36,8 +36,10 @@ public class RazorComponentEndpointFactoryTest
         Assert.NotNull(endpoint.RequestDelegate);
 
         var methods = Assert.Single(endpoint.Metadata.GetOrderedMetadata<HttpMethodMetadata>());
-        var method = Assert.Single(methods.HttpMethods);
-        Assert.Equal("GET", method);
+        Assert.Collection(methods.HttpMethods,
+            method => Assert.Equal("GET", method),
+            method => Assert.Equal("POST", method)
+            );
     }
 
     [Fact]

+ 20 - 2
src/Components/Samples/BlazorUnitedApp/Pages/Index.razor

@@ -4,6 +4,24 @@
 
 <h1>Hello, world!</h1>
 
-Welcome to your new app.
+@* <CascadingModelBinder Name="Page" Context="pageContext">
+    <CascadingModelBinder Name="Navigation" Context="bindingContext">
+        <EditForm FormHandlerName="Action" method="POST" Model="new object()" OnValidSubmit="Submit">
+            <input type="submit" value="Process form" />
+        </EditForm>
+    </CascadingModelBinder>
+</CascadingModelBinder> *@
 
-<SurveyPrompt Title="How is Blazor working for you?" />
+
+@if (_submitted)
+{
+    <p>Submited.</p>
+}
+
+@code{
+    bool _submitted = false;
+    public void Submit()
+    {
+        _submitted = true;
+    }
+}

+ 14 - 0
src/Components/Shared/test/TestRenderer.cs

@@ -29,8 +29,22 @@ public class TestRenderer : Renderer
         Dispatcher = Dispatcher.CreateDefault();
     }
 
+    protected internal override void TrackNamedEventId(ulong eventHandlerId, int componentId, string eventName)
+    {
+        OnNamedEvent?.Invoke((eventHandlerId, componentId, eventName));
+    }
+
+    protected internal override bool ShouldTrackNamedEventHandlers()
+    {
+        return TrackNamedEventHandlers;
+    }
+
     public override Dispatcher Dispatcher { get; }
 
+    public bool TrackNamedEventHandlers { get; set; }
+
+    public Action<(ulong eventHandlerId, int componentId, string eventName)> OnNamedEvent { get; set; }
+
     public Action OnExceptionHandled { get; set; }
 
     public Action<RenderBatch> OnUpdateDisplay { get; set; }

ファイルの差分が大きいため隠しています
+ 0 - 0
src/Components/Web.JS/dist/Release/blazor.server.js


ファイルの差分が大きいため隠しています
+ 0 - 0
src/Components/Web.JS/dist/Release/blazor.webview.js


+ 58 - 9
src/Components/Web/src/Forms/EditForm.cs

@@ -2,6 +2,7 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Diagnostics;
+using Microsoft.AspNetCore.Components.Binding;
 using Microsoft.AspNetCore.Components.Rendering;
 
 namespace Microsoft.AspNetCore.Components.Forms;
@@ -77,6 +78,20 @@ public class EditForm : ComponentBase
     /// </summary>
     [Parameter] public EventCallback<EditContext> OnInvalidSubmit { get; set; }
 
+    /// <summary>
+    /// Gets the context associated with data bound to the EditContext in this form.
+    /// </summary>
+    [CascadingParameter] public ModelBindingContext? BindingContext { get; set; }
+
+    /// <summary>
+    /// Gets or sets the form name.
+    /// </summary>
+    /// <remarks>
+    /// The <c>name</c> attribute on the <c>form</c> element will default to
+    /// the <see cref="FormHandlerName"/> unless an explicit name is provided.
+    /// </remarks>
+    [Parameter] public string? FormHandlerName { get; set; }
+
     /// <inheritdoc />
     protected override void OnParametersSet()
     {
@@ -119,17 +134,51 @@ public class EditForm : ComponentBase
         // optimizing for the common case where _editContext never changes.
         builder.OpenRegion(_editContext.GetHashCode());
 
-        builder.OpenElement(0, "form");
-        builder.AddMultipleAttributes(1, AdditionalAttributes);
-        builder.AddAttribute(2, "onsubmit", _handleSubmitDelegate);
-        builder.OpenComponent<CascadingValue<EditContext>>(3);
-        builder.AddComponentParameter(4, "IsFixed", true);
-        builder.AddComponentParameter(5, "Value", _editContext);
-        builder.AddComponentParameter(6, "ChildContent", ChildContent?.Invoke(_editContext));
-        builder.CloseComponent();
-        builder.CloseElement();
+        if (FormHandlerName != null)
+        {
+            builder.OpenComponent<CascadingModelBinder>(0);
+            builder.AddComponentParameter(1, nameof(CascadingModelBinder.Name), FormHandlerName);
+            builder.AddComponentParameter(2, nameof(CascadingModelBinder.ChildContent), (RenderFragment<ModelBindingContext>)RenderWithNamedContext);
+            builder.CloseComponent();
+        }
+        else
+        {
+            RenderFormContents(builder, BindingContext);
+        }
 
         builder.CloseRegion();
+
+        RenderFragment RenderWithNamedContext(ModelBindingContext context)
+        {
+            return builder => RenderFormContents(builder, context);
+        }
+
+        void RenderFormContents(RenderTreeBuilder builder, ModelBindingContext? bindingContext)
+        {
+            builder.OpenElement(0, "form");
+            if (!string.IsNullOrEmpty(bindingContext?.Name))
+            {
+                builder.AddAttribute(1, "name", bindingContext.Name);
+            }
+
+            if (!string.IsNullOrEmpty(bindingContext?.BindingContextId))
+            {
+                builder.AddAttribute(2, "action", bindingContext.BindingContextId);
+            }
+
+            builder.AddMultipleAttributes(3, AdditionalAttributes);
+            builder.AddAttribute(4, "onsubmit", _handleSubmitDelegate);
+            if (bindingContext != null)
+            {
+                builder.SetEventHandlerName(bindingContext.Name);
+            }
+            builder.OpenComponent<CascadingValue<EditContext>>(5);
+            builder.AddComponentParameter(6, "IsFixed", true);
+            builder.AddComponentParameter(7, "Value", _editContext);
+            builder.AddComponentParameter(8, "ChildContent", ChildContent?.Invoke(_editContext));
+            builder.CloseComponent();
+            builder.CloseElement();
+        }
     }
 
     private async Task HandleSubmitAsync()

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

@@ -1,6 +1,10 @@
 #nullable enable
 *REMOVED*override Microsoft.AspNetCore.Components.Forms.InputFile.OnInitialized() -> void
 Microsoft.AspNetCore.Components.Forms.InputBase<TValue>.NameAttributeValue.get -> string!
+Microsoft.AspNetCore.Components.Forms.EditForm.BindingContext.get -> Microsoft.AspNetCore.Components.Binding.ModelBindingContext?
+Microsoft.AspNetCore.Components.Forms.EditForm.BindingContext.set -> void
+Microsoft.AspNetCore.Components.Forms.EditForm.FormHandlerName.get -> string?
+Microsoft.AspNetCore.Components.Forms.EditForm.FormHandlerName.set -> 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.HtmlRootComponent
 Microsoft.AspNetCore.Components.HtmlRendering.Infrastructure.StaticHtmlRenderer.StaticHtmlRenderer(System.IServiceProvider! serviceProvider, Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void

+ 338 - 9
src/Components/Web/test/Forms/EditFormTest.cs

@@ -1,14 +1,25 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.ComponentModel;
+using Microsoft.AspNetCore.Components.Binding;
 using Microsoft.AspNetCore.Components.Rendering;
 using Microsoft.AspNetCore.Components.RenderTree;
 using Microsoft.AspNetCore.Components.Test.Helpers;
+using Microsoft.Extensions.DependencyInjection;
 
 namespace Microsoft.AspNetCore.Components.Forms;
 
 public class EditFormTest
 {
+    private TestRenderer _testRenderer = new();
+
+    public EditFormTest()
+    {
+        var services = new ServiceCollection();
+        services.AddSingleton<NavigationManager, TestNavigationManager>();
+        _testRenderer = new(services.BuildServiceProvider());
+    }
 
     [Fact]
     public async Task ThrowsIfBothEditContextAndModelAreSupplied()
@@ -79,6 +90,234 @@ public class EditFormTest
         Assert.Same(editContext, returnedEditContext);
     }
 
+    [Fact]
+    public async Task FormElementNameAndAction_SetToComponentName_WhenFormNameIsProvided()
+    {
+        // Arrange
+        var model = new TestModel();
+        var rootComponent = new TestEditFormHostComponent
+        {
+            Model = model,
+            FormName = "my-form",
+        };
+
+        // Act
+        _ = await RenderAndGetTestEditFormComponentAsync(rootComponent);
+        var attributes = GetFormElementAttributeFrames().ToArray();
+
+        // Assert
+        AssertFrame.Attribute(attributes[0], "name", "my-form");
+        AssertFrame.Attribute(attributes[1], "action", "path?query=value&handler=my-form");
+    }
+
+    [Fact]
+    public async Task FormElementNameAndAction_SetToComponentName_WhenCombiningWithDefaultParentBindingContext()
+    {
+        // Arrange
+        var model = new TestModel();
+        var rootComponent = new TestEditFormHostComponent
+        {
+            Model = model,
+            FormName = "my-form",
+            BindingContext = new ModelBindingContext("", "")
+        };
+
+        // Act
+        _ = await RenderAndGetTestEditFormComponentAsync(rootComponent);
+        var attributes = GetFormElementAttributeFrames().ToArray();
+
+        // Assert
+        AssertFrame.Attribute(attributes[0], "name", "my-form");
+        AssertFrame.Attribute(attributes[1], "action", "path?query=value&handler=my-form");
+    }
+
+    [Fact]
+    public async Task FormElementNameAndAction_SetToCombinedIdentifier_WhenCombiningWithNamedParentBindingContext()
+    {
+        // Arrange
+        var model = new TestModel();
+        var rootComponent = new TestEditFormHostComponent
+        {
+            Model = model,
+            FormName = "my-form",
+            BindingContext = new ModelBindingContext("parent-context", "path?handler=parent-context")
+        };
+
+        // Act
+        _ = await RenderAndGetTestEditFormComponentAsync(rootComponent);
+        var attributes = GetFormElementAttributeFrames().ToArray();
+
+        // Assert
+        AssertFrame.Attribute(attributes[0], "name", "parent-context.my-form");
+        AssertFrame.Attribute(attributes[1], "action", "path?query=value&handler=parent-context.my-form");
+    }
+
+    [Fact]
+    public async Task FormElementNameAndAction_CanBeExplicitlyOverriden()
+    {
+        // Arrange
+        var model = new TestModel();
+        var rootComponent = new TestEditFormHostComponent
+        {
+            Model = model,
+            FormName = "my-form",
+            AdditionalFormAttributes = new Dictionary<string, object>() {
+                ["name"] = "my-explicit-name",
+                ["action"] = "/somewhere/else",
+            },
+            BindingContext = new ModelBindingContext("parent-context", "path?handler=parent-context")
+        };
+
+        // Act
+        _ = await RenderAndGetTestEditFormComponentAsync(rootComponent);
+        var attributes = GetFormElementAttributeFrames().ToArray();
+
+        // Assert
+        AssertFrame.Attribute(attributes[0], "name", "my-explicit-name");
+        AssertFrame.Attribute(attributes[1], "action", "/somewhere/else");
+    }
+
+    [Fact]
+    public async Task FormElementNameAndAction_NotSetOnDefaultBindingContext()
+    {
+        // Arrange
+        var model = new TestModel();
+        var rootComponent = new TestEditFormHostComponent
+        {
+            Model = model,
+            BindingContext = new ModelBindingContext("", ""),
+            SubmitHandler = ctx => { }
+        };
+
+        // Act
+        _ = await RenderAndGetTestEditFormComponentAsync(rootComponent);
+        var attributes = GetFormElementAttributeFrames();
+
+        // Assert
+        var frame = Assert.Single(attributes);
+        AssertFrame.Attribute(frame, "onsubmit");
+    }
+
+    [Fact]
+    public async Task FormElementNameAndAction_NotSetWhenNoFormNameAndNoBindingContext()
+    {
+        // Arrange
+        var model = new TestModel();
+        var rootComponent = new TestEditFormHostComponent
+        {
+            Model = model,
+            SubmitHandler = ctx => { }
+        };
+
+        // Act
+        _ = await RenderAndGetTestEditFormComponentAsync(rootComponent);
+        var attributes = GetFormElementAttributeFrames();
+
+        // Assert
+        var frame = Assert.Single(attributes);
+        AssertFrame.Attribute(frame, "onsubmit");
+    }
+
+    [Fact]
+    public async Task EventHandlerName_NotSetWhenNoBindingContextProvided()
+    {
+        // Arrange
+        var tracker = TrackEventNames();
+
+        var model = new TestModel();
+        var rootComponent = new TestEditFormHostComponent
+        {
+            Model = model,
+            SubmitHandler = ctx => { }
+        };
+
+        // Act
+        _ = await RenderAndGetTestEditFormComponentAsync(rootComponent);
+
+        // Assert
+        Assert.Null(tracker.EventName);
+    }
+
+    [Fact]
+    public async Task EventHandlerName_SetToBindingIdOnDefaultHandler()
+    {
+        // Arrange
+        var tracker = TrackEventNames();
+
+        var model = new TestModel();
+        var rootComponent = new TestEditFormHostComponent
+        {
+            Model = model,
+            BindingContext = new ModelBindingContext("", "")
+        };
+
+        // Act
+        _ = await RenderAndGetTestEditFormComponentAsync(rootComponent);
+
+        // Assert
+        Assert.Equal("", tracker.EventName);
+    }
+
+    [Fact]
+    public async Task EventHandlerName_SetToFormNameWhenFormNameIsProvided()
+    {
+        // Arrange
+        var tracker = TrackEventNames();
+
+        var model = new TestModel();
+        var rootComponent = new TestEditFormHostComponent
+        {
+            Model = model,
+            FormName = "my-form",
+        };
+
+        // Act
+        _ = await RenderAndGetTestEditFormComponentAsync(rootComponent);
+
+        // Assert
+        Assert.Equal("my-form", tracker.EventName);
+    }
+
+    [Fact]
+    public async Task EventHandlerName_SetToFormNameWhenParentBindingContextIsDefault()
+    {
+        // Arrange
+        var tracker = TrackEventNames();
+        var model = new TestModel();
+        var rootComponent = new TestEditFormHostComponent
+        {
+            Model = model,
+            FormName = "my-form",
+            BindingContext = new ModelBindingContext("", "")
+        };
+
+        // Act
+        _ = await RenderAndGetTestEditFormComponentAsync(rootComponent);
+
+        // Assert
+        Assert.Equal("my-form", tracker.EventName);
+    }
+
+    [Fact]
+    public async Task EventHandlerName_SetToCombinedNameWhenParentBindingContextIsNamed()
+    {
+        // Arrange
+        var tracker = TrackEventNames();
+        var model = new TestModel();
+        var rootComponent = new TestEditFormHostComponent
+        {
+            Model = model,
+            FormName = "my-form",
+            BindingContext = new ModelBindingContext("parent-context", "path?handler=parent-context")
+        };
+
+        // Act
+        _ = await RenderAndGetTestEditFormComponentAsync(rootComponent);
+
+        // Assert
+        Assert.Equal("parent-context.my-form", tracker.EventName);
+    }
+
     private static EditForm FindEditFormComponent(CapturedBatch batch)
         => batch.ReferenceFrames
                 .Where(f => f.FrameType == RenderTreeFrameType.Component)
@@ -86,12 +325,60 @@ public class EditFormTest
                 .OfType<EditForm>()
                 .Single();
 
-    private static async Task<EditForm> RenderAndGetTestEditFormComponentAsync(TestEditFormHostComponent hostComponent)
+    private async Task<EditForm> RenderAndGetTestEditFormComponentAsync(TestEditFormHostComponent hostComponent)
     {
-        var testRenderer = new TestRenderer();
-        var componentId = testRenderer.AssignRootComponentId(hostComponent);
-        await testRenderer.RenderRootComponentAsync(componentId);
-        return FindEditFormComponent(testRenderer.Batches.Single());
+        var componentId = _testRenderer.AssignRootComponentId(hostComponent);
+        await _testRenderer.RenderRootComponentAsync(componentId);
+        return FindEditFormComponent(_testRenderer.Batches.Single());
+    }
+
+    private IEnumerable<RenderTreeFrame> GetFormElementAttributeFrames()
+    {
+        var frames = _testRenderer.Batches.Single().ReferenceFrames;
+        var index = frames
+            .Select((frame, index) => (frame, index))
+            .Where(pair => pair.frame.FrameType == RenderTreeFrameType.Element)
+            .Select(pair => pair.index)
+            .Single();
+
+        var attributes = frames
+            .Skip(index + 1)
+            .TakeWhile(f => f.FrameType == RenderTreeFrameType.Attribute);
+
+        return attributes;
+    }
+
+    private int GetComponentFrameIndex()
+    {
+        var frames = _testRenderer.Batches.Single().ReferenceFrames;
+        var frameIndex = frames
+            .Select((frame, index) => (frame, index))
+            .Where(pair => pair.frame.FrameType == RenderTreeFrameType.Component && pair.frame.Component is EditForm)
+            .Select(pair => pair.index)
+            .Single();
+        return frameIndex;
+    }
+
+    private EventHandlerNameTracker TrackEventNames()
+    {
+        var tracker = new EventHandlerNameTracker();
+        _testRenderer.TrackNamedEventHandlers = true;
+        _testRenderer.OnNamedEvent += tracker.Track;
+        return tracker;
+    }
+
+    private class EventHandlerNameTracker
+    {
+        public ulong EventHandlerId { get; private set; }
+
+        public int ComponentId { get; private set; }
+
+        public string EventName { get; private set; }
+
+        internal void Track((ulong, int, string) tuple)
+        {
+            (EventHandlerId, ComponentId, EventName) = tuple;
+        }
     }
 
     class TestModel
@@ -102,14 +389,56 @@ public class EditFormTest
     class TestEditFormHostComponent : AutoRenderComponent
     {
         public EditContext EditContext { get; set; }
+
         public TestModel Model { get; set; }
 
+        public ModelBindingContext BindingContext { get; set; }
+
+        public Action<EditContext> SubmitHandler { get; set; }
+
+        public string FormName { get; set; }
+
+        public Dictionary<string, object> AdditionalFormAttributes { get; internal set; }
+
         protected override void BuildRenderTree(RenderTreeBuilder builder)
         {
-            builder.OpenComponent<EditForm>(0);
-            builder.AddComponentParameter(1, "Model", Model);
-            builder.AddComponentParameter(2, "EditContext", EditContext);
-            builder.CloseComponent();
+            if (BindingContext != null)
+            {
+                builder.OpenComponent<CascadingModelBinder>(0);
+                builder.AddComponentParameter(1, nameof(CascadingModelBinder.Name), BindingContext.Name);
+                builder.AddComponentParameter(3, nameof(CascadingModelBinder.ChildContent), (RenderFragment<ModelBindingContext>)((_) => RenderForm));
+                builder.CloseComponent();
+            }
+            else
+            {
+                RenderForm(builder);
+            }
+
+            void RenderForm(RenderTreeBuilder builder)
+            {
+                builder.OpenComponent<EditForm>(0);
+                // Order here is intentional to make sure that the test fails if we
+                // accidentally capture a parameter in a named property.
+                builder.AddMultipleAttributes(1, AdditionalFormAttributes);
+
+                builder.AddComponentParameter(2, "Model", Model);
+                builder.AddComponentParameter(3, "EditContext", EditContext);
+                if (SubmitHandler != null)
+                {
+                    builder.AddComponentParameter(4, "OnValidSubmit", new EventCallback<EditContext>(null, SubmitHandler));
+                }
+                builder.AddComponentParameter(5, "FormHandlerName", FormName);
+
+                builder.CloseComponent();
+            }
+        }
+    }
+
+    class TestNavigationManager : NavigationManager
+    {
+        public TestNavigationManager()
+        {
+            Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash");
         }
     }
 }

+ 279 - 0
src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTest.cs

@@ -0,0 +1,279 @@
+// 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;
+using System.Net.Http;
+using static System.Net.Mime.MediaTypeNames;
+
+namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests;
+
+public class FormHandlingTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup>>
+{
+    public FormHandlingTest(
+        BrowserFixture browserFixture,
+        BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup> serverFixture,
+        ITestOutputHelper output)
+        : base(browserFixture, serverFixture, output)
+    {
+    }
+
+    public override Task InitializeAsync()
+        => InitializeAsync(BrowserFixture.StreamingContext);
+
+    [Fact]
+    public void CanDispatchToTheDefaultForm()
+    {
+        var dispatchToForm = new DispatchToForm(this)
+        {
+            Url = "forms/default-form",
+            FormCssSelector = "form",
+            ExpectedActionValue = null,
+        };
+        DispatchToFormCore(dispatchToForm);
+    }
+
+    [Fact]
+    public void CanDispatchToNamedForm()
+    {
+        var dispatchToForm = new DispatchToForm(this)
+        {
+            Url = "forms/named-form",
+            FormCssSelector = "form[name=named-form-handler]",
+            ExpectedActionValue = "forms/named-form?handler=named-form-handler",
+        };
+        DispatchToFormCore(dispatchToForm);
+    }
+
+    [Fact]
+    public void CanDispatchToNamedFormNoParentBindingContext()
+    {
+        var dispatchToForm = new DispatchToForm(this)
+        {
+            Url = "forms/named-form-no-form-context",
+            FormCssSelector = "form[name=named-form-handler]",
+            ExpectedActionValue = "forms/named-form-no-form-context?handler=named-form-handler",
+        };
+        DispatchToFormCore(dispatchToForm);
+    }
+
+    [Fact]
+    public void CanDispatchToNamedFormInNestedContext()
+    {
+        var dispatchToForm = new DispatchToForm(this)
+        {
+            Url = "forms/nested-named-form",
+            FormCssSelector = "form[name=\"parent-context.named-form-handler\"]",
+            ExpectedActionValue = "forms/nested-named-form?handler=parent-context.named-form-handler",
+        };
+        DispatchToFormCore(dispatchToForm);
+    }
+
+    [Fact]
+    public void CanDispatchToFormDefinedInNonPageComponent()
+    {
+        var dispatchToForm = new DispatchToForm(this)
+        {
+            Url = "forms/form-defined-inside-component",
+            FormCssSelector = "form",
+            ExpectedActionValue = null,
+        };
+        DispatchToFormCore(dispatchToForm);
+    }
+
+    [Fact]
+    public void CanRenderAmbiguousForms()
+    {
+        var dispatchToForm = new DispatchToForm(this)
+        {
+            Url = "forms/ambiguous-forms",
+            FormCssSelector = "form",
+            ExpectedActionValue = null,
+            DispatchEvent = false
+        };
+        DispatchToFormCore(dispatchToForm);
+    }
+
+    [Fact]
+    public void DispatchingToAmbiguousFormFails()
+    {
+        var dispatchToForm = new DispatchToForm(this)
+        {
+            Url = "forms/ambiguous-forms",
+            FormCssSelector = "form",
+            ExpectedActionValue = null,
+            DispatchEvent = true,
+            SubmitButtonId = "send-second",
+            // This is an error ID on the page chrome shows from a 500.
+            SubmitPassId = "main-frame-error"
+        };
+        DispatchToFormCore(dispatchToForm);
+    }
+
+    [Fact]
+    public void FormWithoutBindingContextDoesNotBind()
+    {
+        var dispatchToForm = new DispatchToForm(this)
+        {
+            Url = "forms/no-form-context-no-op",
+            FormCssSelector = "form",
+            ExpectedActionValue = null,
+            SubmitPassId = "main-frame-error"
+        };
+        DispatchToFormCore(dispatchToForm);
+    }
+
+    [Fact]
+    public void CanDispatchToFormRenderedAsynchronously()
+    {
+        var dispatchToForm = new DispatchToForm(this)
+        {
+            Url = "forms/async-rendered-form",
+            FormCssSelector = "form",
+            ExpectedActionValue = null
+        };
+        DispatchToFormCore(dispatchToForm);
+    }
+
+    [Fact]
+    public void FormThatDisappearsBeforeQuiesceDoesNotBind()
+    {
+        var dispatchToForm = new DispatchToForm(this)
+        {
+            Url = "forms/disappears-before-dispatching",
+            FormCssSelector = "form",
+            ExpectedActionValue = null,
+            SubmitButtonId = "test-send",
+            SubmitPassId = "main-frame-error"
+        };
+        DispatchToFormCore(dispatchToForm);
+    }
+
+    [Fact]
+    public void ChangingComponentsToDispatchBeforeQuiesceDoesNotBind()
+    {
+        var dispatchToForm = new DispatchToForm(this)
+        {
+            Url = "forms/switching-components-does-not-bind",
+            FormCssSelector = "form",
+            ExpectedActionValue = null,
+            SubmitPassId = "main-frame-error"
+        };
+        DispatchToFormCore(dispatchToForm);
+    }
+
+    [Fact]
+    public async Task CanPostFormsWithStreamingRenderingAsync()
+    {
+        GoTo("forms/streaming-rendering/CanPostFormsWithStreamingRendering");
+
+        Browser.Exists(By.Id("ready"));
+        var form = Browser.Exists(By.CssSelector("form"));
+        var actionValue = form.GetDomAttribute("action");
+        Assert.Null(actionValue);
+
+        Browser.Click(By.Id("send"));
+
+        Browser.Exists(By.Id("progress"));
+
+        using var client = new HttpClient() { BaseAddress = _serverFixture.RootUri };
+        var response = await client.PostAsync("subdir/forms/streaming-rendering/complete/CanPostFormsWithStreamingRendering", content: null);
+        response.EnsureSuccessStatusCode();
+
+        Browser.Exists(By.Id("pass"));
+    }
+
+    [Fact]
+    public async Task CanModifyTheHttpResponseDuringEventHandling()
+    {
+        GoTo("forms/modify-http-context/ModifyHttpContext");
+
+        Browser.Exists(By.Id("ready"));
+        var form = Browser.Exists(By.CssSelector("form"));
+        var actionValue = form.GetDomAttribute("action");
+        Assert.Null(actionValue);
+
+        Browser.Click(By.Id("send"));
+
+        Browser.Exists(By.Id("progress"));
+
+        using var client = new HttpClient() { BaseAddress = _serverFixture.RootUri };
+        var response = await client.PostAsync("subdir/forms/streaming-rendering/complete/ModifyHttpContext", content: null);
+        response.EnsureSuccessStatusCode();
+
+        Browser.Exists(By.Id("pass"));
+        var cookie = Browser.Manage().Cookies.GetCookieNamed("operation");
+        Assert.Equal("ModifyHttpContext", cookie.Value);
+    }
+
+    [Fact]
+    public async Task CanHandleFormPostNonStreamingRenderingAsyncHandler()
+    {
+        GoTo("forms/non-streaming-async-form-handler/CanHandleFormPostNonStreamingRenderingAsyncHandler");
+
+        Browser.Exists(By.Id("ready"));
+        var form = Browser.Exists(By.CssSelector("form"));
+        var actionValue = form.GetDomAttribute("action");
+        Assert.Null(actionValue);
+
+        Browser.Click(By.Id("send"));
+
+        await Task.Yield();
+
+        using var client = new HttpClient() { BaseAddress = _serverFixture.RootUri };
+        var response = await client.PostAsync("subdir/forms/streaming-rendering/complete/CanHandleFormPostNonStreamingRenderingAsyncHandler", content: null);
+        response.EnsureSuccessStatusCode();
+
+        Browser.Exists(By.Id("pass"));
+    }
+
+    private void DispatchToFormCore(DispatchToForm dispatch)
+    {
+        GoTo(dispatch.Url);
+
+        Browser.Exists(By.Id(dispatch.Ready));
+        var form = Browser.Exists(By.CssSelector(dispatch.FormCssSelector));
+        var formTarget = form.GetAttribute("action");
+        var actionValue = form.GetDomAttribute("action");
+        Assert.Equal(dispatch.ExpectedTarget, formTarget);
+        Assert.Equal(dispatch.ExpectedActionValue, actionValue);
+
+        if (!dispatch.DispatchEvent)
+        {
+            return;
+        }
+
+        Browser.Click(By.Id(dispatch.SubmitButtonId));
+
+        Browser.Exists(By.Id(dispatch.SubmitPassId));
+    }
+
+    private record struct DispatchToForm()
+    {
+        public DispatchToForm(FormHandlingTest test) : this()
+        {
+            Base = new Uri(test._serverFixture.RootUri, test.ServerPathBase).ToString();
+        }
+
+        public string Base;
+        public string Url;
+        public string Ready = "ready";
+        public string SubmitPassId = "pass";
+        public string FormCssSelector;
+        public string ExpectedActionValue;
+        public string ExpectedTarget => $"{Base}/{ExpectedActionValue ?? Url}";
+
+        public bool DispatchEvent { get; internal set; } = true;
+
+        public string SubmitButtonId { get; internal set; } = "send";
+    }
+
+    private void GoTo(string relativePath)
+    {
+        Navigate($"{ServerPathBase}/{relativePath}");
+    }
+}

+ 3 - 3
src/Components/test/E2ETest/ServerExecutionTests/StreamingRenderingTest.cs → src/Components/test/E2ETest/ServerRenderingTests/StreamingRenderingTest.cs

@@ -1,14 +1,14 @@
 // 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.Components.E2ETest.Infrastructure.ServerFixtures;
 using Microsoft.AspNetCore.E2ETesting;
+using OpenQA.Selenium;
 using TestServer;
 using Xunit.Abstractions;
-using OpenQA.Selenium;
 
-namespace Microsoft.AspNetCore.Components.E2ETests.ServerExecutionTests;
+namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests;
 
 public class StreamingRenderingTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup>>
 {

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

@@ -4,6 +4,8 @@
 using System.Globalization;
 using Components.TestServer.RazorComponents;
 using Components.TestServer.RazorComponents.Pages;
+using Components.TestServer.RazorComponents.Pages.Forms;
+using Components.TestServer.Services;
 
 namespace TestServer;
 
@@ -20,6 +22,8 @@ public class RazorComponentEndpointsStartup
     public void ConfigureServices(IServiceCollection services)
     {
         services.AddRazorComponents();
+        services.AddHttpContextAccessor();
+        services.AddSingleton<AsyncOperationService>();
     }
 
     // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
@@ -42,6 +46,7 @@ public class RazorComponentEndpointsStartup
                 endpoints.MapRazorComponents<RazorComponentsRoot>();
 
                 StreamingRendering.MapEndpoints(endpoints);
+                StreamingRenderingForm.MapEndpoints(endpoints);
             });
         });
     }

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

@@ -0,0 +1,14 @@
+@using Microsoft.AspNetCore.Components.Forms
+<EditForm EditContext="_editContext" method="POST" OnValidSubmit="() => _submitted = true" >
+    <input id="send" type="submit" value="Send" />
+</EditForm>
+
+@if (_submitted)
+{
+    <p id="pass">Form submitted!</p>
+}
+
+@code{
+    bool _submitted = false;
+    EditContext _editContext = new EditContext(new object());
+}

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

@@ -0,0 +1 @@
+@namespace Components.TestServer.RazorComponents

+ 22 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/AmbiguousForms.razor

@@ -0,0 +1,22 @@
+@page "/forms/ambiguous-forms"
+@using Microsoft.AspNetCore.Components.Forms
+
+<h2>Ambiguous forms</h2>
+
+<EditForm EditContext="_editContext" method="POST" OnValidSubmit="() => _submitted = true">
+    <input id="send" type="submit" value="Send" />
+</EditForm>
+
+<EditForm EditContext="_editContext" method="POST" OnValidSubmit="() => _submitted = true">
+    <input id="send-second" type="submit" value="Send" />
+</EditForm>
+
+@if (_submitted)
+{
+    <p id="pass">Form submitted!</p>
+}
+
+@code{
+    bool _submitted = false;
+    EditContext _editContext = new EditContext(new object());
+}

+ 20 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/AsyncRenderedForm.razor

@@ -0,0 +1,20 @@
+@page "/forms/async-rendered-form"
+@using Components.TestServer.RazorComponents
+
+<h2>Async rendered form</h2>
+
+@if (_ready)
+{
+    <ComponentWithFormInside>
+    </ComponentWithFormInside>
+}
+
+@code
+{
+    private bool _ready;
+    protected override async Task OnInitializedAsync()
+    {
+        await Task.Yield();
+        _ready = true;
+    }
+}

+ 18 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/DefaultForm.razor

@@ -0,0 +1,18 @@
+@page "/forms/default-form"
+@using Microsoft.AspNetCore.Components.Forms
+
+<h2>Default form</h2>
+
+<EditForm EditContext="_editContext" method="POST" OnValidSubmit="() => _submitted = true" >
+    <input id="send" type="submit" value="Send" />
+</EditForm>
+
+@if (_submitted)
+{
+    <p id="pass">Form submitted!</p>
+}
+
+@code{
+    bool _submitted = false;
+    EditContext _editContext = new EditContext(new object());
+}

+ 25 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/DisappearingForm.razor

@@ -0,0 +1,25 @@
+@page "/forms/disappears-before-dispatching"
+@using Components.TestServer.RazorComponents
+
+<h2>Form disappears before dispatching</h2>
+
+@if (!_ready)
+{
+    <ComponentWithFormInside>
+    </ComponentWithFormInside>
+}
+
+@* Just here so that the test can dispatch the event. *@
+<form method="post" id="test-form">
+    <input id="test-send" type="submit" value="test-send" />
+</form>
+
+@code
+{
+    private bool _ready;
+    protected override async Task OnInitializedAsync()
+    {
+        await Task.Yield();
+        _ready = true;
+    }
+}

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

@@ -0,0 +1,7 @@
+@page "/forms/form-defined-inside-component"
+@using Components.TestServer.RazorComponents
+
+<h2>Form defined in a sub-component</h2>
+
+<ComponentWithFormInside>
+</ComponentWithFormInside>

+ 19 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/FormOutsideBindingContextNoOps.razor

@@ -0,0 +1,19 @@
+@page "/forms/no-form-context-no-op"
+@using Microsoft.AspNetCore.Components.Forms
+@layout NoFormContextLayout
+
+<h2>Form outside a cascading binding context no-ops</h2>
+
+<EditForm EditContext="_editContext" method="POST" OnValidSubmit="() => _submitted = true" >
+    <input id="send" type="submit" value="Send" />
+</EditForm>
+
+@if (_submitted)
+{
+    <p id="pass">Form submitted!</p>
+}
+
+@code{
+    bool _submitted = false;
+    EditContext _editContext = new EditContext(new object());
+}

+ 42 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/ModifyHttpContextForm.razor

@@ -0,0 +1,42 @@
+@page "/forms/modify-http-context/{OperationId}"
+@attribute [StreamRendering(true)]
+@inject AsyncOperationService AsyncOperation
+@inject NavigationManager Navigation
+@* This is not the recommended way to access the HttpContext in Blazor, this is just for test purposes *@
+@inject IHttpContextAccessor Accessor
+@using Components.TestServer.Services;
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Mvc;
+
+<h2>Event handler sets cookie during form POST</h2>
+
+<EditForm EditContext="_editContext" method="POST" OnValidSubmit="HandleSubmit">
+    <input id="send" type="submit" value="Send" />
+</EditForm>
+
+@if (_submitting)
+{
+    <p id="progress">Form submitting!</p>
+}
+else if (_submitted)
+{
+    <p id="pass">Form submitted!</p>
+}
+
+@code {
+    bool _submitted = false;
+    bool _submitting = false;
+    EditContext _editContext = new EditContext(new object());
+
+    public async Task HandleSubmit()
+    {
+        var id = new Uri(Navigation.Uri).AbsolutePath.Split('/')[^1];
+        _submitting = true;
+        // The response can be accessed before any async work happens.
+        // We might want to provide APIs to control when streaming rendering starts.
+        Accessor.HttpContext.Response.Cookies.Append("operation", id);
+        await AsyncOperation.Start(id);
+        _submitting = false;
+        _submitted = true;
+    }
+}

+ 18 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NamedForm.razor

@@ -0,0 +1,18 @@
+@page "/forms/named-form"
+@using Microsoft.AspNetCore.Components.Forms
+
+<h2>Named form</h2>
+
+<EditForm FormHandlerName="named-form-handler" EditContext="_editContext" method="POST" OnValidSubmit="() => _submitted = true" >
+    <input id="send" type="submit" value="Send" />
+</EditForm>
+
+@if (_submitted)
+{
+    <p id="pass">Form submitted!</p>
+}
+
+@code{
+    bool _submitted = false;
+    EditContext _editContext = new EditContext(new object());
+}

+ 19 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NamedFormContextNoFormContextLayout.razor

@@ -0,0 +1,19 @@
+@page "/forms/named-form-no-form-context"
+@layout NoFormContextLayout
+@using Microsoft.AspNetCore.Components.Forms
+
+<h2>Named form</h2>
+
+<EditForm FormHandlerName="named-form-handler" EditContext="_editContext" method="POST" OnValidSubmit="() => _submitted = true" >
+    <input id="send" type="submit" value="Send" />
+</EditForm>
+
+@if (_submitted)
+{
+    <p id="pass">Form submitted!</p>
+}
+
+@code{
+    bool _submitted = false;
+    EditContext _editContext = new EditContext(new object());
+}

+ 21 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NestedNamedForm.razor

@@ -0,0 +1,21 @@
+@page "/forms/nested-named-form"
+@using Microsoft.AspNetCore.Components.Forms
+
+<h2>Nested named form</h2>
+
+<CascadingModelBinder Name="parent-context" Context="bindingContext">
+    <EditForm FormHandlerName="named-form-handler" EditContext="_editContext" method="POST" OnValidSubmit="() => _submitted = true">
+    <input id="send" type="submit" value="Send" />
+</EditForm>
+
+</CascadingModelBinder>
+
+@if (_submitted)
+{
+    <p id="pass">Form submitted!</p>
+}
+
+@code{
+    bool _submitted = false;
+    EditContext _editContext = new EditContext(new object());
+}

+ 41 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/NonStreamingRenderingForm.razor

@@ -0,0 +1,41 @@
+@page "/forms/non-streaming-async-form-handler/{OperationId}"
+@inject AsyncOperationService AsyncOperation
+@inject NavigationManager Navigation;
+@using Components.TestServer.Services;
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Mvc;
+
+<h2>Non streaming async form</h2>
+
+<EditForm EditContext="_editContext" method="POST" OnValidSubmit="HandleSubmit">
+    <input id="send" type="submit" value="Send" />
+</EditForm>
+
+@if (_submitting)
+{
+    <p id="progress">Form submitting!</p>
+}
+else if (_submitted)
+{
+    <p id="pass">Form submitted!</p>
+}
+
+@code {
+    bool _submitted = false;
+    bool _submitting = false;
+    EditContext _editContext = new EditContext(new object());
+
+    protected override void OnInitialized()
+    {
+        // Due to test issues with threads, just register this task ahead of time.
+        _ = AsyncOperation.Start(new Uri(Navigation.Uri).AbsolutePath.Split('/')[^1]);
+    }
+
+    public async Task HandleSubmit()
+    {
+        _submitting = true;
+        await Task.Delay(5000);
+        _submitting = false;
+        _submitted = true;
+    }
+}

+ 46 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/StreamingRenderingForm.razor

@@ -0,0 +1,46 @@
+@page "/forms/streaming-rendering/{OperationId}"
+@attribute [StreamRendering(true)]
+@inject AsyncOperationService AsyncOperation
+@inject NavigationManager Navigation;
+@using Components.TestServer.Services;
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Mvc;
+
+<h2>Streaming rendering async form</h2>
+
+<EditForm EditContext="_editContext" method="POST" OnValidSubmit="HandleSubmit">
+    <input id="send" type="submit" value="Send" />
+</EditForm>
+
+@if (_submitting)
+{
+    <p id="progress">Form submitting!</p>
+}
+else if (_submitted)
+{
+    <p id="pass">Form submitted!</p>
+}
+
+@code {
+    bool _submitted = false;
+    bool _submitting = false;
+    EditContext _editContext = new EditContext(new object());
+
+    public async Task HandleSubmit()
+    {
+        _submitting = true;
+        await AsyncOperation.Start(new Uri(Navigation.Uri).AbsolutePath.Split('/')[^1]);
+        _submitting = false;
+        _submitted = true;
+    }
+
+    public static void MapEndpoints(IEndpointRouteBuilder endpoints)
+    {
+        endpoints.MapPost(
+            "/forms/streaming-rendering/complete/{operationId}",
+            ([FromServices] AsyncOperationService asyncOperation, [FromRoute] string operationId) =>
+            {
+                asyncOperation.Complete(operationId);
+            });
+    }
+}

+ 27 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Forms/SwitchingDispatchedComponentsDoesNotBind.razor

@@ -0,0 +1,27 @@
+@page "/forms/switching-components-does-not-bind"
+@using Components.TestServer.RazorComponents
+@using Microsoft.AspNetCore.Components.Authorization
+
+<h2>Form component instance changes before dispatching</h2>
+
+@if (!_ready)
+{
+    <ComponentWithFormInside>
+    </ComponentWithFormInside>
+}else
+{
+    <CascadingValue Value="new object()">
+        <ComponentWithFormInside>
+        </ComponentWithFormInside>
+    </CascadingValue>
+}
+
+@code
+{
+    private bool _ready;
+    protected override async Task OnInitializedAsync()
+    {
+        await Task.Yield();
+        _ready = true;
+    }
+}

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

@@ -0,0 +1,2 @@
+@using Components.TestServer.RazorComponents.Shared;
+@layout FormsLayout

+ 17 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Shared/FormsLayout.razor

@@ -0,0 +1,17 @@
+@inherits LayoutComponentBase
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8" />
+    <base href="/subdir/" />
+    <HeadOutlet />
+</head>
+<body>
+    <PageTitle>Razor Components Forms</PageTitle>
+    <h1 id="ready">Form test cases</h1>
+    <CascadingModelBinder>
+        @Body
+    </CascadingModelBinder>
+    <script src="_framework/blazor.web.js" suppress-error="BL9992"></script>
+</body>
+</html>

+ 15 - 0
src/Components/test/testassets/Components.TestServer/RazorComponents/Shared/NoFormContextLayout.razor

@@ -0,0 +1,15 @@
+@inherits LayoutComponentBase
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8" />
+    <base href="/subdir/" />
+    <HeadOutlet />
+</head>
+<body>
+    <PageTitle>Razor Components Forms</PageTitle>
+    <h1 id="ready">Form test cases</h1>
+        @Body
+    <script src="_framework/blazor.web.js" suppress-error="BL9992"></script>
+</body>
+</html>

+ 24 - 0
src/Components/test/testassets/Components.TestServer/Services/AsyncOperationService.cs

@@ -0,0 +1,24 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Concurrent;
+
+namespace Components.TestServer.Services;
+
+public class AsyncOperationService
+{
+    private readonly ConcurrentDictionary<string, TaskCompletionSource> _tasks = new();
+
+    public Task Start(string id)
+    {
+        return _tasks.GetOrAdd(id, (id) => new TaskCompletionSource()).Task;
+    }
+
+    public void Complete(string id)
+    {
+        if (_tasks.TryRemove(id, out var tcs))
+        {
+            tcs.SetResult();
+        }
+    }
+}

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません