fix!: lowercase on* names are attributes, not event handlers - #3753
Conversation
Only `on` followed by an uppercase letter (`/^on[A-Z]/`) is an event handler. Lowercase names such as `onclick` are plain attributes everywhere: both compilers (DOM, hydratable, dynamic and SSR), the web runtime's spread/assign and server spread walk, useHead, and the html/h element paths. A leftover `on:click` lowering (to a `:click` listener) is gone; the name is now an ordinary namespaced attribute. Dev builds warn once per name with LOWERCASE_EVENT_ATTRIBUTE when a function reaches setAttribute under an `on*` name. ssrAttribute now escapes function values instead of interpolating their source raw. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
🦋 Changeset detectedLatest commit: e9ea857 The changes in this PR will be included in the next version bump. This PR includes changesets to release 12 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Size (brotli, eager entry chunk)
Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in |
Coverage Report for CI Build 37047230538Coverage remained the same at 75.991%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
On components every zero-argument function prop, `onXxx` handlers and `ref` included, becomes a getter, matching @solidjs/h and 1.x hyper-dom-expressions: a component handler declares its event argument. This reverts #3751's unreleased component exemption. Elements keep the `ref` / `onXxx` exemption; lowercase `on*` stays an attribute. Co-authored-by: Claude via Cursor <noreply@cursor.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Merging this PR will not alter performance
Comparing Footnotes
|
| value.length === 0 && | ||
| name !== "ref" && | ||
| !/^on[A-Z]/.test(name) | ||
| (component || (name !== "ref" && !/^on[A-Z]/.test(name))) |
There was a problem hiding this comment.
I don't think we want components to have different behavior. Makes using component libs tricky.
html`<Button onClick=${addTodo} />`has different behavior if addTodo has arguments or not. I think the new onXxx is good and component libraries should follow that.
The server spread walk and the client's assignProp test every key of a
spread for `onXxx`. A `/^on[A-Z]/` test there regressed the
spread-static-tail renderToString benches by 5-7% against the
`startsWith("on")` it replaced. Gate on `startsWith("on")` and compare one
char code, so a key not starting with `on` costs what it did before.
`ssrAttribute` takes strings first, ahead of the new function branch, and
h tests a prop's type before its name. Behavior is unchanged.
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Ruling
And the follow-up clarification: the
on:namespace is gone in 2.0. Event detection is exactly/^on[A-Z]/, everywhere.This is a deliberate breaking change. Types are left as they are (per the brief). 2.0's JSX types don't declare lowercase
on*at all, so a function passed toonclickis already a type error. So is a string, though (see Open questions).1.x history
There was no deprecation in 1.x. On
main,dom-expressions@0.40.8declaresCustomEventHandlersLowerCase<T>(for exampleonclick?: EventHandlerUnion<T, MouseEvent>) as part ofDOMAttributes, with no@deprecatedtag.babel-plugin-jsx-dom-expressions@0.40.7compiles lowercase handlers without any warning. The only thing marked deprecated in the plugin source isoncapture:. Nothing in the changelogs or commit history mentions deprecating lowercase handlers. So this is the first release where lowercaseon*stops being an event. The migration guide says so directly.Changes by package
@solidjs/babel-pluginand@solidjs/compiler(kept in parity)isEventName/is_event_name, matches/^on[A-Z]/. It replaces thestartsWith("on")checks on the DOM and SSR element paths and in the SSR spread claim walk.onclick="…",onclick={expr},onmouseover={state.code}andonclick={() => …}now compile like any other attribute:setAttributeon the DOM (reactive when the value is dynamic) andssrAttributeon the server.on:clicklowering, which produced anaddEventListener(":click", …)listener, is gone. That name now falls through to an ordinary namespaced attribute, with no special case for it.eventExpressionsfixtures (dom, dom-hydratable, dynamic, ssr-hydratable) move their handler lines to camelCase and add a block of lowercase attributes. Both compilers' outputs were regenerated, and both compilers have new parity probes.@solidjs/webassignProp(spread/assign) binds only/^on[A-Z]/as events. Lowercaseon*goes through as an attribute.ssrElementspread walk does the same, usingisEventNamefromconstants.ts.useHead, on both client and server: only camelCaseonXxxis skipped. Lowercaseon*is rendered as an attribute, which supports the<link rel="preload" onload="…">pattern. I found no documented security reason for filtering everyon*.ssrAttributenow stringifies and escapes function values. A function used to reach it with the compiler's escape wrapped inside it rather than applied, so its source was interpolated raw into the attribute.setAttributereports the newLOWERCASE_EVENT_ATTRIBUTEdiagnostic, once per name, when a function reaches it under anon*name. The check is behind the_SOLID_DEV_constant, so production builds drop it (a test checks the built artifacts).on+ an uppercase letter (onClick). The oldonclickwould now be assigned as an attribute._s:on:*markers are an internal wire format and are unchanged.@solidjs/signals:"LOWERCASE_EVENT_ATTRIBUTE"is added to theDiagnosticCodeunion. The doc for therenderkind is widened to cover attribute positions.@solidjs/html: html now uses h's rule (from 1.xhyper-dom-expressions), so the two packages share one rule:refand camelCaseonXxxare exempt from getter wrapping. Lowercaseon*is an attribute.onXxxhandlers andref, so a component handler must declare its event argument. This reverts the component exemption from fix(html): component props named on/only/once get getters like any dynamic prop (#3728) #3751, which hasn't been released.@solidjs/h: element props exempt only/^on[A-Z]/(andref) from getter wrapping. The component path is unchanged fromnext: every zero-argument function is a getter.Docs:
07-dom.md,MIGRATION.md(a lowercase note with a 1.x/2.0 example, plus theon:leftover), a08-dev-diagnostics.mdsection and table row, thereactivity-diagnosticsskill, and the READMEs for babel-plugin, html and h.Dev diagnostic
Code
LOWERCASE_EVENT_ATTRIBUTE, kindrender, severitywarn, data{ name, handler, tag }:Tests
packages/web/test/lowercase-on-attribute.spec.tsx(10 tests): compiled static and dynamic attributes, camelCase beside lowercase, spread/assign attaches no listener, the exact dev warning text, once per name,on:names, no warning for strings or camelCase, and the built artifacts (web.dev.jscontains the code;web.jsandweb.observe.jsdo not).packages/web/test/server/lowercase-on-attribute.spec.tsx(5 tests): escaped attribute, camelCase dropped, function stringified and escaped,useHeadonloadrendered whileonLoadis skipped, spread and directssrElement.head.spec.js: clientuseHeadwith lowercase attributes.transform.test.js: theon:clicktest now asserts a plain attribute; new DOM and SSR lowercase tests. Parity probes added.Results: babel-plugin 271/271. Compiler 5955/5957: the two failures (
option-matrixomitQuotes:falseandtsrx-typecheck-projection) pass in isolation (216/216) and look like timeouts under full-run load.cargo testpasses. Web client 1126 passed + 1 expected failure, server 1386 passed + 2 skipped, hydrate 270. h 62, html 206 (re-run after the component change), solid 819, signals 4832.test-typesandtypecheckpass.Public API changes
onclick={expr}(any lowercaseon*) sets an attribute instead of binding an event, in DOM, hydratable and dynamic output.on*expression attributes instead of dropping them. WithserverComponents, they are no longer claimed as handlers.on*sets an attribute on the client, and the server spread walk renders it.@solidjs/htmland@solidjs/helements: lowercaseon*props are attributes, and zero-argument functions passed to them become getters (evaluated reactively) instead of being passed through as handlers.@solidjs/htmlcomponents no longer exemptonXxxfrom getter wrapping. Every zero-argument function prop on a component is a getter, includingonXxxandref. This reverts fix(html): component props named on/only/once get getters like any dynamic prop (#3728) #3751's component exemption, which is unreleased. html and h now share one rule.LOWERCASE_EVENT_ATTRIBUTE(web, dev only), plus its addition to theDiagnosticCodeunion in@solidjs/signals.useHeadrenders lowercaseon*attributes (client and server). camelCaseonXxxis still skipped.on:clickis now a plain namespaced attribute (leftover handling removed). It used to compile to anaddEventListener(":click", …)listener. One existing compiler test depended on that and now asserts the attribute.ssrAttributeescapes function values (stringified) instead of interpolating them raw. This applies to any function-valued attribute, not onlyon*.onXxxprops. This is internal, but it is a behavior change in@solidjs/web/frames.Size
Brotli bytes from the
scripts/sizeharness, compared withorigin/next:Every scenario is within its frozen cap, so no
Size-Exceptionis needed. renderToString is 3 B under its cap. Brotli is jumpy at this scale: moving oneletdeclaration inassignPropchanged the base page by 67 B.Performance
CodSpeed flagged the
spread-static-tailrenderToString benches (−5.6% to −7.1%) onb772e77. The cause was the per-key/^on[A-Z]/regex test in the server spread walk, which replacednext'sstartsWith("on"). The event check on the per-key paths (serverisEventNameand clientassignProp) is nowstartsWith("on")plus one char-code comparison, so a key not starting withoncosts what it did onnext.ssrAttributechecks strings first, ahead of the new function branch. h checks a prop's type before its name, so the name test only runs for function props. TheuseHeadfilters keep the regex, since they run only on head tags.Overlap
onproperty for <Loading> does not apply getter #3728) merged first. Per the maintainer's ruling, this PR drops its componentonXxxexemption: html components now use h's rule. The<Loading on=${() => key()}>fix from [2.0] @solidjs/html -onproperty for <Loading> does not apply getter #3728 still holds and is still tested.Open questions
on*value, strings included.onclick="…"works at runtime now, but it still needs a cast or@ts-expect-error(the new tests use@ts-expect-error). Do we want to declare lowercaseon*asstringattributes so the types reject only functions? That would be a types change, so it's left out of this PR.onclick: () => doThing()is now treated as a reactive getter and run at render time. The dev warning doesn't fire there, becausesetAttributereceives the result, not the function.🤖 Generated with Claude via Cursor