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=""'); +});