Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Blazor] Adds support for server side rendered forms (without a body) #47716

Merged
merged 28 commits into from Apr 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
00d7325
Enable handling POST requests
javiercn Apr 11, 2023
bac4bf5
Add DebuggerDisplay to component state
javiercn Apr 12, 2023
55922e7
Set form name
javiercn Apr 12, 2023
ed7c793
Track named event handlers
javiercn Apr 13, 2023
0c9bb3f
Add tests for tracking named event handlers in EndpointHtmlRenderer
javiercn Apr 13, 2023
9ab5011
Define CascadingModelBinder and ModelBindingContext
javiercn Apr 14, 2023
44d4e2a
Add specialized RenderFragment AddComponentParameter overload and rem…
javiercn Apr 14, 2023
9c59e87
Add CascadingModelBinder to RouteView
javiercn Apr 14, 2023
3906d9c
Dispatch forms with streaming rendering
javiercn Apr 14, 2023
4dd9726
More tests
javiercn Apr 14, 2023
b6e4c48
Add E2E test
javiercn Apr 14, 2023
52001ca
Support named forms and hierarchical handlers
javiercn Apr 17, 2023
32f0f4c
Fix tests
javiercn Apr 17, 2023
dbac6ce
Cleanups and fix tests
javiercn Apr 17, 2023
571b1c3
Add additional route view tests
javiercn Apr 17, 2023
4d315ee
CascadingModelBinder, ModelBindingContext and RouteView test
javiercn Apr 17, 2023
37f1a67
CascadingModelBinder updates
javiercn Apr 17, 2023
9b6dc08
Fix tests
javiercn Apr 17, 2023
6d3da0e
Rework cascadingmodelbinder to get rid of BindingContextId
javiercn Apr 18, 2023
9921089
Cascading model binder updates
javiercn Apr 18, 2023
e8ba09f
E2E tests
javiercn Apr 18, 2023
d67a5c4
Update cascading model binder and tests
javiercn Apr 19, 2023
94e3702
Add E2E tests
javiercn Apr 19, 2023
be14184
Cleanup empty lines
javiercn Apr 19, 2023
7d72205
Another test
javiercn Apr 20, 2023
298c178
Additional cleanups
javiercn Apr 20, 2023
5415283
Fix DispatchEvent
javiercn Apr 20, 2023
ed05d7e
Addressed feedback from Mackinnon
javiercn Apr 20, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion AspNetCore.sln
Expand Up @@ -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
Expand Down
51 changes: 47 additions & 4 deletions src/Components/Authorization/test/AuthorizeRouteViewTest.cs
Expand Up @@ -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;
Expand Down Expand Up @@ -33,8 +34,10 @@ public 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);
}

Expand Down Expand Up @@ -63,10 +66,26 @@ public void WhenAuthorized_RendersPageInsideLayout()
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,
Expand Down Expand Up @@ -100,10 +119,26 @@ public void AuthorizesWhenResourceIsSet()
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,
Expand Down Expand Up @@ -291,6 +326,8 @@ public void WithoutCascadedAuthenticationState_WrapsOutputInCascadingAuthenticat
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));
}

Expand Down Expand Up @@ -322,6 +359,8 @@ public void WithCascadedAuthenticationState_DoesNotWrapOutputInCascadingAuthenti
// 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));
}

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

class TestNavigationManager : NavigationManager
{
public TestNavigationManager()
{
Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash");
}
}
}
Expand Up @@ -79,7 +79,7 @@ public 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);
}

Expand Down
140 changes: 140 additions & 0 deletions 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 changes: 37 additions & 0 deletions 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; }
}
Expand Up @@ -738,7 +738,7 @@ private static QueryParameterFormatter<object> GetFormatterFromParameterValueTyp
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)
{
Expand Down
15 changes: 15 additions & 0 deletions 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
Expand Down Expand Up @@ -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
Copy link
Member Author

@javiercn javiercn Apr 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to avoid allocations altogether if we are not interesting on tracking event handlers. We only track them at the time we want to dispatch an event.

We actually want to avoid tracking events altogether when we are not trying to dispatch an event. That limits/eliminates any chance we introduce breaking changes if for example, someone have today multiple forms defined on their app without giving them unique event handler names.

virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.TrackNamedEventId(ulong eventHandlerId, int componentId, string! eventHandlerName) -> void
Expand Up @@ -23,7 +23,8 @@ enum DiffAction { Match, Insert, Delete }
RenderBatchBuilder batchBuilder,
int componentId,
ArrayRange<RenderTreeFrame> oldTree,
ArrayRange<RenderTreeFrame> newTree)
ArrayRange<RenderTreeFrame> newTree,
Dictionary<string, int>? namedEventIndexes)
SteveSandersonMS marked this conversation as resolved.
Show resolved Hide resolved
{
var editsBuffer = batchBuilder.EditsBuffer;
var editsBufferStartLength = editsBuffer.Count;
Expand All @@ -33,6 +34,30 @@ enum DiffAction { Match, Insert, Delete }

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.
javiercn marked this conversation as resolved.
Show resolved Hide resolved
if (namedEventIndexes != null)
{
foreach (var (name, index) in namedEventIndexes)
{
ref var frame = ref newTree.Array[index];
renderer.TrackNamedEventId(frame.AttributeEventHandlerId, componentId, name);
}
}

return result;
}

Expand Down