Skip to content

Commit 442d656

Browse files
authored
[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.
1 parent b8646ba commit 442d656

File tree

55 files changed

+2722
-140
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+2722
-140
lines changed

AspNetCore.sln

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1778,7 +1778,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Compon
17781778
EndProject
17791779
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}"
17801780
EndProject
1781-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorUnitedApp", "src\Components\Samples\BlazorUnitedApp\BlazorUnitedApp.csproj", "{F5AE525F-F435-40F9-A567-4D5EC3B50D6E}"
1781+
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorUnitedApp", "src\Components\Samples\BlazorUnitedApp\BlazorUnitedApp.csproj", "{F5AE525F-F435-40F9-A567-4D5EC3B50D6E}"
17821782
EndProject
17831783
Global
17841784
GlobalSection(SolutionConfigurationPlatforms) = preSolution

src/Components/Authorization/test/AuthorizeRouteViewTest.cs

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Security.Claims;
55
using Microsoft.AspNetCore.Authorization;
6+
using Microsoft.AspNetCore.Components.Binding;
67
using Microsoft.AspNetCore.Components.Rendering;
78
using Microsoft.AspNetCore.Components.RenderTree;
89
using Microsoft.AspNetCore.Components.Test.Helpers;
@@ -33,8 +34,10 @@ public AuthorizeRouteViewTest()
3334
serviceCollection.AddSingleton<IAuthorizationService>(_testAuthorizationService);
3435
serviceCollection.AddSingleton<NavigationManager, TestNavigationManager>();
3536

36-
_renderer = new TestRenderer(serviceCollection.BuildServiceProvider());
37-
_authorizeRouteViewComponent = new AuthorizeRouteView();
37+
var services = serviceCollection.BuildServiceProvider();
38+
_renderer = new TestRenderer(services);
39+
var componentFactory = new ComponentFactory(new DefaultComponentActivator());
40+
_authorizeRouteViewComponent = (AuthorizeRouteView)componentFactory.InstantiateComponent(services, typeof(AuthorizeRouteView));
3841
_authorizeRouteViewComponentId = _renderer.AssignRootComponentId(_authorizeRouteViewComponent);
3942
}
4043

@@ -63,10 +66,26 @@ public void WhenAuthorized_RendersPageInsideLayout()
6366
edit =>
6467
{
6568
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
66-
AssertFrame.Component<TestPageRequiringAuthorization>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
69+
AssertFrame.Component<CascadingModelBinder>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
6770
},
6871
edit => AssertPrependText(batch, edit, "Layout ends here"));
6972

73+
var cascadingModelBinderDiff = batch.GetComponentDiffs<CascadingModelBinder>().Single();
74+
Assert.Collection(cascadingModelBinderDiff.Edits,
75+
edit =>
76+
{
77+
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
78+
AssertFrame.Component<CascadingValue<ModelBindingContext>>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
79+
});
80+
81+
var cascadingValueDiff = batch.GetComponentDiffs<CascadingValue<ModelBindingContext>>().Single();
82+
Assert.Collection(cascadingValueDiff.Edits,
83+
edit =>
84+
{
85+
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
86+
AssertFrame.Component<TestPageRequiringAuthorization>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
87+
});
88+
7089
// Assert: renders page
7190
var pageDiff = batch.GetComponentDiffs<TestPageRequiringAuthorization>().Single();
7291
Assert.Collection(pageDiff.Edits,
@@ -100,10 +119,26 @@ public void AuthorizesWhenResourceIsSet()
100119
edit =>
101120
{
102121
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
103-
AssertFrame.Component<TestPageRequiringAuthorization>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
122+
AssertFrame.Component<CascadingModelBinder>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
104123
},
105124
edit => AssertPrependText(batch, edit, "Layout ends here"));
106125

126+
var cascadingModelBinderDiff = batch.GetComponentDiffs<CascadingModelBinder>().Single();
127+
Assert.Collection(cascadingModelBinderDiff.Edits,
128+
edit =>
129+
{
130+
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
131+
AssertFrame.Component<CascadingValue<ModelBindingContext>>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
132+
});
133+
134+
var cascadingValueDiff = batch.GetComponentDiffs<CascadingValue<ModelBindingContext>>().Single();
135+
Assert.Collection(cascadingValueDiff.Edits,
136+
edit =>
137+
{
138+
Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
139+
AssertFrame.Component<TestPageRequiringAuthorization>(batch.ReferenceFrames[edit.ReferenceFrameIndex]);
140+
});
141+
107142
// Assert: renders page
108143
var pageDiff = batch.GetComponentDiffs<TestPageRequiringAuthorization>().Single();
109144
Assert.Collection(pageDiff.Edits,
@@ -291,6 +326,8 @@ public void WithoutCascadedAuthenticationState_WrapsOutputInCascadingAuthenticat
291326
component => Assert.IsType<CascadingValue<Task<AuthenticationState>>>(component),
292327
component => Assert.IsAssignableFrom<AuthorizeViewCore>(component),
293328
component => Assert.IsType<LayoutView>(component),
329+
component => Assert.IsType<CascadingModelBinder>(component),
330+
component => Assert.IsType<CascadingValue<ModelBindingContext>>(component),
294331
component => Assert.IsType<TestPageWithNoAuthorization>(component));
295332
}
296333

@@ -322,6 +359,8 @@ public void WithCascadedAuthenticationState_DoesNotWrapOutputInCascadingAuthenti
322359
// further CascadingAuthenticationState
323360
component => Assert.IsAssignableFrom<AuthorizeViewCore>(component),
324361
component => Assert.IsType<LayoutView>(component),
362+
component => Assert.IsType<CascadingModelBinder>(component),
363+
component => Assert.IsType<CascadingValue<ModelBindingContext>>(component),
325364
component => Assert.IsType<TestPageWithNoAuthorization>(component));
326365
}
327366

@@ -424,5 +463,9 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
424463

425464
class TestNavigationManager : NavigationManager
426465
{
466+
public TestNavigationManager()
467+
{
468+
Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash");
469+
}
427470
}
428471
}

src/Components/Components/perf/RenderTreeDiffBuilderBenchmark.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public RenderTreeDiffBuilderBenchmark()
7979
public void ComputeDiff_SingleFormField()
8080
{
8181
builder.ClearStateForCurrentBatch();
82-
var diff = RenderTreeDiffBuilder.ComputeDiff(renderer, builder, 0, original.GetFrames(), modified.GetFrames());
82+
var diff = RenderTreeDiffBuilder.ComputeDiff(renderer, builder, 0, modified.GetFrames(), original.GetFrames(), original.GetNamedEvents());
8383
GC.KeepAlive(diff);
8484
}
8585

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Reflection.Metadata;
5+
using Microsoft.AspNetCore.Components.Binding;
6+
using Microsoft.AspNetCore.Components.Routing;
7+
8+
namespace Microsoft.AspNetCore.Components;
9+
10+
/// <summary>
11+
/// Defines the binding context for data bound from external sources.
12+
/// </summary>
13+
public sealed class CascadingModelBinder : IComponent, IDisposable
14+
{
15+
private RenderHandle _handle;
16+
private ModelBindingContext? _bindingContext;
17+
private bool _hasPendingQueuedRender;
18+
19+
/// <summary>
20+
/// The binding context name.
21+
/// </summary>
22+
[Parameter] public string Name { get; set; } = "";
23+
24+
/// <summary>
25+
/// If true, indicates that <see cref="ModelBindingContext.BindingContextId"/> will not change.
26+
/// This is a performance optimization that allows the framework to skip setting up
27+
/// change notifications. Set this flag only if you will not change
28+
/// <see cref="Name"/> of this context or its parents' context during the component's lifetime.
29+
/// </summary>
30+
[Parameter] public bool IsFixed { get; set; }
31+
32+
/// <summary>
33+
/// Specifies the content to be rendered inside this <see cref="CascadingModelBinder"/>.
34+
/// </summary>
35+
[Parameter] public RenderFragment<ModelBindingContext> ChildContent { get; set; } = default!;
36+
37+
[CascadingParameter] ModelBindingContext? ParentContext { get; set; }
38+
39+
[Inject] private NavigationManager Navigation { get; set; } = null!;
40+
41+
void IComponent.Attach(RenderHandle renderHandle)
42+
{
43+
_handle = renderHandle;
44+
}
45+
46+
Task IComponent.SetParametersAsync(ParameterView parameters)
47+
{
48+
if (_bindingContext == null)
49+
{
50+
// First render
51+
Navigation.LocationChanged += HandleLocationChanged;
52+
}
53+
54+
parameters.SetParameterProperties(this);
55+
if (ParentContext != null && string.IsNullOrEmpty(Name))
56+
{
57+
throw new InvalidOperationException($"Nested binding contexts must define a Name. (Parent context) = '{ParentContext.Name}'.");
58+
}
59+
60+
UpdateBindingInformation(Navigation.Uri);
61+
Render();
62+
63+
return Task.CompletedTask;
64+
}
65+
66+
private void Render()
67+
{
68+
if (_hasPendingQueuedRender)
69+
{
70+
return;
71+
}
72+
_hasPendingQueuedRender = true;
73+
_handle.Render(builder =>
74+
{
75+
_hasPendingQueuedRender = false;
76+
builder.OpenComponent<CascadingValue<ModelBindingContext>>(0);
77+
builder.AddComponentParameter(1, nameof(CascadingValue<ModelBindingContext>.IsFixed), IsFixed);
78+
builder.AddComponentParameter(2, nameof(CascadingValue<ModelBindingContext>.Value), _bindingContext);
79+
builder.AddComponentParameter(3, nameof(CascadingValue<ModelBindingContext>.ChildContent), ChildContent?.Invoke(_bindingContext!));
80+
builder.CloseComponent();
81+
});
82+
}
83+
84+
private void HandleLocationChanged(object? sender, LocationChangedEventArgs e)
85+
{
86+
var url = e.Location;
87+
UpdateBindingInformation(url);
88+
Render();
89+
}
90+
91+
private void UpdateBindingInformation(string url)
92+
{
93+
// BindingContextId: action parameter used to define the handler
94+
// Name: form name and context used to bind
95+
// Cases:
96+
// 1) No name ("")
97+
// Name = "";
98+
// BindingContextId = "";
99+
// <form name="" action="" />
100+
// 2) Name provided
101+
// Name = "my-handler";
102+
// BindingContextId = <<base-relative-uri>>((<<existing-query>>&)|?)handler=my-handler
103+
// <form name="my-handler" action="relative/path?existing=value&handler=my-handler
104+
// 3) Parent has a name "parent-name"
105+
// Name = "parent-name.my-handler";
106+
// BindingContextId = <<base-relative-uri>>((<<existing-query>>&)|?)handler=my-handler
107+
var name = string.IsNullOrEmpty(ParentContext?.Name) ? Name : $"{ParentContext.Name}.{Name}";
108+
var bindingId = string.IsNullOrEmpty(name) ? "" : GenerateBindingContextId(name);
109+
110+
var bindingContext = _bindingContext != null &&
111+
string.Equals(_bindingContext.Name, Name, StringComparison.Ordinal) &&
112+
string.Equals(_bindingContext.BindingContextId, bindingId, StringComparison.Ordinal) ?
113+
_bindingContext : new ModelBindingContext(name, bindingId);
114+
115+
// It doesn't matter that we don't check IsFixed, since the CascadingValue we are setting up will throw if the app changes.
116+
if (IsFixed && _bindingContext != null && _bindingContext != bindingContext)
117+
{
118+
// Throw an exception if either the Name or the BindingContextId changed. Once a CascadingModelBinder has been initialized
119+
// as fixed, it can't change it's name nor its BindingContextId. This can happen in several situations:
120+
// * Component ParentContext hierarchy changes.
121+
// * Technically, the component won't be retained in this case and will be destroyed instead.
122+
// * A parent changes Name.
123+
throw new InvalidOperationException($"'{nameof(CascadingModelBinder)}' 'Name' can't change after initialized.");
124+
}
125+
126+
_bindingContext = bindingContext;
127+
128+
string GenerateBindingContextId(string name)
129+
{
130+
var bindingId = Navigation.ToBaseRelativePath(Navigation.GetUriWithQueryParameter("handler", name));
131+
var hashIndex = bindingId.IndexOf('#');
132+
return hashIndex == -1 ? bindingId : new string(bindingId.AsSpan(0, hashIndex));
133+
}
134+
}
135+
136+
void IDisposable.Dispose()
137+
{
138+
Navigation.LocationChanged -= HandleLocationChanged;
139+
}
140+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Components.Binding;
5+
6+
/// <summary>
7+
/// The binding context associated with a given model binding operation.
8+
/// </summary>
9+
public sealed class ModelBindingContext
10+
{
11+
internal ModelBindingContext(string name, string bindingContextId)
12+
{
13+
ArgumentNullException.ThrowIfNull(name);
14+
ArgumentNullException.ThrowIfNull(bindingContextId);
15+
// We are initializing the root context, that can be a "named" root context, or the default context.
16+
// A named root context only provides a name, and that acts as the BindingId
17+
// A "default" root context does not provide a name, and instead it provides an explicit Binding ID.
18+
// The explicit binding ID matches that of the default handler, which is the URL Path.
19+
if (string.IsNullOrEmpty(name) ^ string.IsNullOrEmpty(bindingContextId))
20+
{
21+
throw new InvalidOperationException("A root binding context needs to provide a name and explicit binding context id or none.");
22+
}
23+
24+
Name = name;
25+
BindingContextId = bindingContextId ?? name;
26+
}
27+
28+
/// <summary>
29+
/// The context name.
30+
/// </summary>
31+
public string Name { get; }
32+
33+
/// <summary>
34+
/// The computed identifier used to determine what parts of the app can bind data.
35+
/// </summary>
36+
public string BindingContextId { get; }
37+
}

src/Components/Components/src/NavigationManagerExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -738,7 +738,7 @@ private static bool TryRebuildExistingQueryFromUri(
738738
var hashStartIndex = uri.IndexOf('#');
739739
hash = hashStartIndex < 0 ? "" : uri.AsSpan(hashStartIndex);
740740

741-
var queryStartIndex = uri.IndexOf('?');
741+
var queryStartIndex = (hashStartIndex > 0 ? uri.AsSpan(0, hashStartIndex) : uri).IndexOf('?');
742742

743743
if (queryStartIndex < 0)
744744
{

src/Components/Components/src/PublicAPI.Unshipped.txt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Components.Binding.ModelBindingContext
3+
Microsoft.AspNetCore.Components.Binding.ModelBindingContext.BindingContextId.get -> string!
4+
Microsoft.AspNetCore.Components.Binding.ModelBindingContext.Name.get -> string!
5+
Microsoft.AspNetCore.Components.CascadingModelBinder
6+
Microsoft.AspNetCore.Components.CascadingModelBinder.CascadingModelBinder() -> void
7+
Microsoft.AspNetCore.Components.CascadingModelBinder.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.Binding.ModelBindingContext!>!
8+
Microsoft.AspNetCore.Components.CascadingModelBinder.ChildContent.set -> void
9+
Microsoft.AspNetCore.Components.CascadingModelBinder.IsFixed.get -> bool
10+
Microsoft.AspNetCore.Components.CascadingModelBinder.IsFixed.set -> void
11+
Microsoft.AspNetCore.Components.CascadingModelBinder.Name.get -> string!
12+
Microsoft.AspNetCore.Components.CascadingModelBinder.Name.set -> void
213
Microsoft.AspNetCore.Components.ComponentBase.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
314
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.Dispatcher! dispatcher) -> System.Threading.Tasks.Task!
415
Microsoft.AspNetCore.Components.RenderHandle.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
516
*REMOVED*Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string! relativeUri) -> System.Uri!
617
Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string? relativeUri) -> System.Uri!
18+
Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.SetEventHandlerName(string! eventHandlerName) -> void
719
Microsoft.AspNetCore.Components.Routing.IScrollToLocationHash
820
Microsoft.AspNetCore.Components.Routing.IScrollToLocationHash.RefreshScrollPositionForHash(string! locationAbsolute) -> System.Threading.Tasks.Task!
921
Microsoft.AspNetCore.Components.Rendering.ComponentState
@@ -39,3 +51,6 @@ override Microsoft.AspNetCore.Components.EventCallback<TValue>.GetHashCode() ->
3951
override Microsoft.AspNetCore.Components.EventCallback<TValue>.Equals(object? obj) -> bool
4052
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.AddPendingTask(Microsoft.AspNetCore.Components.Rendering.ComponentState? componentState, System.Threading.Tasks.Task! task) -> void
4153
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!
54+
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo? fieldInfo, System.EventArgs! eventArgs, bool quiesce) -> System.Threading.Tasks.Task!
55+
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.ShouldTrackNamedEventHandlers() -> bool
56+
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.TrackNamedEventId(ulong eventHandlerId, int componentId, string! eventHandlerName) -> void

src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ public static RenderTreeDiff ComputeDiff(
2323
RenderBatchBuilder batchBuilder,
2424
int componentId,
2525
ArrayRange<RenderTreeFrame> oldTree,
26-
ArrayRange<RenderTreeFrame> newTree)
26+
ArrayRange<RenderTreeFrame> newTree,
27+
Dictionary<string, int>? namedEventIndexes)
2728
{
2829
var editsBuffer = batchBuilder.EditsBuffer;
2930
var editsBufferStartLength = editsBuffer.Count;
@@ -33,6 +34,30 @@ public static RenderTreeDiff ComputeDiff(
3334

3435
var editsSegment = editsBuffer.ToSegment(editsBufferStartLength, editsBuffer.Count);
3536
var result = new RenderTreeDiff(componentId, editsSegment);
37+
38+
// Named event handlers name must be unique globally and stable over the time period we are deciding where to
39+
// dispatch a given named event.
40+
// Once a component has defined a named event handler with a concrete name, no other component instance can
41+
// define a named event handler with that name.
42+
//
43+
// At this stage, we only ensure that the named event handler is unique per component instance, as that,
44+
// combined with the check that the EndpointRenderer does, is enough to ensure the uniqueness and the stability
45+
// of the named event handler over time **globally**.
46+
//
47+
// Tracking and uniqueness are enforced when we are trying to dispatch an event to a named event handler, since in
48+
// any other case we don't actually track the named event handlers. We do this because:
49+
// 1) We don't want to break the user's app if we don't have to.
50+
// 2) We don't have to pay the cost of continously tracking all events all the time to throw.
51+
// That's why raising the error is delayed until we are forced to make a decission.
52+
if (namedEventIndexes != null)
53+
{
54+
foreach (var (name, index) in namedEventIndexes)
55+
{
56+
ref var frame = ref newTree.Array[index];
57+
renderer.TrackNamedEventId(frame.AttributeEventHandlerId, componentId, name);
58+
}
59+
}
60+
3661
return result;
3762
}
3863

0 commit comments

Comments
 (0)