File: Rendering\EndpointHtmlRenderer.EventDispatch.cs
Web Access
Project: src\src\Components\Endpoints\src\Microsoft.AspNetCore.Components.Endpoints.csproj (Microsoft.AspNetCore.Components.Endpoints)
// 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.Rendering;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Globalization;
using System.Linq;
using System.Text;
 
namespace Microsoft.AspNetCore.Components.Endpoints;
 
internal partial class EndpointHtmlRenderer
{
    private readonly Dictionary<(int ComponentId, int FrameIndex), string> _namedSubmitEventsByLocation = new();
    private readonly Dictionary<string, HashSet<(int ComponentId, int FrameIndex)>> _namedSubmitEventsByScopeQualifiedName = new(StringComparer.Ordinal);
 
    internal Task DispatchSubmitEventAsync(string? handlerName, out bool isBadRequest)
    {
        if (string.IsNullOrEmpty(handlerName))
        {
            // This is likely during development if the developer adds <form method=post> without @formname,
            // or in production if someone just does a POST request even though there's no UI to trigger it
            isBadRequest = true;
            return ReturnErrorResponse("The POST request does not specify which form is being submitted. To fix this, ensure <form> elements have a @formname attribute with any unique value, or pass a FormName parameter if using <EditForm>.");
        }
 
        if (!_namedSubmitEventsByScopeQualifiedName.TryGetValue(handlerName, out var locationsForName) || locationsForName.Count == 0)
        {
            // This may happen if you deploy an app update and someone still on the old page submits a form,
            // or if you're dynamically building the UI and the submitted form doesn't exist the next time
            // the page is rendered
            isBadRequest = true;
            return ReturnErrorResponse($"Cannot submit the form '{handlerName}' because no form on the page currently has that name.");
        }
 
        if (locationsForName.Count > 1)
        {
            // We could allow multiple events with the same name, since they are all tracked separately. However
            // this is most likely a mistake on the developer's part so we will consider it an error.
            // This is an internal server error, not a bad request, because the application itself is at fault
            // and needs to find out about it. End users can't trigger this unless the app has a bug.
            throw new InvalidOperationException(CreateMessageForAmbiguousNamedSubmitEvent(handlerName, locationsForName));
        }
 
        isBadRequest = false;
        var frameLocation = locationsForName.Single();
        var eventHandlerId = FindEventHandlerIdForNamedEvent("onsubmit", frameLocation.ComponentId, frameLocation.FrameIndex);
        return eventHandlerId.HasValue
            ? DispatchEventAsync(eventHandlerId.Value, null, EventArgs.Empty, waitForQuiescence: true)
            : Task.CompletedTask;
    }
 
    private string CreateMessageForAmbiguousNamedSubmitEvent(string scopeQualifiedName, IEnumerable<(int ComponentId, int FrameIndex)> locations)
    {
        var sb = new StringBuilder($"There is more than one named submit event with the name '{scopeQualifiedName}'. Ensure named submit events have unique names, or are in scopes with distinct names. The following components use this name:");
 
        foreach (var location in locations)
        {
            sb.Append(CultureInfo.InvariantCulture, $"\n - {GenerateComponentPath(location.ComponentId)}");
        }
 
        return sb.ToString();
    }
 
    private Task ReturnErrorResponse(string detailedMessage)
    {
        _httpContext.Response.StatusCode = 400;
        _httpContext.Response.ContentType = "text/plain";
        return _httpContext.RequestServices.GetService<IHostEnvironment>()?.IsDevelopment() == true
            ? _httpContext.Response.WriteAsync(detailedMessage)
            : Task.CompletedTask;
    }
 
    private void UpdateNamedSubmitEvents(in RenderBatch renderBatch)
    {
        if (renderBatch.NamedEventChanges is { } changes)
        {
            ProcessNamedSubmitEventRemovals(changes);
            ProcessNamedSubmitEventAdditions(changes);
        }
    }
 
    private void ProcessNamedSubmitEventRemovals(ArrayRange<NamedEventChange> changes)
    {
        var changesCount = changes.Count;
        var changesArray = changes.Array;
        for (var i = 0; i < changesCount; i++)
        {
            ref var change = ref changesArray[i];
            if (change.ChangeType == NamedEventChangeType.Removed
                && string.Equals(change.EventType, "onsubmit", StringComparison.Ordinal))
            {
                var location = (change.ComponentId, change.FrameIndex);
                if (_namedSubmitEventsByLocation.Remove(location, out var scopeQualifiedName))
                {
                    var locationsForName = _namedSubmitEventsByScopeQualifiedName[scopeQualifiedName];
                    locationsForName.Remove(location);
                    if (locationsForName.Count == 0)
                    {
                        _namedSubmitEventsByScopeQualifiedName.Remove(scopeQualifiedName);
                    }
                }
            }
        }
    }
 
    private void ProcessNamedSubmitEventAdditions(ArrayRange<NamedEventChange> changes)
    {
        var changesCount = changes.Count;
        var changesArray = changes.Array;
        for (var i = 0; i < changesCount; i++)
        {
            ref var change = ref changesArray[i];
            if (change.ChangeType == NamedEventChangeType.Added
                && string.Equals(change.EventType, "onsubmit", StringComparison.Ordinal))
            {
                if (TryCreateScopeQualifiedEventName(change.ComponentId, change.AssignedName, out var scopeQualifiedName))
                {
                    var locationsForName = GetOrAddNewToDictionary(_namedSubmitEventsByScopeQualifiedName, scopeQualifiedName);
                    var location = (change.ComponentId, change.FrameIndex);
                    if (!locationsForName.Add(location))
                    {
                        // This shouldn't be possible, since each NamedEvent frame innately has a distinct location
                        throw new InvalidOperationException($"A single named submit event is tracked more than once at the same location.");
                    }
 
                    _namedSubmitEventsByLocation.Add(location, scopeQualifiedName);
                }
            }
        }
    }
 
    private static TVal GetOrAddNewToDictionary<TKey, TVal>(Dictionary<TKey, TVal> dictionary, TKey key) where TKey: notnull where TVal: new()
    {
        if (!dictionary.TryGetValue(key, out var value))
        {
            value = new();
            dictionary.Add(key, value);
        }
 
        return value;
    }
 
    private ulong? FindEventHandlerIdForNamedEvent(string eventType, int componentId, int frameIndex)
    {
        var frames = GetCurrentRenderTreeFrames(componentId);
        ref var frame = ref frames.Array[frameIndex];
 
        if (frame.FrameType != RenderTreeFrameType.NamedEvent)
        {
            // This should not be possible, as the system doesn't create a way that the location could be wrong. But if it happens, we want to know.
            throw new InvalidOperationException($"The named value frame for component '{componentId}' at index '{frameIndex}' unexpectedly matches a frame of type '{frame.FrameType}'.");
        }
 
        if (!string.Equals(frame.NamedEventType, eventType, StringComparison.Ordinal))
        {
            // This should not be possible, as currently we are only tracking name-values with the expected name. But if it happens, we want to know.
            throw new InvalidOperationException($"Expected a named value with name '{eventType}' but found the name '{frame.NamedEventType}'.");
        }
 
        for (var i = frameIndex - 1; i >= 0; i--)
        {
            ref var candidate = ref frames.Array[i];
            if (candidate.FrameType == RenderTreeFrameType.Attribute)
            {
                if (candidate.AttributeEventHandlerId > 0 && string.Equals(candidate.AttributeName, eventType, StringComparison.OrdinalIgnoreCase))
                {
                    return candidate.AttributeEventHandlerId;
                }
            }
            else if (candidate.FrameType == RenderTreeFrameType.Element)
            {
                break;
            }
        }
 
        // No match found
        return default;
    }
 
    private string GenerateComponentPath(int componentId)
    {
        // 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 = GetComponentState(componentId); 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;
    }
}