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:
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||||
// WRN editor compiler source hash: 4ba6ddaf22c0a33fa1c2a108a97f077a69c0a46de9fe031c0a00e38863fab613
|
// WRN editor compiler source hash: 3dd8f4ba41eb3b4086c63e530a5daa985c1591e76e23bdd0fc47c87eefe5a4a6
|
||||||
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||||
// Generated with TypeScript: 6.0.3
|
// Generated with TypeScript: 6.0.3
|
||||||
const __nodeRequire = require;
|
const __nodeRequire = require;
|
||||||
@@ -1017,6 +1017,27 @@ const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
|||||||
function isHtmlBooleanAttribute(name) {
|
function isHtmlBooleanAttribute(name) {
|
||||||
return HTML_BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
|
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) {
|
||||||
|
return OMIT_WHEN_EMPTY_ATTRIBUTES.has(name.toLowerCase());
|
||||||
|
}
|
||||||
const URL_ATTRIBUTES = new Set([
|
const URL_ATTRIBUTES = new Set([
|
||||||
"href",
|
"href",
|
||||||
"src",
|
"src",
|
||||||
@@ -1078,9 +1099,13 @@ function renderAttr(attr) {
|
|||||||
case "csrText":
|
case "csrText":
|
||||||
return "";
|
return "";
|
||||||
default:
|
default:
|
||||||
return attr.boolean
|
if (attr.boolean)
|
||||||
? ` ${attr.name}`
|
return ` ${attr.name}`;
|
||||||
: ` ${attr.name}="${attrEscape(safeAttributeValue(attr.name, attr.value))}"`;
|
// 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))}"`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function eventAttribute(name) {
|
function eventAttribute(name) {
|
||||||
@@ -2964,6 +2989,18 @@ function renderComponentNode(node, ctx) {
|
|||||||
const compiledValue = isExplicitComponentMount && wholeExpression
|
const compiledValue = isExplicitComponentMount && wholeExpression
|
||||||
? `\${__wrnProp(${elementContext.resolveExpr(wholeExpression)})}`
|
? `\${__wrnProp(${elementContext.resolveExpr(wholeExpression)})}`
|
||||||
: compileAttrValue(a.value, elementContext);
|
: 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 rendered = ` ${a.name}="${compiledValue}"`;
|
||||||
const referencesState = exprRefsComponentReactiveValue(a.value, ctx);
|
const referencesState = exprRefsComponentReactiveValue(a.value, ctx);
|
||||||
const referencesLoopVariable = elementContext.loopVars
|
const referencesLoopVariable = elementContext.loopVars
|
||||||
@@ -3264,6 +3301,10 @@ function __wrnHtml(v: unknown): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function __wrnOptionalAttr(name: string, value: string): string {
|
||||||
|
return value === "" ? "" : " " + name + '="' + value + '"';
|
||||||
|
}
|
||||||
|
|
||||||
function __wrnAttr(v: unknown): string {
|
function __wrnAttr(v: unknown): string {
|
||||||
return String(v == null ? "" : v).replace(
|
return String(v == null ? "" : v).replace(
|
||||||
/[&<>"]/g,
|
/[&<>"]/g,
|
||||||
@@ -3415,6 +3456,9 @@ function wholeAttributeExpression(value) {
|
|||||||
function __wrnHtml(v) {
|
function __wrnHtml(v) {
|
||||||
return String(v == null ? "" : v).replace(/[&<>]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : ">");
|
return String(v == null ? "" : v).replace(/[&<>]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : ">");
|
||||||
}
|
}
|
||||||
|
function __wrnOptionalAttr(name, value) {
|
||||||
|
return value === "" ? "" : " " + name + '="' + value + '"';
|
||||||
|
}
|
||||||
function __wrnAttr(v) {
|
function __wrnAttr(v) {
|
||||||
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """);
|
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// WRN editor extension source hash: 6c652a0d490112180d2167de7a6bbc8ba179343ee313c4b51812958bcd941b83
|
// WRN editor extension source hash: 59ff54353ed4f50aa53f3731e232726d55549936b2edf8958beb407cc4fdf9d2
|
||||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||||
"use strict";
|
"use strict";
|
||||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||||
|
|||||||
@@ -138,6 +138,29 @@ function isHtmlBooleanAttribute(name: string): boolean {
|
|||||||
return HTML_BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
|
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([
|
const URL_ATTRIBUTES = new Set([
|
||||||
"href",
|
"href",
|
||||||
"src",
|
"src",
|
||||||
@@ -203,9 +226,11 @@ function renderAttr(attr: Attr): string {
|
|||||||
case "csrText":
|
case "csrText":
|
||||||
return "";
|
return "";
|
||||||
default:
|
default:
|
||||||
return attr.boolean
|
if (attr.boolean) return ` ${attr.name}`;
|
||||||
? ` ${attr.name}`
|
// An empty constraint attribute is worse than absent -- see
|
||||||
: ` ${attr.name}="${attrEscape(safeAttributeValue(attr.name, attr.value))}"`;
|
// 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
|
isExplicitComponentMount && wholeExpression
|
||||||
? `\${__wrnProp(${elementContext.resolveExpr(wholeExpression)})}`
|
? `\${__wrnProp(${elementContext.resolveExpr(wholeExpression)})}`
|
||||||
: compileAttrValue(a.value, elementContext);
|
: 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 rendered = ` ${a.name}="${compiledValue}"`;
|
||||||
|
|
||||||
const referencesState = exprRefsComponentReactiveValue(a.value, ctx);
|
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 {
|
function __wrnAttr(v: unknown): string {
|
||||||
return String(v == null ? "" : v).replace(
|
return String(v == null ? "" : v).replace(
|
||||||
/[&<>"]/g,
|
/[&<>"]/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 {
|
function __wrnAttr(v: unknown): string {
|
||||||
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) =>
|
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) =>
|
||||||
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """,
|
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """,
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ function __wrnHtml(v: unknown): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function __wrnOptionalAttr(name: string, value: string): string {
|
||||||
|
return value === "" ? "" : " " + name + '="' + value + '"';
|
||||||
|
}
|
||||||
|
|
||||||
function __wrnAttr(v: unknown): string {
|
function __wrnAttr(v: unknown): string {
|
||||||
return String(v == null ? "" : v).replace(
|
return String(v == null ? "" : v).replace(
|
||||||
/[&<>"]/g,
|
/[&<>"]/g,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { expect, test } from "bun:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { compile } from "../src/index.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `pattern=""` is not inert. An empty pattern compiles to a regex that matches
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* Attributes reach the output through two different emitters, and both matter:
|
||||||
|
* a component's interpolated attribute is baked at render time via
|
||||||
|
* `__wrnAttr`, while a page's static element is serialized by `renderAttr`.
|
||||||
|
*/
|
||||||
|
const uiInput = join(import.meta.dir, "..", "..", "ui", "components", "input.wrn");
|
||||||
|
|
||||||
|
function render(view: string): string {
|
||||||
|
return compile(`page P {\n view { ${view} }\n}\n`, "P.wrn").code;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the real ui input never emits a bare pattern attribute", () => {
|
||||||
|
const code = compile(readFileSync(uiInput, "utf8"), uiInput).code;
|
||||||
|
|
||||||
|
// The broken shape: the attribute is always present, empty or not.
|
||||||
|
expect(code).not.toContain('pattern="${__wrnAttr(pattern)}"');
|
||||||
|
// The fixed shape: the attribute itself is decided at render time.
|
||||||
|
expect(code).toContain("__wrnOptionalAttr");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an interpolated constraint attribute on a component is emitted through the helper", () => {
|
||||||
|
const source = `component Field {\n props { pattern: string = "" }\n view { <input type="text" pattern="{pattern}" /> }\n}\n`;
|
||||||
|
const code = compile(source, "Field.wrn").code;
|
||||||
|
|
||||||
|
expect(code).toContain("__wrnOptionalAttr");
|
||||||
|
expect(code).not.toContain('pattern="${__wrnAttr(pattern)}"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a static empty constraint attribute on a page element is dropped", () => {
|
||||||
|
const code = render(`<input type="email" pattern="" minlength="" />`);
|
||||||
|
|
||||||
|
expect(code).not.toContain('pattern=""');
|
||||||
|
expect(code).not.toContain('minlength=""');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a populated constraint attribute survives on both paths", () => {
|
||||||
|
expect(render(`<input type="text" pattern="[0-9]+" />`)).toContain('pattern="[0-9]+"');
|
||||||
|
|
||||||
|
const component = compile(
|
||||||
|
`component F {\n props { p: string = "x" }\n view { <input pattern="{p}" /> }\n}\n`,
|
||||||
|
"F.wrn",
|
||||||
|
).code;
|
||||||
|
expect(component).toContain("pattern");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("attributes outside the constraint set keep their empty values", () => {
|
||||||
|
// An empty value or class is meaningful and must survive untouched.
|
||||||
|
const code = render(`<input type="text" value="" class="" />`);
|
||||||
|
expect(code).toContain('value=""');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user