Add JS library for client-side validation for Blazor SSR#66420
Add JS library for client-side validation for Blazor SSR#66420
Conversation
3093d1d to
f15952a
Compare
There was a problem hiding this comment.
Pull request overview
Adds a zero-dependency client-side validation library for Blazor SSR forms (and a standalone bundle) that reads data-val-* attributes, runs built-in validators, and updates the DOM with CSS/ARIA/error summary behavior—along with Jest + Playwright coverage and test fixtures.
Changes:
- Introduces a new validation runtime (scanner/engine/eventing/display) plus 10 built-in validators and a public API for custom validators.
- Hooks lazy initialization into
blazor.web.js(SSR start options + enhanced navigation rescan) and adds a standaloneaspnet-core-validationRollup entry. - Adds Jest unit tests, Playwright integration tests, and HTML fixtures with a minimal fixture server.
Reviewed changes
Copilot reviewed 41 out of 41 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Components/Web.JS/test/Validation/serve-fixtures.mjs | Local fixture + bundle server used by Playwright integration tests |
| src/Components/Web.JS/test/Validation/integration/validation.spec.ts | Playwright integration tests for core validation behaviors |
| src/Components/Web.JS/test/Validation/integration/validation-scenarios.spec.ts | Playwright scenario coverage (timing, radios, dynamic content, etc.) |
| src/Components/Web.JS/test/Validation/fixtures/basic-validation.html | Basic fixture covering required/length/summary/reset |
| src/Components/Web.JS/test/Validation/fixtures/all-validators.html | Fixture for exercising the full built-in validator set |
| src/Components/Web.JS/test/Validation/fixtures/custom-validator.html | Fixture for custom validator registration behavior |
| src/Components/Web.JS/test/Validation/fixtures/dynamic-content.html | Fixture for DOM changes + scanRules() |
| src/Components/Web.JS/test/Validation/fixtures/formnovalidate.html | Fixture for formnovalidate + hidden/disabled skipping |
| src/Components/Web.JS/test/Validation/fixtures/multiple-forms.html | Fixture for multiple independent forms on a page |
| src/Components/Web.JS/test/Validation/fixtures/no-validation.html | Fixture for pages without data-val usage |
| src/Components/Web.JS/test/Validation/fixtures/radio-group.html | Fixture for radio-group required validation |
| src/Components/Web.JS/test/Validation/fixtures/server-rendered-messages.html | Fixture simulating SSR-rendered sibling message cleanup |
| src/Components/Web.JS/test/Validation/fixtures/timing.html | Fixture for lazy-validation/eager-recovery timing rules |
| src/Components/Web.JS/test/Validation/fixtures/aria-advanced.html | Fixture for ARIA behaviors + data-valmsg-replace=false |
| src/Components/Web.JS/test/Validation/DomScanner.test.ts | Jest tests for data-val-* rule parsing |
| src/Components/Web.JS/test/Validation/CoreValidators.test.ts | Jest tests for built-in validators’ semantics |
| src/Components/Web.JS/src/Validation/index.ts | Standalone bundle entry exposing window.__aspnetValidation |
| src/Components/Web.JS/src/Validation/ValidationTypes.ts | Public types for validators, options, and service API |
| src/Components/Web.JS/src/Validation/ValidationService.ts | Factory wiring registry/engine/scanner/events + initial scan |
| src/Components/Web.JS/src/Validation/ValidationEngine.ts | Per-form/element tracking + validation execution + summary update |
| src/Components/Web.JS/src/Validation/DomScanner.ts | Discovery/reconcile + fingerprinting + rule parsing |
| src/Components/Web.JS/src/Validation/DomUtils.ts | DOM helpers (form lookup, msg elements, skip logic) |
| src/Components/Web.JS/src/Validation/EventManager.ts | Document-level submit/reset interception + per-field listeners |
| src/Components/Web.JS/src/Validation/ErrorDisplay.ts | CSS class toggling, message updates, ARIA, summary rendering |
| src/Components/Web.JS/src/Validation/CoreValidators.ts | Registration for the built-in validators |
| src/Components/Web.JS/src/Validation/Validators/Required.ts | Required validator (text/checkbox/radio/select/textarea) |
| src/Components/Web.JS/src/Validation/Validators/StringLength.ts | Length/minlength/maxlength validator |
| src/Components/Web.JS/src/Validation/Validators/Range.ts | Numeric range validator |
| src/Components/Web.JS/src/Validation/Validators/Regex.ts | Regex full-match validator |
| src/Components/Web.JS/src/Validation/Validators/Email.ts | Email format validator |
| src/Components/Web.JS/src/Validation/Validators/Url.ts | URL format validator |
| src/Components/Web.JS/src/Validation/Validators/Phone.ts | Phone number validator |
| src/Components/Web.JS/src/Validation/Validators/CreditCard.ts | Credit card (Luhn) validator |
| src/Components/Web.JS/src/Validation/Validators/EqualTo.ts | Compare/equal-to validator |
| src/Components/Web.JS/src/Validation/Validators/FileExtensions.ts | File extension allow-list validator |
| src/Components/Web.JS/src/Platform/SsrStartOptions.ts | Adds SSR start options for validation configuration |
| src/Components/Web.JS/src/GlobalExports.ts | Exposes Blazor.formValidation on the global API surface |
| src/Components/Web.JS/src/Boot.Web.ts | Lazy init + enhanced navigation rescan integration |
| src/Components/Web.JS/rollup.config.mjs | Adds Rollup entry for aspnet-core-validation bundle |
| src/Components/Web.JS/playwright.config.ts | Playwright config for integration test directory + webServer |
| src/Components/Web.JS/package.json | Adds Playwright dependency + test:integration script |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
| await page.locator('#default-field').press('Control+a'); | ||
| await page.locator('#default-field').press('Backspace'); |
There was a problem hiding this comment.
locator.press('Control+a') is platform-specific and will fail on macOS where select-all is Meta+A. Use a platform-agnostic way to clear the field (e.g., locator.fill('')) so the test behaves consistently across OSes.
| await page.locator('#default-field').press('Control+a'); | ||
| await page.locator('#default-field').press('Backspace'); |
There was a problem hiding this comment.
locator.press('Control+a') is platform-specific and will fail on macOS where select-all is Meta+A. To keep these Playwright tests cross-platform, clear the input using page.fill(selector, '')/locator.fill('') (or another platform-agnostic approach) instead of relying on the select-all shortcut.
Description
This PR adds a zero-dependency client-side form validation JS library for Blazor SSR forms. The companion PR with .NET-side changes (rendering
data-val-*attributes from Blazor components) will follow separately.Contributes to #51040
Companion PR to #66441
What this enables
Blazor SSR forms can get instant, in-browser validation feedback without a server round-trip, matching the experience that interactive Blazor and MVC apps provide today. The .NET model remains the single source of truth for validation rules — the server renders
data-val-*HTML attributes, and this JS library reads them and enforces validation client-side.How it works
flowchart LR subgraph Server["Server (ASP.NET Core)"] Model["<b>Model</b><br/>[Required]<br/>[Range]<br/>etc."] Render["<b>SSR Rendering</b><br/>(Blazor / MVC)"] Model --> Render end subgraph HTML["HTML Document"] Attrs["data-val-required='...'<br/>data-val-range-min='1'<br/>etc."] end subgraph Client["JS Validation Library"] Scan["<b>DomScanner</b><br/>parses rules"] Engine["<b>ValidationEngine</b><br/>runs validators"] Display["<b>ErrorDisplay</b><br/>CSS + ARIA + messages"] Scan --> Engine --> Display end Render -->|"generates<br/>data-val-* attributes"| Attrs Attrs -->|"querySelectorAll<br/>[data-val=true]"| Scan Display -->|"updates DOM"| HTMLdata-val-*HTML attributes derived from .NET validation attributes on the model (e.g.,[Required]→data-val-required="The Name field is required.").data-val="true", parses theirdata-val-*attributes into structured validation rules, and attaches event listeners.change(and oninputafter the first error or submit), running the matching validator function for each rule.setCustomValidity) is also updated, driving:valid/:invalidCSS pseudo-classes.preventDefault+stopPropagationblocks the submit (including Blazor enhanced navigation). Avalidationcompleteevent is dispatched with the validation result.Delivery
The library can be built in two ways from the same codebase:
blazor.web.jsdata-valelements are detectedaspnet-core-validation.jsblazor.web.jsKey features
Lazy initialization —
Blazor.formValidationis only created whenquerySelector('[data-val="true"]')finds validatable fields. Interactive-only apps pay zero cost (no listeners added).Blazor enhanced navigation compatibility — the library listens in capture phase (before enhanced nav's bubble phase). Invalid submissions are blocked via
stopPropagation(). Additionally, when enhanced navigation replaces the DOM, re-scan is triggered viaenhancedloadevent to update validation rules automatically.Built-in validators matching standard .NET
ValidationAttributes:required·length·range·regex·email·url·phone·creditcard·equalto·fileextensionsUX: Lazy validation, eager recovery — fields are validated on
changeby default (configurable). After the first error (or first submit), validation switches toinput(every keystroke) for immediate feedback as the user corrects errors.Accessibility —
aria-invalid,aria-describedby,role="alert",aria-live="assertive"are managed automatically.CSS framework integration via two mechanisms:
setCustomValidity()drives:valid/:invalidpseudo-classes that Bootstrap (was-validated), Tailwind (invalid:variant), and other modern frameworks use natively.Blazor.start():Space-separated values are supported for utility class based frameworks such as Tailwind.
Custom validators supported via public API:
validationcompleteevent with programmatic error access:Future: Async validation extension
The design intentionally supports adding async/deferred validators (e.g., remote endpoint validation) without breaking changes. The
Validatorreturn type can be widened to includePromise<ValidationResult>, and a pending validation tracking with a deferred re-submitting mechanism can be added to run async validators. This can be added in a later PR if wanted.Solution details
graph TD subgraph Entry["Entry Points"] Boot["<b>Boot.Web.ts</b><br/>(Blazor)"] Index["<b>index.ts</b><br/>(Standalone)"] end Factory["createValidationService()"] Boot --> Factory Index --> Factory subgraph Service["<b>ValidationService</b> (public API)"] direction TB API["addValidator()<br/>scanRules()<br/>validateField()<br/>validateForm()"] end Factory --> Service subgraph Internals["Internal Components"] Registry["<b>ValidatorRegistry</b><br/>name to validator fn"] Engine["<b>ValidationEngine</b><br/>per-form + per-element state<br/>runs validators, coordinates UI"] Scanner["<b>DomScanner</b><br/>discovery + reconciliation<br/>fingerprint-based change detection"] Events["<b>EventManager</b><br/>submit/reset interception<br/>lazy validation, eager recovery"] Display["<b>ErrorDisplay</b><br/>CSS classes<br/>ARIA attributes<br/>message elements<br/>summary"] end Service --> Registry Service --> Engine Service --> Scanner Service --> Events Engine --> Display subgraph Validators["Built-in Validators"] V["required / length / range / regex<br/>email / url / phone / creditcard<br/>equalto / fileextensions"] end Registry --> ValidatorsHTML Attribute Protocol
The library reads validation rules from
data-val-*attributes on form elements. This is an established attribute protocol used by ASP.NET for client-side validation. Reusing it means the same server-side attribute generation works for both Blazor SSR and existing MVC/Razor Pages apps.Element attributes
data-val="true"- marks the element as validatable. The library discovers these viaquerySelectorAll.data-val-{rule}="{message}"- defines a validation rule with its error message.data-val-{rule}-{param}="{value}"- provides parameters for the rule (e.g.,data-val-range-min="1").data-val-event="{events}"- overrides the default validation trigger (e.g.,"submit"for submit-only, or"input change"for custom events).Validation message
data-valmsg-for="{name}"- links a message element to a field bynameattribute.data-valmsg-replace="true|false"- controls whether the message element's text content is replaced (defaulttrue) or preserved.data-valmsg-summary="true"- identifies the validation summary container.Validation summary
The summary shows a deduplicated list of all error messages for the form. It toggles between
validation-summary-valid(hidden) andvalidation-summary-errors(visible) CSS classes.Components
Core Types
Validatoris a pure function: receives field value + params, returns pass/fail.ValidationServiceis the public API exposed to developers viaBlazor.formValidationorwindow.__aspnetValidation.ValidatorRegistry
A
Map<string, ValidatorEntry>that maps rule names (e.g.,"required","range") to validator functions. Theset/getAPI is used both internally (for built-in validators) and externally (viaaddValidator).sethas overriding semantics — calling it with an existing name replaces the validator, allowing developers to customize built-in behavior (e.g., replace theemailregex).ValidationEngine
The central coordinator. Manages per-form and per-element state, runs validators, and updates the UI.
Element state tracks:
rules— parsedValidationRule[]fromdata-val-*attributes. Immutable after registration; attribute changes trigger unregister + re-register via DomScanner's fingerprint comparison.triggerEvents—"default","submit", or custom event typesfingerprint— hash of alldata-val*attributes for change detection during re-scancurrentError— the active error message (if any)hasBeenInvalid— enables eager recovery after first errorlistenerController—AbortControllerfor cleaning up event listeners when the element is unregistered or the DOM changesForm state tracks:
trackedElements—Set<ValidatableElement>of all validated fields in the formhasBeenSubmitted— enables input-level validation after first submitKey methods:
validateElement(element)— runs all validators for the element, marks valid/invalid, returns boolean.validateForm(form)— validates all tracked elements, updates summary, focuses first invalid field. ReturnsMap<string, string>(field name → error message) internally; the publicValidationService.validateFormwraps this as a boolean.resetForm(form)— clears all validation state and error display.registerElement/unregisterElement— manages element lifecycle, including ARIA initialization and listener cleanup.Validation loop (
validateElementInternal):The loop uses fail-fast (one error per field) for two reasons: (1) the browser's Constraint Validation API (
setCustomValidity()) accepts only a single error message per element, and (2) showing one error at a time is the standard web form UX — the user fixes it, then sees the next issue.The engine calls
setCustomValidity()on every validated element, which drives the browser's:valid/:invalidCSS pseudo-classes. This is the primary integration mechanism for CSS frameworks — Bootstrap, Tailwind, Pico, and others can read validation state without any library-specific class names.DomScanner
Discovers validatable elements in the DOM and registers them with the engine.
scan(root?)(called internally byscanRules()) performs two phases:Reconcile — iterates tracked elements and unregisters any that are no longer connected, hidden, disabled, or missing
data-val="true". This is essential for Blazor enhanced navigation, which replaces the entire DOM — stale element references must be cleaned up before discovering new ones.Discover — queries
input[data-val="true"], select[data-val="true"], textarea[data-val="true"]within the root. For each candidate:type="hidden"elements (hidden fields shouldn't show validation errors)data-val*attributes. This avoids unnecessary unregister/re-register on re-scan when attributes haven't changed — important for enhanced navigation where the DOM is replaced but the form structure is identical.novalidateon the form to suppress native browser validation bubbles (we usesetCustomValidityto drive:valid/:invalidpseudo-classes, but we don't want the browser's built-in tooltip UI)parseRules(element)reads alldata-val-*attributes and groups them by rule name:data-val-range="Error."→{ ruleName: 'range', errorMessage: 'Error.', params: {} }data-val-range-min="10"→ addsparams.min = '10'to the range ruleEventManager
Manages DOM event listeners for validation triggers and form submission interception.
Per-field listeners (
attachInputListeners):data-val-eventspecifies custom events → listen to those, no gating.data-val-event="submit"→ no per-field listeners (validate only on submit).change(fires on blur-commit for text inputs, immediately for checkboxes/selects).input(every keystroke). This avoids the "everything is red immediately" problem while providing responsive feedback as the user corrects errors.All per-field listeners use
AbortControllersignals so they are automatically cleaned up when the element is unregistered (e.g., DOM replaced by enhanced navigation).Form-level listeners (
attachFormInterceptors):Both listeners use
{ capture: true }ondocument. This is critical for Blazor compatibility: Blazor's enhanced navigation listens in the bubble phase, so our capture-phase handler fires first. When validation fails,stopPropagation()prevents the submit event from reaching enhanced navigation — the form stays on the page with errors instead of being submitted via fetch.submithandler:formnovalidateon the submitter element (HTML spec).data-valelements). Untracked forms pass through untouched.hasBeenSubmitted = true(enables input-level validation for eager recovery).engine.validateForm(form).preventDefault()+stopPropagation()(blocks both native and enhanced submission).validationcompletecustom event with{ detail: { valid, errors } }.resethandler:setTimeoutto clear validation state after the browser resets field values. This is necessary because theresetevent fires before the browser clears the form — without the delay, we'd clear validation state and then immediately see stale values.Custom event:
validationcompleteDispatched on the form element after validation completes. Bubbles to document. The
detailcontains:valid— boolean indicating whether the form passed validation.errors—Map<string, string>mapping field names to their error messages (one error per field, empty map when valid). This provides programmatic access to validation results for developers who need custom error rendering (toasts, modals, framework-specific components) without scraping the DOM.ErrorDisplay
Manages visual feedback: CSS classes, message element content, ARIA attributes, and validation summary.
CSS class names are configurable via the
CssClassNamesinterface:The defaults match the CSS class names established by ASP.NET's existing client validation ecosystem, ensuring backward compatibility. Override via
ValidationOptions.cssClasses(passed tocreateValidationService()or configured inBlazor.start()).Space-separated class names are supported —
addClasses/removeClasseshelpers split on spaces before callingclassList.add/remove.The split enables Tailwind-style multi-class values like'border-red-500 ring-1 ring-red-500'.ARIA management:
aria-invalid="true"andaria-describedbypointing to the message element (auto-generates anidon the message element if one is not already present).role="alert"andaria-live="assertive"for screen reader announcements. These are set at scan time, not on every validation cycle.Server-rendered message cleanup:
Blazor SSR renders one
<div class="validation-message">per error, but only the first hasdata-valmsg-for. When client validation runs, it needs to remove these server-rendered siblings to avoid showing duplicate messages. The cleanup walks sibling elements after the message element and removes any withvalidation-messageclass that lackdata-valmsg-for.Validation summary:
[data-valmsg-summary]within the form.<ul>with<li>elements for each unique error message.summaryError/summaryValidCSS classes.Factory:
createValidationService(options?)The single entry point for both Blazor and standalone. It wires together all internal components:
ValidatorRegistryand registers all 10 built-in validatorsErrorDisplaywith optional CSS class overrides fromValidationOptionsValidationEngine(coordinator) andDomScanner(discovery)EventManagerand attaches document-level submit/reset interceptorsValidationServiceobject with the public APITesting
Playwright tests were added because they test the actual HTML-JS interaction against real browser DOM without the overhead of full Blazor E2E tests (no server needed, sub-second execution). Currently, these run only locally via
npm run test:integrationwith a lightweight fixture server. If we keep these tests, they should be added into the CI pipeline in a follow up PR.