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>
64 lines
2.6 KiB
TypeScript
64 lines
2.6 KiB
TypeScript
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=""');
|
|
});
|