fix(compiler): never emit an empty pattern attribute
An empty pattern compiles to a regex matching only the empty string, so
every typed value becomes invalid and the form silently refuses to submit
-- no error, no request. @wrnexus/ui's input declares pattern: string = ""
and renders pattern="{pattern}", so every input that did not opt into a
pattern shipped one that could never match. This broke sign-up in a real
app, and only became visible once the dev-server client-module fix let
form enhancements mount at all.
Attributes reach the output through two emitters and both needed it: a
component's interpolated value is baked at render time, so the whole
attribute is now emitted by __wrnOptionalAttr, while a page's static
element is dropped at compile time. Component mounts are excluded, where
the value is a prop being passed down rather than an attribute.
minlength/maxlength/min/max/step/inputmode/accept get the same treatment --
inert when empty, but meaningless too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -138,6 +138,29 @@ function isHtmlBooleanAttribute(name: string): boolean {
|
||||
return HTML_BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
/*
|
||||
* Constraint attributes that must be absent rather than empty. `pattern` is the
|
||||
* dangerous one: an empty pattern compiles to a regex matching only the empty
|
||||
* string, so every typed value becomes invalid and the form silently refuses to
|
||||
* submit. A component declaring `pattern: string = ""` and rendering
|
||||
* `pattern="{pattern}"` therefore breaks every input that did not ask for a
|
||||
* pattern. The rest are inert when empty, but carry no meaning either.
|
||||
*/
|
||||
const OMIT_WHEN_EMPTY_ATTRIBUTES = new Set([
|
||||
"pattern",
|
||||
"minlength",
|
||||
"maxlength",
|
||||
"min",
|
||||
"max",
|
||||
"step",
|
||||
"inputmode",
|
||||
"accept",
|
||||
]);
|
||||
|
||||
function isOmitWhenEmptyAttribute(name: string): boolean {
|
||||
return OMIT_WHEN_EMPTY_ATTRIBUTES.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
const URL_ATTRIBUTES = new Set([
|
||||
"href",
|
||||
"src",
|
||||
@@ -203,9 +226,11 @@ function renderAttr(attr: Attr): string {
|
||||
case "csrText":
|
||||
return "";
|
||||
default:
|
||||
return attr.boolean
|
||||
? ` ${attr.name}`
|
||||
: ` ${attr.name}="${attrEscape(safeAttributeValue(attr.name, attr.value))}"`;
|
||||
if (attr.boolean) return ` ${attr.name}`;
|
||||
// An empty constraint attribute is worse than absent -- see
|
||||
// OMIT_WHEN_EMPTY_ATTRIBUTES.
|
||||
if (attr.value === "" && isOmitWhenEmptyAttribute(attr.name)) return "";
|
||||
return ` ${attr.name}="${attrEscape(safeAttributeValue(attr.name, attr.value))}"`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2505,6 +2530,19 @@ function renderComponentNode(node: ViewNode, ctx: CompCtx): string {
|
||||
isExplicitComponentMount && wholeExpression
|
||||
? `\${__wrnProp(${elementContext.resolveExpr(wholeExpression)})}`
|
||||
: compileAttrValue(a.value, elementContext);
|
||||
|
||||
/*
|
||||
* A constraint attribute whose value is only known at render time (a
|
||||
* component prop defaulting to "") must be decided at render time, so
|
||||
* the whole attribute -- name included -- is emitted by the helper.
|
||||
* Component mounts are excluded: there the value is a prop being passed
|
||||
* down, not an HTML attribute being written.
|
||||
*/
|
||||
if (isOmitWhenEmptyAttribute(a.name) && !isExplicitComponentMount) {
|
||||
if (a.value === "") return "";
|
||||
return `\${__wrnOptionalAttr(${JSON.stringify(a.name)}, \`${compiledValue}\`)}`;
|
||||
}
|
||||
|
||||
const rendered = ` ${a.name}="${compiledValue}"`;
|
||||
|
||||
const referencesState = exprRefsComponentReactiveValue(a.value, ctx);
|
||||
@@ -2880,6 +2918,10 @@ function __wrnHtml(v: unknown): string {
|
||||
);
|
||||
}
|
||||
|
||||
function __wrnOptionalAttr(name: string, value: string): string {
|
||||
return value === "" ? "" : " " + name + '="' + value + '"';
|
||||
}
|
||||
|
||||
function __wrnAttr(v: unknown): string {
|
||||
return String(v == null ? "" : v).replace(
|
||||
/[&<>"]/g,
|
||||
@@ -3046,6 +3088,10 @@ function __wrnHtml(v: unknown): string {
|
||||
);
|
||||
}
|
||||
|
||||
function __wrnOptionalAttr(name: string, value: string): string {
|
||||
return value === "" ? "" : " " + name + '="' + value + '"';
|
||||
}
|
||||
|
||||
function __wrnAttr(v: unknown): string {
|
||||
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) =>
|
||||
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """,
|
||||
|
||||
Reference in New Issue
Block a user