diff --git a/editors/vscode/src/compiler.cjs b/editors/vscode/src/compiler.cjs
index 689dc78e..cb6aed74 100644
--- a/editors/vscode/src/compiler.cjs
+++ b/editors/vscode/src/compiler.cjs
@@ -1,6 +1,6 @@
"use strict";
// 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
// Generated with TypeScript: 6.0.3
const __nodeRequire = require;
@@ -1017,6 +1017,27 @@ const HTML_BOOLEAN_ATTRIBUTES = new Set([
function isHtmlBooleanAttribute(name) {
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([
"href",
"src",
@@ -1078,9 +1099,13 @@ function renderAttr(attr) {
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))}"`;
}
}
function eventAttribute(name) {
@@ -2964,6 +2989,18 @@ function renderComponentNode(node, ctx) {
const compiledValue = 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);
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 {
return String(v == null ? "" : v).replace(
/[&<>"]/g,
@@ -3415,6 +3456,9 @@ function wholeAttributeExpression(value) {
function __wrnHtml(v) {
return String(v == null ? "" : v).replace(/[&<>]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : ">");
}
+function __wrnOptionalAttr(name, value) {
+ return value === "" ? "" : " " + name + '="' + value + '"';
+}
function __wrnAttr(v) {
return String(v == null ? "" : v).replace(/[&<>"]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """);
}
diff --git a/editors/vscode/src/extension.bundle.cjs b/editors/vscode/src/extension.bundle.cjs
index 73b59eec..e2c62167 100644
--- a/editors/vscode/src/extension.bundle.cjs
+++ b/editors/vscode/src/extension.bundle.cjs
@@ -1,4 +1,4 @@
-// WRN editor extension source hash: 6c652a0d490112180d2167de7a6bbc8ba179343ee313c4b51812958bcd941b83
+// WRN editor extension source hash: 59ff54353ed4f50aa53f3731e232726d55549936b2edf8958beb407cc4fdf9d2
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
"use strict";
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
diff --git a/packages/compiler/src/codegen.ts b/packages/compiler/src/codegen.ts
index 54dd1462..07e46978 100644
--- a/packages/compiler/src/codegen.ts
+++ b/packages/compiler/src/codegen.ts
@@ -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 === ">" ? ">" : """,
diff --git a/packages/compiler/test/__snapshots__/resilience.test.ts.snap b/packages/compiler/test/__snapshots__/resilience.test.ts.snap
index 3bd0d8bb..b61e9545 100644
--- a/packages/compiler/test/__snapshots__/resilience.test.ts.snap
+++ b/packages/compiler/test/__snapshots__/resilience.test.ts.snap
@@ -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 {
return String(v == null ? "" : v).replace(
/[&<>"]/g,
diff --git a/packages/compiler/test/empty-constraint-attributes.test.ts b/packages/compiler/test/empty-constraint-attributes.test.ts
new file mode 100644
index 00000000..1760fede
--- /dev/null
+++ b/packages/compiler/test/empty-constraint-attributes.test.ts
@@ -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 { }\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(``);
+
+ expect(code).not.toContain('pattern=""');
+ expect(code).not.toContain('minlength=""');
+});
+
+test("a populated constraint attribute survives on both paths", () => {
+ expect(render(``)).toContain('pattern="[0-9]+"');
+
+ const component = compile(
+ `component F {\n props { p: string = "x" }\n view { }\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(``);
+ expect(code).toContain('value=""');
+});