I had a normalizer I was pleased with. Trims, lowercases the host, drops www., adds https:// when the scheme is missing, strips a trailing slash. Handles example.com, EXAMPLE.com/, www.example.com/about/.
Users typing example.com got a browser tooltip instead:
Please enter a URL.
The normalizer never ran. Not "ran and failed" — never executed, not once, for that input.
Why
<input type="url" name="site" required>
type="url" attaches native constraint validation, and the spec's definition of a valid URL requires a scheme. example.com has no scheme, so the input is invalid, so the form's implicit validation fails on submit — before the submit event handler where my normalizer lived.
The ordering is the whole bug. Native validation runs first. Your JavaScript is downstream of a gate that already rejected the input.
And a bare domain is exactly what people type. Nobody types https://. So the single most common input hit a wall built into the platform, and the code written to handle it sat there looking correct.
Three fixes, in the order I'd try them
1. Normalize before validation happens, not on submit.
Keep type="url" and its keyboard and its native messaging, but fix the value while the user is still in the field:
const input = document.querySelector('input[name="site"]')
input.addEventListener("blur", () => {
const v = input.value.trim()
if (v && !/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) {
input.value = "https://" + v
}
})
blur and not input: rewriting the value on every keystroke fights the person typing, and moves the caret out from under them. On blur they've finished.
This is my default. It's four lines, it keeps every native behaviour, and by the time validation runs the value is valid.
2. Drop type="url", keep the keyboard, own the validation.
<input type="text" inputmode="url" autocomplete="url"
name="site" required
aria-describedby="site-hint">
<p id="site-hint">A domain or full URL, e.g. example.com</p>
inputmode="url" still gets the URL-friendly keyboard on mobile, which is most of what type="url" was buying you. You now own validation, which means you own the error message too — and you must actually render one, because you just removed the browser's.
3. Keep type="url" and replace the message with setCustomValidity.
Worth knowing, but it doesn't solve this: it changes the wording of the rejection, not the rejection. Use it when the input genuinely is invalid, not to work around a value you could have fixed.
Don't fix it by deleting the validation
The tempting fourth option is novalidate on the form, or type="text" with nothing replacing it. Both make the symptom disappear.
If you do that, you owe the user everything you removed: an error message tied to the field with aria-describedby, aria-invalid="true" when it's wrong, focus moved to the first bad field on failed submit, and a message that says what to do rather than what went wrong. The native behaviour was doing all of that for free, including for screen readers.
That's the reason fix 1 is my default: it changes when the value is corrected and leaves the platform's accessibility work alone.
Check every surface, not the one in the bug report
This is the part I actually got wrong. I fixed the field named in the report and shipped.
There were three. The main audit form, a second form on a landing page, and a query parameter that skipped the form entirely and went straight to the API — which had its own validation and its own opinion about schemes.
A normalizer is only as consistent as its least-covered entry point, and entry points multiply quietly. Enumerate them:
grep -rn 'type="url"' --include='*.astro' --include='*.html' --include='*.tsx' --include='*.jsx' .
Then the ones without a form at all: query params, deep links, API bodies, a CLI flag, anything pasted from an email. Each is a place a bare domain can arrive.
The rule: normalize at the boundary where a string becomes your data, not at the boundary where a form is submitted. One is a single function every path routes through. The other is however many forms you have, minus the ones you forgot.
Which is the same rule as the trailing slash, arriving from the other side. Same function, in fact — the one that has to prepend the scheme before urlsplit gets a chance to read the whole thing as a path.
Top comments (1)
I'd include an Enter-key submission while the URL field still has focus in the regression cases. The blur fix covers leaving the field, but implicit submission may reach native validation without that blur, bringing back the original bare-domain rejection. Test mouse submit, Enter, and
requestSubmit()with the same input, alongside the query/API paths you listed. Does the current fix handle the focused-field path too, or only values normalized after leaving the field?