Skip to content

Improve Blazor SSR client-side form validation#67324

Draft
oroztocil wants to merge 11 commits into
mainfrom
oroztocil/validation-client-side-rework
Draft

Improve Blazor SSR client-side form validation#67324
oroztocil wants to merge 11 commits into
mainfrom
oroztocil/validation-client-side-rework

Conversation

@oroztocil

@oroztocil oroztocil commented Jun 19, 2026

Copy link
Copy Markdown
Member

Summary

Reworks the .NET Preview 5 client-side validation implementation for Blazor SSR forms. From a developer's point of view the feature is unchanged (add <DataAnnotationsValidator /> to a static SSR EditForm and validation runs in the browser), but the design underneath is substantially simpler and better layered: validation rules are delivered through a single inert per-form carrier element instead of data-val-* attributes on every input, all render-mode-specific code is out of the core Forms assembly, and the public surface shrinks to a small, purposeful set of types.

Contributes to #51040. Supersedes the Preview 5 implementation.

Background and motivation

Preview 5 shipped an initial implementation that emitted jQuery-unobtrusive-style data-val-* attributes onto every input, generated by a public IClientValidationService living in the core Microsoft.AspNetCore.Components.Forms assembly and read on the client by a DOM scanner. Follow-up architectural review raised three concerns this PR addresses:

  • Render-mode-specific logic (and the public client-validation service) lived in the core Forms assembly, where it does not belong.
  • The data-val-* approach spread validation metadata across many attributes on many elements and coupled the input components to the client-validation wire format.
  • The public API exposed the wire format (an attribute-dictionary service) as the extension point.

What changed

Wire format: one inert carrier element per form

Instead of data-val-* attributes on each input, each static-SSR form emits a single custom element carrying all of the form's rules as JSON in a data-rules attribute:

<blazor-client-validation-data data-rules='{"fields":[{"name":"Input.Email","rules":[{"name":"required","message":"Email is required."},{"name":"email","message":"Enter a valid email address."}]}]}'></blazor-client-validation-data>

Because the payload is in an attribute (not element content), the element renders nothing and needs no CSS to stay hidden. The JSON is encoder-escaped by the serializer and HTML-attribute-encoded by Blazor, so it is safe in the page and round-trips through getAttribute + JSON.parse.

Assembly layering

The feature is split across three assemblies along the existing Endpoints -> Web -> Forms reference direction:

Assembly Responsibility Visibility
Forms The EnableClientValidation flag on DataAnnotationsValidator, and writing the activation signal into EditContext.Properties. No render logic, no dedicated types. public flag only
Web EditForm/InputBase integration: InputBase registers rendered fields into an internal RenderedFieldRegistry; the internal ClientValidationData component emits the carrier; the ClientValidationProvider abstraction is the DI seam. ClientValidationProvider public; rest internal
Endpoints The concrete DataAnnotationsClientValidationProvider, its reflection/MEV cache, the typed descriptors, and the JSON serializer. Maps DataAnnotations attributes to rules for server-validated fields only. ClientValidationRule + IClientValidationAdapter public; rest internal

Input* components are untouched beyond a single gated registration call in InputBase; there is no per-input rule logic anywhere.

The provider returns a RenderFragment

ClientValidationProvider.RenderClientValidationRules(...) returns a RenderFragment? that renders the carrier element (or null when there is nothing to emit). The Web-side ClientValidationData component simply renders that fragment; it never sees the descriptor types or the serializer, both of which are internal to Endpoints. This keeps the wire format fully owned by Endpoints and off the Web (and therefore WebAssembly) surface.

Client ingestion: form-associated custom element

blazor.web.js defines <blazor-client-validation-data> as a form-associated custom element. Its connectedCallback reads data-rules, finds the owning form via attachInternals().form, registers each field with the validation engine, and sets novalidate on the form; disconnectedCallback unregisters. This replaces the Preview 5 DOM scanner (querySelectorAll('[data-val=true]')), which is removed.

How it works

flowchart LR
    subgraph Server["Server (static SSR)"]
        DAV["DataAnnotationsValidator<br/>writes activation flag<br/>keyed by its own type"]
        IB["InputBase<br/>registers rendered field + name<br/>(AssignedRenderMode is null)"]
        CVD["ClientValidationData (internal)<br/>checks activation + provider,<br/>renders provider fragment"]
        Prov["DataAnnotationsClientValidationProvider<br/>DataAnnotations to rules -> JSON<br/>(only server-validated fields)"]
        DAV --> IB --> CVD
        Prov --> CVD
    end

    subgraph HTML["Rendered HTML"]
        Carrier["blazor-client-validation-data<br/>data-rules='{...}'"]
    end

    subgraph Client["blazor.web.js"]
        FACE["Form-associated custom element<br/>connectedCallback / reconcile()"]
        Engine["ValidationEngine + validators<br/>ErrorDisplay + EventManager"]
        FACE --> Engine
    end

    CVD --> Carrier
    Carrier --> FACE
    Engine -->|"blocks invalid submit,<br/>shows messages"| HTML
Loading
  1. DataAnnotationsValidator writes the activation flag into EditContext.Properties[typeof(DataAnnotationsValidator)] when EnableClientValidation is true (the default).
  2. During static SSR only (AssignedRenderMode is null), each InputBase registers its FieldIdentifier and rendered HTML name into a per-form RenderedFieldRegistry.
  3. ClientValidationData (rendered by EditForm after its ChildContent, so it initializes after the inputs register) checks the activation flag, resolves the optional ClientValidationProvider, reads the registry, and renders the provider's fragment.
  4. DataAnnotationsClientValidationProvider builds rules from the model's DataAnnotations attributes and returns a fragment that emits the carrier, emitting client rules only for fields the server would also validate (see Security below).
  5. On the client, the custom element registers the form's fields, suppresses native browser validation, and validates on interaction. Invalid submissions are blocked in the capture phase (including enhanced-navigation submits).

Public API

Public surface after this PR:

// Assembly: Microsoft.AspNetCore.Components.Forms
namespace Microsoft.AspNetCore.Components.Forms;

public class DataAnnotationsValidator // existing type
{
    // Opt a form in/out of client-side validation. Default true.
    public bool EnableClientValidation { get; set; }
}
// Assembly: Microsoft.AspNetCore.Components.Web
namespace Microsoft.AspNetCore.Components.Forms.ClientValidation;

// The DI seam. A host returns a RenderFragment that renders the carrier for the rendered fields,
// or null when there is nothing to emit. Implemented internally by Components.Endpoints.
public abstract class ClientValidationProvider
{
    public abstract RenderFragment? RenderClientValidationRules(
        EditContext editContext,
        IReadOnlyDictionary<FieldIdentifier, string> renderedFields);
}
// Assembly: Microsoft.AspNetCore.Components.Endpoints
namespace Microsoft.AspNetCore.Components.Endpoints.Forms;

// A single client-side validation rule. Rule names match a validator registered on the JS side.
public sealed class ClientValidationRule
{
    public ClientValidationRule(string name, string errorMessage, IReadOnlyDictionary<string, string>? parameters = null);
    public string Name { get; }
    public string ErrorMessage { get; }
    public IReadOnlyDictionary<string, string>? Parameters { get; } // string values on the wire
}

// Opt a custom ValidationAttribute into emitting client-side rules.
public interface IClientValidationAdapter
{
    IEnumerable<ClientValidationRule> GetClientValidationRules(string errorMessage);
}

Everything else is internal: ClientValidationData and RenderedFieldRegistry (Web); ClientValidationFormDescriptor, ClientValidationFieldDescriptor, ClientValidationDataSerializer, ClientValidationCache, and DataAnnotationsClientValidationProvider (Endpoints).

Removed from Preview 5 (superseded): IClientValidationService, the AddClientValidation DI extension, and the old object?-parameter ClientValidationRule (with WithParameter). The Preview 5 types lived in Forms; the reworked feature exposes no client-validation types from Forms at all.

Behavior and scope

  • Zero-config activation. No change for developers: adding <DataAnnotationsValidator /> to a static-SSR EditForm activates client validation. EnableClientValidation="false" opts out.
  • Enhanced navigation is fully supported. Enhanced nav patches the page by DOM morphing, which reuses the carrier element in place (so its connectedCallback does not re-fire) and strips the JS-added novalidate. A per-carrier reconcile(), run on enhancedload, re-applies rules when a reused carrier's payload changed and re-asserts novalidate. Covered by E2E for form-to-form, round-trip, form-to-no-form, and no-form-to-form transitions.
  • Streaming-added inputs are a documented limitation. Inputs added to an already-rendered form by a later streamed batch are not covered by client validation (they still validate on the server). A whole form delivered in a single streamed batch is covered.
  • Security: rules are only emitted for server-validated fields. The provider gates each field on whether the server would validate it on submit (matching the DataAnnotations top-level vs. Minimal-Validation recursive paths), so client validation never gives a false sense of coverage for fields the server ignores.
  • XSS-safe payload. The serializer uses Utf8JsonWriter (default encoder escapes <, >, &, ') combined with Blazor's HTML attribute encoding, so a hostile validation message cannot break out of the carrier element or its attribute.

Testing

  • .NET unit tests: the Endpoints provider (attribute-to-rule mapping, display-name and localization resolution, server-validatable gating, caching), the JSON serializer (shape, params omission, HTML escaping), the ClientValidationData component (renders the provider's fragment only when activated, the registry is populated, and a provider is registered), and InputBase field registration.
  • JS unit tests (Jest): the validators, the form-associated custom element lifecycle and reconcile(), error display, and event handling.
  • End-to-end (Selenium): the ClientValidation suite covers basic validation, all built-in attributes, localization, custom validators, radio groups, multiple forms, enhanced-navigation transitions, formnovalidate, and server-rendered message interop.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant