Skip to content

fix!: lowercase on* names are attributes, not event handlers - #3753

Merged
ryansolid merged 3 commits into
nextfrom
fix/lowercase-on-is-attribute
Oct 2, 2026
Merged

ryansolid merged 3 commits into
nextfrom
fix/lowercase-on-is-attribute

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Ruling

onclick should be treated like an attribute not an event handler.. types should shout if you pass it a function.

I want to take a harder line here. we did the deprecation on 1.x I think.. so maybe its time to be stricter.

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 to onclick is 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.8 declares CustomEventHandlersLowerCase<T> (for example onclick?: EventHandlerUnion<T, MouseEvent>) as part of DOMAttributes, with no @deprecated tag. babel-plugin-jsx-dom-expressions@0.40.7 compiles lowercase handlers without any warning. The only thing marked deprecated in the plugin source is oncapture:. Nothing in the changelogs or commit history mentions deprecating lowercase handlers. So this is the first release where lowercase on* stops being an event. The migration guide says so directly.

Changes by package

@solidjs/babel-plugin and @solidjs/compiler (kept in parity)

  • A new shared predicate, isEventName / is_event_name, matches /^on[A-Z]/. It replaces the startsWith("on") checks on the DOM and SSR element paths and in the SSR spread claim walk.
  • onclick="…", onclick={expr}, onmouseover={state.code} and onclick={() => …} now compile like any other attribute: setAttribute on the DOM (reactive when the value is dynamic) and ssrAttribute on the server.
  • The leftover on:click lowering, which produced an addEventListener(":click", …) listener, is gone. That name now falls through to an ordinary namespaced attribute, with no special case for it.
  • Fixtures: the eventExpressions fixtures (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.
  • Neither compiler has a warning channel (only errors), so there is no compile-time warning. The runtime diagnostic below covers that case.

@solidjs/web

  • assignProp (spread/assign) binds only /^on[A-Z]/ as events. Lowercase on* goes through as an attribute.
  • The server ssrElement spread walk does the same, using isEventName from constants.ts.
  • useHead, on both client and server: only camelCase onXxx is skipped. Lowercase on* is rendered as an attribute, which supports the <link rel="preload" onload="…"> pattern. I found no documented security reason for filtering every on*.
  • ssrAttribute now 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.
  • In dev builds, setAttribute reports the new LOWERCASE_EVENT_ATTRIBUTE diagnostic, once per name, when a function reaches it under an on* name. The check is behind the _SOLID_DEV_ constant, so production builds drop it (a test checks the built artifacts).
  • Frames client: the handler prop for a binding-slot position is now on + an uppercase letter (onClick). The old onclick would 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 the DiagnosticCode union. The doc for the render kind is widened to cover attribute positions.

@solidjs/html: html now uses h's rule (from 1.x hyper-dom-expressions), so the two packages share one rule:

@solidjs/h: element props exempt only /^on[A-Z]/ (and ref) from getter wrapping. The component path is unchanged from next: every zero-argument function is a getter.

Docs: 07-dom.md, MIGRATION.md (a lowercase note with a 1.x/2.0 example, plus the on: leftover), a 08-dev-diagnostics.md section and table row, the reactivity-diagnostics skill, and the READMEs for babel-plugin, html and h.

Dev diagnostic

Code LOWERCASE_EVENT_ATTRIBUTE, kind render, severity warn, data { name, handler, tag }:

[LOWERCASE_EVENT_ATTRIBUTE] `onclick` received a function, but `onclick` is an attribute in Solid 2.0, not an event handler: the function was set as the attribute's text. Use `onClick` for event handlers.

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.js contains the code; web.js and web.observe.js do not).
  • packages/web/test/server/lowercase-on-attribute.spec.tsx (5 tests): escaped attribute, camelCase dropped, function stringified and escaped, useHead onload rendered while onLoad is skipped, spread and direct ssrElement.
  • head.spec.js: client useHead with lowercase attributes.
  • h and html: lowercase attribute tests for elements. One existing h test is moved to camelCase.
  • Compiler transform.test.js: the on:click test 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-matrix omitQuotes:false and tsrx-typecheck-projection) pass in isolation (216/216) and look like timeouts under full-run load. cargo test passes. 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-types and typecheck pass.

Public API changes

  1. Compiled onclick={expr} (any lowercase on*) sets an attribute instead of binding an event, in DOM, hydratable and dynamic output.
  2. SSR renders lowercase on* expression attributes instead of dropping them. With serverComponents, they are no longer claimed as handlers.
  3. Spread/assign of lowercase on* sets an attribute on the client, and the server spread walk renders it.
  4. @solidjs/html and @solidjs/h elements: lowercase on* props are attributes, and zero-argument functions passed to them become getters (evaluated reactively) instead of being passed through as handlers.
  5. @solidjs/html components no longer exempt onXxx from getter wrapping. Every zero-argument function prop on a component is a getter, including onXxx and ref. 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.
  6. New dev diagnostic LOWERCASE_EVENT_ATTRIBUTE (web, dev only), plus its addition to the DiagnosticCode union in @solidjs/signals.
  7. useHead renders lowercase on* attributes (client and server). camelCase onXxx is still skipped.
  8. on:click is now a plain namespaced attribute (leftover handling removed). It used to compile to an addEventListener(":click", …) listener. One existing compiler test depended on that and now asserts the attribute.
  9. ssrAttribute escapes function values (stringified) instead of interpolating them raw. This applies to any function-valued attribute, not only on*.
  10. Frames binding slots bind handler positions under onXxx props. This is internal, but it is a behavior change in @solidjs/web/frames.

Size

Brotli bytes from the scripts/size harness, compared with origin/next:

Scenario next this PR Δ cap
frames eager client 12963 12977 +14 12980
page, base SC 46165 46181 +16 46200
page, live SC 50374 50385 +11 50450
server renderToString 20372 20377 +5 20380
server floor 1331 1331 0 —
signals floors, simple app, hydrating apps, CSR apps — — 0 —

Every scenario is within its frozen cap, so no Size-Exception is needed. renderToString is 3 B under its cap. Brotli is jumpy at this scale: moving one let declaration in assignProp changed the base page by 67 B.

Performance

CodSpeed flagged the spread-static-tail renderToString benches (−5.6% to −7.1%) on b772e77. The cause was the per-key /^on[A-Z]/ regex test in the server spread walk, which replaced next's startsWith("on"). The event check on the per-key paths (server isEventName and client assignProp) is now startsWith("on") plus one char-code comparison, so a key not starting with on costs what it did on next. ssrAttribute checks 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. The useHead filters keep the regex, since they run only on head tags.

Overlap

Open questions

  • Types reject every lowercase 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 lowercase on* as string attributes so the types reject only functions? That would be a types change, so it's left out of this PR.
  • In html/h, an element onclick: () => doThing() is now treated as a reactive getter and run at render time. The dev warning doesn't fire there, because setAttribute receives the result, not the function.
  • On the server, the text of a function attribute differs from the client's: the server sees the arrow with the escape wrapped inside it.

🤖 Generated with Claude via Cursor

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-bot

changeset-bot Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e9ea857

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
@solidjs/babel-plugin Patch
@solidjs/compiler Patch
@solidjs/web Patch
@solidjs/html Patch
@solidjs/h Patch
@solidjs/signals Patch
test-integration Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
todos-server-example Patch
solid-js Patch
@solidjs/universal Patch

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

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 9.49 KB 0 B 9.51 KB ✅
signals: + createStore 16.84 KB 0 B 16.85 KB ✅
signals: + isPending/latest 12.15 KB 0 B 12.16 KB ✅
app: render + one signal (the simple-app floor) 11.98 KB 0 B 12.05 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 19.67 KB 0 B 19.69 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 30.82 KB 0 B 30.82 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 14.88 KB 0 B 14.94 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 16.42 KB 0 B 16.48 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 30.61 KB 0 B 30.71 KB ✅ lazy-page.js 0.04 KB
frames: eager client consumer (frames client + transport, lazy codec) 12.98 KB +14 B (+0.1%) 12.98 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 46.18 KB +16 B (+0.0%) 46.20 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 50.38 KB +11 B (+0.0%) 50.45 KB ✅ decode.js 6.07 KB, lazy-page.js 0.04 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.38 KB +5 B (+0.0%) 20.38 KB ✅

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body).

@coveralls

coveralls commented Oct 2, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37047230538

Coverage remained the same at 75.991%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1195
Covered Lines: 962
Line Coverage: 80.5%
Relevant Branches: 925
Covered Branches: 649
Branch Coverage: 70.16%
Branches in Coverage %: Yes
Coverage Strength: 27.77 hits per line

💛 - 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>
@codspeed

codspeed Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 185 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing fix/lowercase-on-is-attribute (e9ea857) with next (a5d0e76)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

value.length === 0 &&
name !== "ref" &&
!/^on[A-Z]/.test(name)
(component || (name !== "ref" && !/^on[A-Z]/.test(name)))

@danielrkling danielrkling Oct 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@ryansolid
ryansolid merged commit 4f67697 into next Oct 2, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants