From 30947b0b5e08063dc4c9b7b4af8091e5c54873bf Mon Sep 17 00:00:00 2001 From: Lukasz Lenart Date: Mon, 24 Aug 2026 18:17:28 +0200 Subject: [PATCH 01/25] WW-5695 docs(validation): design HTML5 constraint validation and retire the JS validator Design covering the replacement of the generated JavaScript client-side validator with native HTML5 constraint attributes derived from the action's validators. Governing rule is never to false-reject: a constraint is emitted only when the browser cannot reject input the server would accept. That rules out changing an input's type, since type="number" refuses "1234,50" which locale-aware conversion accepts, and browser email/url regexes diverge from the framework's. Struts only adds constraints safe for the type already present. Also records why WW-2975 is superseded rather than fixed: constraints riding on each input remove the central tagNames list its root cause depends on. Covers WW-5694 (deprecate, 7.4.0), WW-5695 (constraints, 7.4.0) and WW-5696 (remove, 8.0.0). The implementation plan drawn from this covers 7.4.0 only. Co-Authored-By: Claude Opus 5 --- ...8-24-html5-constraint-validation-design.md | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md diff --git a/docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md b/docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md new file mode 100644 index 0000000000..58ed7f93b3 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-html5-constraint-validation-design.md @@ -0,0 +1,344 @@ +# HTML5 constraint validation, and retiring the JavaScript client-side validator + +**Tickets:** not yet filed — three proposed, see "Ticket structure" +**Supersedes:** [WW-2975](https://issues.apache.org/jira/browse/WW-2975) (to be closed Won't Fix) +**Target:** 7.4.0 (deprecate + new feature), 8.0.0 (remove) +**Date:** 2026-08-24 +**Status:** Design approved, pending implementation plan + +**Plan scope:** the implementation plan drawn from this spec covers the **7.4.0 work only** — the deprecation +and the new constraint feature. The 8.0.0 removal is specified here so the deprecation is written against a +known endpoint, but it is a separate ticket, a separate release and a separate plan. + +## Problem + +`xhtml/form-close-validate.ftl` generates a `validateForm_()` function by iterating +`attributes.tagNames`. That list is seeded by `Form.evaluateExtraParams` (`Form.java:207`) and appended to +by `UIBean.evaluateParams` (`UIBean.java:834`) — but **only** when `findAncestor(Form.class)` finds the form +on the component stack. + +Any input that reaches the form's markup by another route is therefore invisible to client-side validation: +raw HTML, a custom tag, an included fragment, or a component handed pre-rendered field markup. The generated +function is then an empty shell: + +```js +function validateForm_doubleValidationAction() { + ... + var errors = false; + var continueValidation = true; + + return !errors; +} +``` + +This is WW-2975, reported 2009-01-30 against 2.1.6 and reproduced unchanged on `main` (7.4.0-SNAPSHOT) on +2026-08-24. The reproduction renders a form whose action declares a `double` validator for +`myUpDownSelectTag`: a nested `` produces the `if (form.elements['myUpDownSelectTag'])` +block, while an identical raw `` produces nothing at all. + +The ticket's second complaint is also live, at LOW confidence (the repo has **no** JavaScript test +infrastructure — no `package.json`, nothing). `validation.js` `addErrorXHTML` walks `row.parentNode` up +until it finds a `TR`. A field with no `TR` ancestor sends that walk off the top of the document, and the +handler is `catch (err) { alert(err) }` — so the user gets a raw JavaScript error instead of a validation +message. + +### Why this is not being fixed in place + +The feature is legacy on every axis. It exists only in `xhtml` (the default theme) and `css_xhtml` +(`parent = xhtml`); the `html5` theme has `parent = simple` and never had it. It supports eight validators. +It reports errors by inserting `` elements, so it only works with the table layout. It has zero test +coverage beyond four golden files. + +The successor is native HTML5 constraint validation, emitted per field. That dissolves the root cause rather +than patching it: with constraints riding on each ``, there is no central field list for a foreign +field to be missing from. + +## Goals + +- Emit HTML5 constraint attributes from the `html5` theme, derived from the action's validators. +- Never emit a constraint the browser would enforce more strictly than the server does. +- Deprecate the JavaScript client-side validator in 7.4.0, remove it in 8.0.0. +- Change nothing about existing renderings on upgrade to 7.4.0. + +## Non-goals + +- Fixing the `tagNames` scope or `addError` fragility in the deprecated path. It is documented as a known + limitation and deleted in 8.0.0. +- Shipping any JavaScript. Struts emits messages as `data-*` attributes and stops there. +- Changing an input's `type` attribute. See "The never-change-the-type rule". +- Constraint support in `xhtml`, `css_xhtml` or `simple`. This is an `html5` theme feature. +- Touching `struts.ui.theme`'s default. `xhtml` remains the default theme in 7.4.0. + +## Approach + +Derive constraints in Java from the action's validators, behind a swappable container bean, and render them +from a single new `html5` template include. + +### The never-change-the-type rule + +`min`/`max` are inert on `type="text"`; they only apply to `number`, `range` and the temporal types. Emitting +them therefore means switching the input to `type="number"` — and that **is** a false rejection: a browser +`type="number"` refuses `1234,50`, which Struts' locale-aware conversion accepts in a comma-decimal locale. +The same argument rules out `type="email"` and `type="url"`, whose browser regexes differ from +`EmailValidator` and `URLValidator`. + +So: **Struts never sets or changes `type`. It only adds constraints that are safe for whatever type is +already there.** A developer who writes `type="number"` has accepted that widget's semantics, and `min`/`max` +become pure additions. + +### Mapping table + +| Validator | Emits | Condition | +|---|---|---| +| `required` | `required` | always | +| `requiredstring` | `required` | always — server is stricter on whitespace-only input, which is safe | +| `stringlength` | `minlength` / `maxlength` | only when `trim="false"` | +| `regex` | `pattern` | only when `caseSensitive="true"` **and** the regex is ECMAScript-safe | +| `int`, `short`, `long` | `min` / `max` | only when the control is already numeric | +| `double` | `min` / `max` | only when the control is already numeric | +| `date` | `min` / `max` | only when the control is already temporal | +| `email`, `url` | — | never; browser regexes diverge from Struts' | +| `creditcard`, `fieldexpression`, `expression`, `conversion`, visitor | — | no safe mapping | + +`stringlength` with `trim="true"` is excluded because the server measures the *trimmed* value: a +`maxlength` derived from it would stop the user typing input the server would have accepted. + +`RegexFieldValidator` uses `matcher.matches()`, so it is fully anchored and matches HTML5 `pattern` +semantics. The divergence is syntactic, not positional. + +### ECMAScript-safe regex detection + +This is the least-solved part of the design and the strongest argument for the provider being swappable. + +A denylist of Java-only constructs (`\p{Alpha}`, possessive quantifiers, `\A`/`\z`, lookbehind) violates the +never-false-reject rule the first time it misses one: a missed construct becomes a `pattern` the browser +interprets differently. So detection is an **allowlist** — literals, `\d \w \s` and their negations, +character classes without POSIX or Unicode property syntax, grouping, alternation, anchors, and bounded +quantifiers. Anything outside it emits no `pattern`. + +This is conservative to the point that some legitimate regexes will silently get no client-side check. That +is the correct failure direction under the agreed rule, and it is the piece most likely to need tuning after +real use. + +## Components + +### `HtmlControlType` (new enum) + +The provider's real question is not "what string is in `type`" but "which constraint attributes are legal on +this control". `textarea` and `select` have no `type` attribute at all yet do accept `required`, so the enum +models the *control*, not the attribute — hence `HtmlControlType`, not `HtmlInputType`. + +```java +public enum HtmlControlType { + TEXT, SEARCH, TEL, PASSWORD, EMAIL, URL, + NUMBER, RANGE, + DATE, MONTH, WEEK, TIME, DATETIME_LOCAL, + CHECKBOX, RADIO, FILE, HIDDEN, SELECT, + TEXTAREA, + OTHER; + + public static HtmlControlType from(String type); + public boolean supportsPattern(); // text-entry only + public boolean supportsLength(); // text-entry + TEXTAREA + public boolean supportsRange(); // numeric + temporal +} +``` + +`from()` must never throw. `type` is an OGNL-evaluated tag attribute, so at runtime it can be any string — a +typo, or an input type newer than this enum. Unknown and `null` normalise to `OTHER`, which supports nothing, +so an unrecognised type degrades to emitting no constraints. The conservative default falls out for free. + +The enum appears in the public signature of an extension point. Adding members later stays binary- and +source-compatible for callers, but can make an exhaustive `switch` in a custom provider non-exhaustive. That +belongs in the release notes. + +### `HtmlConstraintProvider` (new interface) and `StrutsHtmlConstraintProvider` + +```java +public interface HtmlConstraintProvider { + Map constraintsFor(List validators, HtmlControlType control); +} +``` + +Named per the project convention of `Struts*` for default implementations rather than `Default*`. Registered +**once** in `struts-beans.xml` as `type="...HtmlConstraintProvider" name="struts"`, following the +`UrlRenderer` model — a bean registered under two types builds two instances, which is not wanted here. + +The default implementation encodes the mapping table. Because the agreed policy is deliberately restrictive, +the swappable bean is how applications that want `type="email"` or best-effort `pattern` get served. + +### `StrutsConstants.STRUTS_UI_HTML5_CONSTRAINTS` + +`"struts.ui.html5.constraints"`, following the `struts.ui.checkbox.submitUnchecked` naming precedent. +Defaults to `false` in `default.properties` for 7.4.0 and `true` for 8.0.0. + +Off by default matters: the `html5` theme shipped in 7.2.x, so emitting `required` on upgrade would start +blocking submits on forms that render unchanged today. The 8.0.0 flip lands in a major with a migration entry. + +### `UIBean` hook + +`UIBean.evaluateParams` already resolves `final Form form = findAncestor(Form.class)` and appends to +`tagNames`. Immediately after that block, when the constant is on and a form was found: + +``` +form.getFieldValidators(translatedName) + → provider.constraintsFor(validators, HtmlControlType.from(type)) + → addParameter("constraints", map) +``` + +Gating the *computation* on the constant keeps the cost at zero when off. Themes that do not render +`attributes.constraints` simply ignore it. + +### `Form.getFieldValidators(String)` (new) + +`Form.getValidators(String)` re-runs the action-mapping lookup and +`actionValidatorManager.getValidators(actionClass, actionName, methodName)` on every call, so a 20-field form +would do 20 full lookups. `getFieldValidators` resolves the action's validator list **once**, memoises it on +the form's attributes, and filters by field name per call. + +The existing `getValidators(String)` stays untouched for the deprecated `form-close-validate.ftl` and is +deleted with it in 8.0.0. + +### `html5/constraints.ftl` (new), included from `common-attributes.ftl` + +```freemarker +<#if attributes.constraints??><#list attributes.constraints as k, v> ${k}="${v?html}"<#rt/> +``` + +Including it from `common-attributes.ftl` means every `html5` input picks it up without per-template edits. + +`constraintsFor` returns the full set of attributes to render, not only constraints — messages ride the same +map as `data-msg-` entries: `data-msg-required`, `data-msg-stringlength`, `data-msg-regex`. +The text comes from `validator.getMessage(action)`, which resolves through `DelegatingValidatorContext` and +`textProviderFactory`, so it is properly i18n'd. + +A `data-msg-*` entry is emitted for **every** validator carrying a message, including those that produce no +constraint. An `email` validator therefore contributes `data-msg-email` and nothing else — which is exactly +the case where an application most needs the message, since Struts could not express the rule natively. +Struts ships nothing that consumes these attributes. + +### Deliberately unchanged + +`requiredLabel` keeps meaning "draw a `*` next to the label". It never produces a `required` attribute — only +a `required` *validator* does. Conflating the two is the most likely regression in this work. + +## Deprecation and removal + +`validate="true"` is not one feature but four edits to the rendered form: + +- `xhtml/form-validate.ftl` injects the `validation.js` `