go()} onmouseover={state.code} oninput="run()" />;
+const b =
;
+const c =
;
`,
"class and style namespaces": `
const a =
;
diff --git a/packages/compiler/__tests__/transform.test.js b/packages/compiler/__tests__/transform.test.js
index 1212487cb..411d514ed 100644
--- a/packages/compiler/__tests__/transform.test.js
+++ b/packages/compiler/__tests__/transform.test.js
@@ -909,18 +909,42 @@ describe("@solidjs/compiler transform", () => {
expect(result.code).not.toContain("_$delegateEvents");
});
- it("treats namespaced event attributes like Babel after the event update", () => {
- // The `on:`/`oncapture:` namespaces were removed on this branch; Babel's
- // `key.startsWith("on")` branch now sees the raw namespaced key.
+ it("treats removed `on:` namespaced names as plain attributes", () => {
+ // Only `on` + an uppercase letter is an event handler; `on:click` is an
+ // unknown namespace like any other.
const result = transform("
increment()} />", {
filename: "input.jsx",
moduleName: "r-dom"
});
- expect(result.code).toContain('_el$.addEventListener(":click",');
+ expect(result.code).toContain('_$setAttribute(_el$, "on:click", () => increment());');
+ expect(result.code).not.toContain("addEventListener");
expect(result.code).not.toContain("_$delegateEvents");
});
+ it("lowers lowercase on* expressions as attributes, not events", () => {
+ const result = transform(
+ " ",
+ { filename: "input.jsx", moduleName: "r-dom" }
+ );
+
+ expect(result.code).toContain('_$setAttribute(_el$, "onclick", handler);');
+ expect(result.code).toContain('_$setAttribute(_el$, "onmouseover", _v$);');
+ expect(result.code).toContain('_$addEvent(_el$, "click", click, true);');
+ expect(result.code).toContain('_$delegateEvents(["click"]);');
+ });
+
+ it("renders lowercase on* expressions as SSR attributes", () => {
+ const result = transform(" ", {
+ filename: "input.jsx",
+ moduleName: "r-server",
+ generate: "ssr"
+ });
+
+ expect(result.code).toContain('_$ssrAttribute("onclick", _$escape(code, true))');
+ expect(result.code).not.toContain("clickHandler");
+ });
+
it("lowers known namespaced DOM attributes through setAttributeNS", () => {
const result = transform(" ", {
filename: "input.jsx",
diff --git a/packages/compiler/src/dom/attrs.rs b/packages/compiler/src/dom/attrs.rs
index 7ed3fa082..9395cf99c 100644
--- a/packages/compiler/src/dom/attrs.rs
+++ b/packages/compiler/src/dom/attrs.rs
@@ -9,7 +9,7 @@ use crate::dom::set_attr::SetAttrOptions;
use crate::shared::attr_plan::{AttrPlan, AttrPlanOutcome, AttrPlanner, ConfidentValue, PlanValue};
use crate::shared::bindings::push_unique;
use crate::shared::constants::{
- ALWAYS_CLOSE_ELEMENTS, BLOCK_ELEMENTS, child_properties, inline_elements,
+ ALWAYS_CLOSE_ELEMENTS, BLOCK_ELEMENTS, child_properties, inline_elements, is_event_name,
};
use crate::shared::refs::{callable_test, ref_assignment_fallback};
use crate::shared::utils::{
@@ -304,7 +304,7 @@ impl<'a> AstDomTransform<'a, '_> {
return Ok(());
}
- if plan.key.starts_with("on") {
+ if is_event_name(&plan.key) {
front_groups.push(self.dom_event_statements(span, element_id, &plan.key, raw));
return Ok(());
}
diff --git a/packages/compiler/src/dom/events.rs b/packages/compiler/src/dom/events.rs
index 37bb55cd0..def53d4e5 100644
--- a/packages/compiler/src/dom/events.rs
+++ b/packages/compiler/src/dom/events.rs
@@ -6,7 +6,7 @@ use crate::dom::element::AstDomTransform;
use crate::shared::constants::delegated_events;
impl<'a> AstDomTransform<'a, '_> {
- /// Port of Babel's `key.startsWith("on")` attribute branch: returns the
+ /// Port of Babel's `isEventName(key)` attribute branch: returns the
/// flat statement group that gets unshifted ahead of the element's other
/// expressions.
pub(crate) fn event_statements(
diff --git a/packages/compiler/src/shared/constants.rs b/packages/compiler/src/shared/constants.rs
index 42d523774..f247d3f5d 100644
--- a/packages/compiler/src/shared/constants.rs
+++ b/packages/compiler/src/shared/constants.rs
@@ -256,6 +256,13 @@ pub(crate) fn namespaces(prefix: &str) -> Option<&'static str> {
}
}
+/// Babel's `isEventName`: only `on` + an uppercase letter is an event
+/// handler. Lowercase `on*` names (`onclick`) are plain attributes.
+pub(crate) fn is_event_name(name: &str) -> bool {
+ let bytes = name.as_bytes();
+ bytes.len() > 2 && bytes[0] == b'o' && bytes[1] == b'n' && bytes[2].is_ascii_uppercase()
+}
+
/// Babel's `reservedNameSpaces`: JSX namespace prefixes with compiler
/// semantics rather than XML namespaces. The 0.50 line dropped the
/// `class:`/`style:`/`on:`/`use:`/`attr:`/`bool:` special namespaces — those
diff --git a/packages/compiler/src/ssr/transform.rs b/packages/compiler/src/ssr/transform.rs
index df26829be..6d79c90b1 100644
--- a/packages/compiler/src/ssr/transform.rs
+++ b/packages/compiler/src/ssr/transform.rs
@@ -29,7 +29,7 @@ use crate::shared::condition::{
zero_arg_call_thunk,
};
use crate::shared::constants::{
- DomPropertyState, child_properties, dom_with_state, reserved_namespace,
+ DomPropertyState, child_properties, dom_with_state, is_event_name, reserved_namespace,
};
use crate::shared::utils::{
child_slot_allocates_ids, decode_html_entities, element_name, escape_html_attribute,
@@ -1660,10 +1660,10 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
}
/// The handler position a named attribute of a spread element claims
- /// (Babel's `claims` in `createElement`): a `ref`/`on*` whose value is
+ /// (Babel's `claims` in `createElement`): a `ref`/`onXxx` whose value is
/// an expression that is not a literal; `onXxx` lowercases to the event
/// name as the template path derives it. `None` for every other
- /// attribute — including a `ref`/`on*` with a literal or no value,
+ /// attribute — including a `ref`/`onXxx` with a literal or no value,
/// which `spread_prop_property` drops as before.
fn spread_claim(
&self,
@@ -1677,12 +1677,10 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
};
let pos = if name == "ref" {
"ref".to_string()
+ } else if is_event_name(&name) {
+ name[2..].to_lowercase()
} else {
- let pos = name.strip_prefix("on")?.to_lowercase();
- if pos.is_empty() {
- return None;
- }
- pos
+ return None;
};
let Some(JSXAttributeValue::ExpressionContainer(container)) = &attr.value else {
return None;
@@ -1718,9 +1716,9 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
if has_children && name == "children" {
return Ok(None);
}
- // `ref`/`on*` render nothing on the server; under `serverComponents`
+ // `ref`/`onXxx` render nothing on the server; under `serverComponents`
// they are the element's claim map instead (`spread_claim`).
- if name == "ref" || name.starts_with("prop:") || name.starts_with("on") {
+ if name == "ref" || name.starts_with("prop:") || is_event_name(&name) {
return Ok(None);
}
// `$key` on an intrinsic element compiles to the `_key` attribute
@@ -2039,7 +2037,7 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
}
/// One planned attribute, following Babel's SSR `transformAttributes`:
- /// refs hoist `_ref$N` declarations, `prop:`/`on*` drop, child properties
+ /// refs hoist `_ref$N` declarations, `prop:`/`onXxx` drop, child properties
/// redirect into children, class/style get their SSR serializers, and
/// everything else routes through `setAttr` or an inline quoted hole.
#[allow(clippy::too_many_arguments)]
@@ -2138,15 +2136,12 @@ impl<'a, 'source> AstSsrTransform<'a, 'source> {
if key.starts_with("prop:") {
return Ok(());
}
- if let Some(rest) = key.strip_prefix("on") {
+ if is_event_name(&key) {
// `onXxx` lowercases to the event name — the client runtime's
// own derivation (`onClick` -> `click`); the position is bound
// under it.
if self.server_components {
- let pos = rest.to_lowercase();
- if !pos.is_empty() {
- claims.push((pos, expression));
- }
+ claims.push((key[2..].to_lowercase(), expression));
}
return Ok(());
}
diff --git a/packages/h/README.md b/packages/h/README.md
index 28792d4ab..7dbe16e9f 100644
--- a/packages/h/README.md
+++ b/packages/h/README.md
@@ -118,3 +118,12 @@ h("div#some-id.my-class");
```js
[h("span", "1"), h("span", "2")];
```
+
+7. Only `onXxx` names are event handlers
+
+On elements, only `on` followed by an uppercase letter binds an event. A lowercase `onclick` is a plain attribute, and a function passed to it becomes a getter like any other attribute.
+
+```js
+h("button", { onClick: () => save() }); // event handler
+h("button", { onclick: () => code() }); // reactive `onclick` attribute
+```
diff --git a/packages/h/src/hyperscript.ts b/packages/h/src/hyperscript.ts
index fb749a919..6b33c499d 100644
--- a/packages/h/src/hyperscript.ts
+++ b/packages/h/src/hyperscript.ts
@@ -114,7 +114,7 @@ function materialize(args: any[]): ExpandableNode | ExpandableNode[] {
Object.defineProperty(l, "class", { ...d[k], value });
classes = [];
}
- if (k !== "ref" && k.slice(0, 2) !== "on" && typeof d[k].value === "function") {
+ if (typeof d[k].value === "function" && k !== "ref" && !/^on[A-Z]/.test(k)) {
dynamicProperty(l, k);
dynamic = true;
} else if (d[k].get) dynamic = true;
diff --git a/packages/h/test/hyperscript.spec.js b/packages/h/test/hyperscript.spec.js
index fe71243e8..e7b3c92c5 100644
--- a/packages/h/test/hyperscript.spec.js
+++ b/packages/h/test/hyperscript.spec.js
@@ -64,14 +64,14 @@ describe("Test HyperScript", () => {
const template = mount(() =>
h("#main", [
- h("button", { onclick: () => (exec.bound = true) }, "Click Bound"),
+ h("button", { onMouseEnter: () => (exec.bound = true) }, "Click Bound"),
h("button", { onClick: () => (exec.delegated = true) }, "Click Delegated")
])
);
expect(template.outerHTML).toBe(FIXTURES[2]);
document.body.appendChild(template);
r.registerDelegatedRoot(document.body);
- var event = new MouseEvent("click", { bubbles: true });
+ var event = new MouseEvent("mouseenter");
template.firstChild.dispatchEvent(event);
event = new MouseEvent("click", { bubbles: true });
template.firstChild.nextSibling.dispatchEvent(event);
@@ -81,6 +81,20 @@ describe("Test HyperScript", () => {
document.body.textContent = "";
});
+ test("Lowercase on* Attributes", () => {
+ const [code, setCode] = createSignal("first()");
+ const template = mount(() =>
+ h("#main", [h("button", { onclick: "run()" }), h("button", { onmouseover: () => code() })])
+ );
+ const [first, second] = template.children;
+ expect(first.getAttribute("onclick")).toBe("run()");
+ expect(first._$$click).toBeUndefined();
+ expect(second.getAttribute("onmouseover")).toBe("first()");
+ setCode("second()");
+ flush();
+ expect(second.getAttribute("onmouseover")).toBe("second()");
+ });
+
test("Fragments", () => {
const [inserted] = createSignal("middle");
diff --git a/packages/html/README.md b/packages/html/README.md
index 007288870..87a3c0ee2 100644
--- a/packages/html/README.md
+++ b/packages/html/README.md
@@ -90,12 +90,15 @@ There are a few differences from Solid's JSX that are important to note.
html` props.id}>${() => firstName() + lastName()}
`
```
-2. Zero-argument functions passed to component props become getters
+2. Events on components require explicit event in the arguments
-Solid's Tagged Template Literals automatically wrap functions with no arguments passed to props of components in getters, so a render prop like the `` child needs a declared argument to stay a function. Event handlers named `onXxx` (an uppercase letter after `on`) are exempt; any other prop, including `on` on ``, `only`, or a lowercase `onclick`, is wrapped.
+Solid's Tagged Template Literals automatically wrap functions with no arguments passed to props of components in getters, so you need to declare an argument to prevent this. That covers every prop, `onXxx` handlers and `ref` included; the same applies to render props like in the `` component.
```js
-// handler: passed as-is
+// good: passed as a function
+html`<${Button} onClick=${e => console.log("Hi")} />`;
+
+// bad: a getter, so `props.onClick` runs `console.log` and reads its result
html`<${Button} onClick=${() => console.log("Hi")} />`;
// getter: `props.on` reads `key()`
@@ -119,4 +122,13 @@ html`
1
2
`
+```
+
+6. Only `onXxx` names are event handlers
+
+On elements, camelCase `onXxx` names (`on` followed by an uppercase letter) are event handlers and are passed through as functions, as is `ref`. A lowercase `onclick` is a plain attribute, and a zero-argument function passed to it becomes a getter like any other attribute.
+
+```js
+html` save()} />`; // event handler
+html` code()} />`; // reactive `onclick` attribute
```
\ No newline at end of file
diff --git a/packages/html/src/tagged-jsx.ts b/packages/html/src/tagged-jsx.ts
index c53ec0ccb..f1d6c546c 100644
--- a/packages/html/src/tagged-jsx.ts
+++ b/packages/html/src/tagged-jsx.ts
@@ -258,12 +258,9 @@ function createHtml() {
return mergeProps(...sources) as Record;
};
- // Elements: every `on*` prop is an event to `spread`. Components have no
- // event namespace, so only the `onXxx` handler convention is exempt —
- // `on`, `only`, `once` are ordinary props.
- const isHandlerName = (name: string, component: boolean) =>
- component ? /^on[A-Z]/.test(name) : name.startsWith("on");
-
+ // Components: every zero-argument function is a getter, so a handler must
+ // declare its event argument. Elements: `ref` and `onXxx` handlers are
+ // exempt; a lowercase `onclick` is an attribute like any other.
const applyGetter = (
props: Record,
name: string,
@@ -273,8 +270,7 @@ function createHtml() {
if (
typeof value === "function" &&
value.length === 0 &&
- name !== "ref" &&
- !isHandlerName(name, component)
+ (component || (name !== "ref" && !/^on[A-Z]/.test(name)))
) {
Object.defineProperty(props, name, {
get() {
diff --git a/packages/html/test/tagged-jsx.test.ts b/packages/html/test/tagged-jsx.test.ts
index 02fc7dfe8..dbdfed2bc 100644
--- a/packages/html/test/tagged-jsx.test.ts
+++ b/packages/html/test/tagged-jsx.test.ts
@@ -336,6 +336,28 @@ describe("Tagged JSX Integration Tests", () => {
dispose();
}));
+ it("treats lowercase on* on elements as attributes, reactive through a getter", () => {
+ const [code, setCode] = createSignal("first()");
+ let el!: HTMLElement;
+ const dispose = createRoot(dispose => {
+ el = html`
+ Static
+ code()}>Dynamic
+ Expression
+
` as HTMLElement;
+ return dispose;
+ });
+ const [first, second, third] = el.querySelectorAll("button");
+ expect(first.getAttribute("onclick")).toBe("run()");
+ expect(second.getAttribute("onmouseover")).toBe("first()");
+ expect(third.getAttribute("onclick")).toBe("expr()");
+ expect((third as any)._$$click).toBeUndefined();
+ setCode("second()");
+ flush();
+ expect(second.getAttribute("onmouseover")).toBe("second()");
+ dispose();
+ });
+
it("integrates ref listeners and delegated events", () => {
const exec = { first: false, delegated: false, second: false };
const container = document.createElement("div");
@@ -430,10 +452,10 @@ describe("Tagged JSX Integration Tests", () => {
dispose();
}));
- it("wraps zero-arg functions in getters for non-handler `on*` props (#3728)", () => {
+ it("wraps every zero-arg function prop on a component in a getter (#3728)", () => {
const [value, setValue] = createSignal(1);
- const handler = () => "handled";
- const ref = () => {};
+ const handler = (e: Event) => e;
+ const ref = (el: unknown) => el;
let props!: any;
const Probe = (p: any) => {
props = p;
@@ -444,8 +466,10 @@ describe("Tagged JSX Integration Tests", () => {
on=${() => value()}
only=${() => value() * 2}
once=${() => value() * 3}
- onClick=${handler}
- ref=${ref}
+ onClick=${() => value() * 4}
+ onInput=${handler}
+ ref=${() => value() * 5}
+ refWithArg=${ref}
/>`;
return d;
});
@@ -453,14 +477,18 @@ describe("Tagged JSX Integration Tests", () => {
expect(props.on).toBe(1);
expect(props.only).toBe(2);
expect(props.once).toBe(3);
- expect(props.onClick).toBe(handler);
- expect(props.ref).toBe(ref);
+ expect(props.onClick).toBe(4);
+ expect(props.onInput).toBe(handler);
+ expect(props.ref).toBe(5);
+ expect(props.refWithArg).toBe(ref);
setValue(2);
flush();
expect(props.on).toBe(2);
expect(props.only).toBe(4);
expect(props.once).toBe(6);
+ expect(props.onClick).toBe(8);
+ expect(props.ref).toBe(10);
dispose();
});
diff --git a/packages/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts
index c3de22c72..60d588726 100644
--- a/packages/signals/src/core/dev.ts
+++ b/packages/signals/src/core/dev.ts
@@ -103,6 +103,7 @@ export type DiagnosticCode =
| "HEAD_TAG_INVALID"
| "UNRECOGNIZED_INSERT_VALUE"
| "UNSCOPED_HOLE_ALLOCATED_IDS"
+ | "LOWERCASE_EVENT_ATTRIBUTE"
| "BINDING_SLOT_POSITION"
| "FRAME_MARKER_CORRUPTED"
| "DYNAMIC_ASYNC_COMPONENT";
@@ -122,7 +123,7 @@ export type DiagnosticKind =
| "ssr"
/** The response head: `` tags, preload descriptors, HTTP headers. */
| "head"
- /** The renderer's insert positions, on either platform: a value it has no rendering for. */
+ /** The renderer's insert and attribute positions: a value it has no rendering for, or one it renders other than intended. */
| "render";
/** First warning when a change reaches (or a pass tracks) this many edges. */
diff --git a/packages/solid/skills/reactivity-diagnostics/SKILL.md b/packages/solid/skills/reactivity-diagnostics/SKILL.md
index d9b2d0caa..ed59520c8 100644
--- a/packages/solid/skills/reactivity-diagnostics/SKILL.md
+++ b/packages/solid/skills/reactivity-diagnostics/SKILL.md
@@ -843,6 +843,16 @@ JavaScript or through a cast. Fix: call the function at the hole
(`{renderHead()}` — a call hole is scoped on both sides) or assign the built
value first and insert that.
+### LOWERCASE_EVENT_ATTRIBUTE
+
+A function was set on an attribute whose name starts with `on` but is not an
+event handler (`data.name`): a lowercase `onclick`, or a leftover 1.x
+`on:click`. In 2.0 only `on` + an uppercase letter binds an event; anything
+else is a plain attribute, so the function was stringified into it and never
+runs. Once per attribute name. Fix: use the camelCase handler
+(`data.handler` — `onclick={save}` → `onClick={save}`); keep a lowercase name
+only for a string inline-handler attribute.
+
### BINDING_SLOT_POSITION
A binding slot's property (`const row = props.row(args); row.done`)
diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts
index 90a6b8a02..b29575cb4 100644
--- a/packages/web/frames/src/client.ts
+++ b/packages/web/frames/src/client.ts
@@ -468,14 +468,16 @@ function bindDataOccurrence(
let st = state.get(element);
if (!st) state.set(element, (st = { prev: {}, handlers: {}, ref: undefined, refId: "" }));
// Handler positions: the marker's event name (`onClick` compiled to
- // `click`) as the prop `assign` binds. The server merges duplicate
- // handlers last-wins, so a position names one key; given more, the last.
- // Several keys at a ref position all fire, in marker order.
+ // `click`) as the prop `assign` binds. The prop must be `on` + an
+ // uppercase letter (`onClick`) — a lowercase `onclick` is an attribute
+ // to `assign`. The server merges duplicate handlers last-wins, so a
+ // position names one key; given more, the last. Several keys at a ref
+ // position all fire, in marker order.
const handlers: Record = {};
const refKeys: string[] = [];
for (const { pos, key } of positions) {
if (pos === "ref") refKeys.push(key);
- else if (pos.startsWith("on:")) handlers["on" + pos.slice(3)] = key;
+ else if (pos.startsWith("on:")) handlers["on" + pos[3].toUpperCase() + pos.slice(4)] = key;
}
let owners = handlerOwners.get(element);
if (!owners) handlerOwners.set(element, (owners = {}));
diff --git a/packages/web/src/client.ts b/packages/web/src/client.ts
index 6d65010f0..490932549 100644
--- a/packages/web/src/client.ts
+++ b/packages/web/src/client.ts
@@ -186,7 +186,7 @@ import {
qualifierValue,
STYLESHEET_FETCH_META
} from "./head.js";
-import { devCheck, unscopedHoleAllocatedIds } from "./diagnostics.js";
+import { devCheck, lowercaseEventAttribute, unscopedHoleAllocatedIds } from "./diagnostics.js";
export {
DOMWithState,
ChildProperties,
@@ -713,7 +713,10 @@ export function claimElement(node) {
export function setAttribute(node: Element, name: string, value: string): void;
export function setAttribute(node, name, value) {
- if ("_SOLID_DEV_") tagElement(node);
+ if ("_SOLID_DEV_") {
+ tagElement(node);
+ if (typeof value === "function" && name.startsWith("on")) lowercaseEventAttribute(name, node);
+ }
if (isHydrating(node)) return;
const selectMultiple = name === "multiple" && node.localName === "select";
if (value == null || value === false) node.removeAttribute(name);
@@ -1901,13 +1904,13 @@ function flushHeadRegistry() {
}
}
-// Shared filtered create: skips children/ref/on* and invalid names, drops
-// null/false values, sets the text body. Used by the replaceable render and
-// the resource mount.
+// Shared filtered create: skips children/ref/onXxx handlers and invalid
+// names, drops null/false values, sets the text body. Used by the
+// replaceable render and the resource mount.
function createHeadElement(tag, props) {
const el = document.createElement(tag);
for (const name in props) {
- if (name === "children" || name === "ref" || name.slice(0, 2) === "on") continue;
+ if (name === "children" || name === "ref" || /^on[A-Z]/.test(name)) continue;
if (!HEAD_ATTR_NAME.test(name)) {
if ("_SOLID_DEV_") console.warn(`useHead: ignoring invalid attribute name "${name}"`);
continue;
@@ -1933,7 +1936,7 @@ function renderHeadElement(t, identity, existing) {
function headElementMatches(el, t) {
if (el.tagName.toLowerCase() !== t.tag) return false;
for (const name in t.props) {
- if (name === "children" || name === "ref" || name.slice(0, 2) === "on") continue;
+ if (name === "children" || name === "ref" || /^on[A-Z]/.test(name)) continue;
if (!HEAD_ATTR_NAME.test(name)) continue;
const v = t.props[name];
if (v == null || v === false) {
@@ -2577,9 +2580,13 @@ function assignProp(node, prop, value, prev, skipRef, nodeName) {
return value;
}
+ let c;
const hasNamespace = prop.indexOf(":") > -1;
- if (!hasNamespace && prop.slice(0, 2) === "on") {
+ // Only `on` + an uppercase letter is an event; a lowercase `onclick` is an
+ // attribute. No regex on this per-prop path: a key not starting with `on`
+ // pays one `startsWith`.
+ if (!hasNamespace && prop.startsWith("on") && (c = prop.charCodeAt(2)) > 64 && c < 91) {
const name = prop.slice(2).toLowerCase();
const delegate = DelegatedEvents.has(name);
if (!delegate && prev) {
diff --git a/packages/web/src/constants.ts b/packages/web/src/constants.ts
index 0dfdd30e2..50165b681 100644
--- a/packages/web/src/constants.ts
+++ b/packages/web/src/constants.ts
@@ -232,6 +232,14 @@ function isHttpNavigationTarget(target: string): boolean {
}
}
+// Only `on` + an uppercase letter (`onClick`) is an event handler. Lowercase
+// `on*` names (`onclick`) are plain attributes. No regex: this runs on every
+// key of a spread, and a key not starting with `on` pays one `startsWith`.
+function isEventName(name: string): boolean {
+ let c;
+ return name.startsWith("on") && (c = name.charCodeAt(2)) > 64 && c < 91;
+}
+
export {
DOMWithState,
ChildProperties,
@@ -245,5 +253,6 @@ export {
$$SLOT,
$$HOST,
COMPOSED_BODY_FRAMING,
- isHttpNavigationTarget
+ isHttpNavigationTarget,
+ isEventName
};
diff --git a/packages/web/src/diagnostics.ts b/packages/web/src/diagnostics.ts
index 7230a7e7b..da7be7e41 100644
--- a/packages/web/src/diagnostics.ts
+++ b/packages/web/src/diagnostics.ts
@@ -114,6 +114,32 @@ export function unscopedHoleAllocatedIds(
);
}
+const lowercaseEventAttributesReported = /*#__PURE__*/ new Set();
+
+/**
+ * Dev CHECK: a function reached an attribute whose name starts with `on` but
+ * is not an event handler — a lowercase `onclick`, or a 1.x `on:click`. Only
+ * `on` + an uppercase letter (`onClick`) binds an event in 2.0; anything else
+ * is a plain attribute, so the function is stringified into the attribute's
+ * text. Reported once per attribute name so a row template reports once.
+ */
+export function lowercaseEventAttribute(name: string, node: Element): void {
+ if (lowercaseEventAttributesReported.has(name)) return;
+ lowercaseEventAttributesReported.add(name);
+ const event = name.slice(name.charAt(2) === ":" ? 3 : 2);
+ const handler = "on" + event.charAt(0).toUpperCase() + event.slice(1);
+ devCheck({
+ code: "LOWERCASE_EVENT_ATTRIBUTE",
+ kind: "render",
+ severity: "warn",
+ message:
+ `[LOWERCASE_EVENT_ATTRIBUTE] \`${name}\` received a function, but \`${name}\` is an attribute ` +
+ `in Solid 2.0, not an event handler: the function was set as the attribute's text. ` +
+ `Use \`${handler}\` for event handlers.`,
+ data: { name, handler, tag: node.localName }
+ });
+}
+
/** `Name: message` for an Error, `String(value)` otherwise. */
export function errorText(error: unknown): string {
if (error instanceof Error) return error.message ? `${error.name}: ${error.message}` : error.name;
diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts
index c2bcaf2ba..cc619ea5d 100644
--- a/packages/web/src/server.ts
+++ b/packages/web/src/server.ts
@@ -1,5 +1,10 @@
// @ts-nocheck
-import { COMPOSED_BODY_FRAMING, ChildProperties, isHttpNavigationTarget } from "./constants.js";
+import {
+ COMPOSED_BODY_FRAMING,
+ ChildProperties,
+ isEventName,
+ isHttpNavigationTarget
+} from "./constants.js";
import {
createRoot as root,
getOwner,
@@ -1371,7 +1376,7 @@ function flushHeadFragment(registry, boundary, nonce) {
const t = winner.tags[i];
const attrs = {};
for (const name in t.props) {
- if (name === "children" || name === "ref" || name.slice(0, 2) === "on") continue;
+ if (name === "children" || name === "ref" || isEventName(name)) continue;
if (!HEAD_ATTR_NAME.test(name)) {
if ("_SOLID_DEV_")
headTagInvalid(
@@ -1486,7 +1491,7 @@ function nonceAttr(nonce, destination) {
function renderHeadAttrHtml(props) {
let attrs = "";
for (const name in props) {
- if (name === "children" || name === "ref" || name.slice(0, 2) === "on") continue;
+ if (name === "children" || name === "ref" || isEventName(name)) continue;
if (!HEAD_ATTR_NAME.test(name)) {
if ("_SOLID_DEV_")
headTagInvalid(
@@ -1509,7 +1514,7 @@ function renderHeadAttrHtml(props) {
function headAttrRecord(props, skipRelHref) {
let attrs = null;
for (const name in props) {
- if (name === "children" || name === "ref" || name.slice(0, 2) === "on") continue;
+ if (name === "children" || name === "ref" || isEventName(name)) continue;
if (skipRelHref && (name === "rel" || name === "href")) continue;
if (!HEAD_ATTR_NAME.test(name)) continue;
const v = props[name];
@@ -4475,7 +4480,7 @@ export function ssrElement(tag, props, children, needsId, skip, attrs, claims) {
// `sourceHas`), then attaches nothing for `undefined` — so a named
// handler before this source binds nothing either.
if (value == undefined) {
- if (slots && claims !== undefined && (prop === "ref" || prop.startsWith("on")))
+ if (slots && claims !== undefined && (prop === "ref" || isEventName(prop)))
behaviors = spreadBehaviorPosition(
behaviors,
prop,
@@ -4498,7 +4503,7 @@ export function ssrElement(tag, props, children, needsId, skip, attrs, claims) {
// that knows them (`spreadObjectAttribute`; the compiled positions
// share it); strings and booleans — the walk's common case — never
// do, and outside a server component the ladder is the pre-slot one.
- if (prop === "ref" || prop.startsWith("on")) {
+ if (prop === "ref" || isEventName(prop)) {
if (slots)
behaviors = spreadBehaviorPosition(
behaviors,
@@ -4544,7 +4549,7 @@ export function ssrElement(tag, props, children, needsId, skip, attrs, claims) {
// source it replaces had its getters read: the expressions run at the same
// point in the hydration-id sequence. Evaluating it in argument position
// would move them ahead of the element's own key.
- // `claims` is the compiled claim map of the element's named `ref`/`on*`
+ // `claims` is the compiled claim map of the element's named `ref`/`onXxx`
// attributes (the spread element's counterpart of the template path's
// guarded `ssrClaim` hole), keyed by the source index each attribute sits
// before, a thunk read only inside a server component's render — the same
@@ -4585,7 +4590,7 @@ export function ssrElementAttribute(key, value) {
// walk, in the same order: nullish is "not set" (`class`/`style` included,
// #3382), `style`/`class` take their serializers, a boolean is present or
// absent, `""` is a bare attribute, anything else is attribute-escaped.
- // `key` is a compile-time attribute name (never `ref`, `on*` or `prop:*`,
+ // `key` is a compile-time attribute name (never `ref`, `onXxx` or `prop:*`,
// which the compiler drops) and is trusted like `ssrAttribute`'s.
//
// Under the `serverComponents` compiler option a dynamic `class`/`style`
@@ -4613,12 +4618,17 @@ export function ssrAttribute(key, value) {
// passes a binding-slot value through untouched, so the position it names
// is bound here (principles §9.2.3). Both are trusted here so this hot
// path stays a pure string concatenation.
+ if (typeof value === "string") return ` ${key}="${value}"`;
if (value == null || value === false) return "";
if (typeof value === "object") {
return isSlotValue(value)
? slotAttribute(key, value)
: ` ${key}="${escape(String(value), true)}"`;
}
+ // A function value (`onclick={() => …}`) reaches here with the compiler's
+ // escape wrapped inside it, not applied. Its source is not attribute-safe:
+ // stringify and escape it, as the client's setAttribute stringifies it.
+ if (typeof value === "function") return ` ${key}="${escape(String(value), true)}"`;
return value === true ? ` ${key}` : ` ${key}="${value}"`;
}
export function ssrHydrationKey(): string;
@@ -4665,14 +4675,14 @@ export function ssrHydrationKey() {
// passes the stand-in through), `ssrElementAttribute` (a compiled
// `class`/`style` under the `serverComponents` option, or a trailing
// attribute of a spread element), `ssrElement`'s walk (a runtime spread),
-// `ssrClaim` (the compiled per-element hole for ref/on* positions). The
+// `ssrClaim` (the compiled per-element hole for ref/onXxx positions). The
// grammar: the occurrence alphabet (frame-sink.ts) excludes `:`, `,` and
// `=`; keys and names percent-encode onto an alphabet that excludes them
// too, so every split is exact and the client decodes names back.
// The compiled guard's arming values (`sharedConfig.context.claims`): the
// frame renderers set one at server-component entry so renders with no
-// server components never evaluate the ref/on* hole's expressions. On the
+// server components never evaluate the ref/onXxx hole's expressions. On the
// document face only owner chains inside the component barrier warn about
// server-local handlers (client fill content re-enters the zone owner
// captured OUTSIDE the barrier — its handlers are hydration's).
@@ -4912,7 +4922,7 @@ function eventPosition(prop) {
}
/**
- * A `ref`/`on*` key met by `ssrElement`'s walk under `slots` — in a
+ * A `ref`/`onXxx` key met by `ssrElement`'s walk under `slots` — in a
* source, whatever its shape: a stand-in, a list of them (refs), a handler
* tuple, a server-local function, nothing — collected by position into
* `behaviors` exactly as `ssrClaim` reads the compiled claim map, so the
@@ -4946,7 +4956,7 @@ function spreadBehaviorPosition(behaviors, prop, value, mode, index, settle) {
/**
* The behavior markers of a spread element: the sources' (`behaviors`)
- * settled against its compiled claim map (`claims` — the named `ref`/`on*`
+ * settled against its compiled claim map (`claims` — the named `ref`/`onXxx`
* attributes, keyed by the index of the source each sits before; a thunk
* `ssrElement` calls only under `slots`, so plain SSR never evaluates
* them). The marker promises what the client binds, and the client's
@@ -5090,7 +5100,7 @@ function slotSpreadSource(tag, source) {
}
/**
- * The compiled per-element hole for ref/on* positions on a server
+ * The compiled per-element hole for ref/onXxx positions on a server
* intrinsic (behind the `serverComponents` compiler option):
* `ctx.claims ? ssrClaim({ click: expr, ref: expr2 }) : ""`. The compiler
* drops handler and ref expressions from plain SSR output, so this is
diff --git a/packages/web/test/lowercase-on-attribute.spec.tsx b/packages/web/test/lowercase-on-attribute.spec.tsx
new file mode 100644
index 000000000..152963a75
--- /dev/null
+++ b/packages/web/test/lowercase-on-attribute.spec.tsx
@@ -0,0 +1,183 @@
+/**
+ * @jsxImportSource @solidjs/web
+ * @vitest-environment jsdom
+ */
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { createSignal, flush } from "solid-js";
+import { assign, render, spread } from "@solidjs/web";
+
+// Only `on` + an uppercase letter (`onClick`) is an event handler in 2.0.
+// Lowercase `on*` names are plain attributes: compiled, spread and assigned.
+
+const warnings = (spy: { mock: { calls: unknown[][] } }) =>
+ spy.mock.calls
+ .map(args => String(args[0]))
+ .filter(m => m.includes("[LOWERCASE_EVENT_ATTRIBUTE]"));
+
+describe("lowercase on* names are attributes", () => {
+ let container: HTMLDivElement;
+ let dispose: (() => void) | undefined;
+
+ afterEach(() => {
+ dispose?.();
+ dispose = undefined;
+ container?.remove();
+ vi.restoreAllMocks();
+ });
+
+ function mount(fn: () => any) {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ dispose = render(fn, container);
+ return container.firstElementChild as HTMLElement;
+ }
+
+ test("a compiled lowercase on* expression sets the attribute and binds no event", () => {
+ const code = "console.log('hi')";
+ const button = mount(() => (
+
+ x
+
+ ));
+ expect(button.getAttribute("onclick")).toBe(code);
+ expect((button as any)._$$click).toBeUndefined();
+ });
+
+ test("a dynamic lowercase on* expression is a reactive attribute", () => {
+ const [code, setCode] = createSignal("first()");
+ const button = mount(() => (
+
+ ));
+ expect(button.getAttribute("onmouseover")).toBe("first()");
+ setCode("second()");
+ flush();
+ expect(button.getAttribute("onmouseover")).toBe("second()");
+ setCode(undefined);
+ flush();
+ expect(button.hasAttribute("onmouseover")).toBe(false);
+ });
+
+ test("camelCase handlers are unchanged beside a lowercase attribute", () => {
+ const clicked = vi.fn();
+ const button = mount(() => (
+
+ ));
+ expect(button.getAttribute("onclick")).toBe("noop()");
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ expect(clicked).toHaveBeenCalledTimes(1);
+ });
+
+ test("spread: a lowercase on* key sets the attribute, a camelCase key binds the event", () => {
+ const clicked = vi.fn();
+ const [props, setProps] = createSignal>({ onclick: "run()" });
+ const div = mount(() =>
);
+ expect(div.getAttribute("onclick")).toBe("run()");
+ expect((div as any)._$$click).toBeUndefined();
+
+ setProps({ onClick: clicked });
+ flush();
+ expect(div.hasAttribute("onclick")).toBe(false);
+ div.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ expect(clicked).toHaveBeenCalledTimes(1);
+ });
+
+ test("spread and assign: a non-delegated lowercase on* key adds no listener", () => {
+ const el = document.createElement("input");
+ const listen = vi.spyOn(el, "addEventListener");
+ assign(el, { oninput: "update()" });
+ expect(el.getAttribute("oninput")).toBe("update()");
+ expect(listen).not.toHaveBeenCalled();
+
+ const other = document.createElement("div");
+ const listenOther = vi.spyOn(other, "addEventListener");
+ spread(other, { onscroll: "track()" });
+ flush();
+ expect(other.getAttribute("onscroll")).toBe("track()");
+ expect(listenOther).not.toHaveBeenCalled();
+ });
+});
+
+describe("LOWERCASE_EVENT_ATTRIBUTE (dev)", () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ test("a function set on a compiled lowercase on* attribute warns and is not a handler", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const handler = vi.fn();
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const dispose = render(
+ () => (
+
+ ),
+ container
+ );
+ const button = container.firstElementChild as HTMLButtonElement;
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ expect(handler).not.toHaveBeenCalled();
+ expect(button.getAttribute("onclick")).toBe(String(handler));
+ expect(warnings(warn)).toEqual([
+ expect.stringContaining(
+ "[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."
+ )
+ ]);
+ dispose();
+ container.remove();
+ });
+
+ test("reports once per attribute name, through spread and assign alike", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const a = document.createElement("div");
+ const b = document.createElement("div");
+ spread(a, { ondblclick: () => {} });
+ flush();
+ assign(b, { ondblclick: () => {} });
+ expect(warnings(warn)).toHaveLength(1);
+ expect(warnings(warn)[0]).toContain("Use `onDblclick` for event handlers.");
+ });
+
+ test("a function on a removed `on:` name warns with the camelCase handler", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const el = document.createElement("div");
+ assign(el, { "on:custom-thing": () => {} });
+ expect(el.hasAttribute("on:custom-thing")).toBe(true);
+ expect(warnings(warn)).toEqual([
+ expect.stringContaining("`on:custom-thing` received a function")
+ ]);
+ expect(warnings(warn)[0]).toContain("Use `onCustom-thing` for event handlers.");
+ });
+
+ test("string values and camelCase handlers do not warn", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const el = document.createElement("div");
+ assign(el, { onmouseout: "leave()", onMouseOut: () => {} });
+ expect(warnings(warn)).toEqual([]);
+ });
+});
+
+describe("LOWERCASE_EVENT_ATTRIBUTE, in the built artifacts", () => {
+ // Requires a prior `pnpm build`.
+ const read = (file: string) => readFileSync(resolve(import.meta.dirname, "..", file), "utf8");
+
+ test("the check ships in the dev client and folds out of prod and observe", () => {
+ expect(read("dist/web.dev.js")).toContain("LOWERCASE_EVENT_ATTRIBUTE");
+ expect(read("dist/web.js")).not.toContain("LOWERCASE_EVENT_ATTRIBUTE");
+ expect(read("dist/web.observe.js")).not.toContain("LOWERCASE_EVENT_ATTRIBUTE");
+ });
+});
diff --git a/packages/web/test/runtime/head.spec.js b/packages/web/test/runtime/head.spec.js
index 66a8a2cad..d012ba2ca 100644
--- a/packages/web/test/runtime/head.spec.js
+++ b/packages/web/test/runtime/head.spec.js
@@ -228,6 +228,24 @@ describe("useHead client registry", () => {
await tick();
});
+ it("applies lowercase on* as attributes and skips camelCase handler names", async () => {
+ let dispose;
+ createRoot(d => {
+ dispose = d;
+ r.useHead([
+ {
+ tag: "meta",
+ props: { name: "lowercase-on", content: "x", onload: "go()", onLoad: "skipped()" }
+ }
+ ]);
+ });
+ await tick();
+ const meta = document.head.querySelector('meta[name="lowercase-on"]');
+ expect(meta.getAttribute("onload")).toBe("go()");
+ dispose();
+ await tick();
+ });
+
it("re-reads reactive group membership (function form)", async () => {
let dispose, setTags;
createRoot(d => {
diff --git a/packages/web/test/server/lowercase-on-attribute.spec.tsx b/packages/web/test/server/lowercase-on-attribute.spec.tsx
new file mode 100644
index 000000000..7a6637c83
--- /dev/null
+++ b/packages/web/test/server/lowercase-on-attribute.spec.tsx
@@ -0,0 +1,78 @@
+/**
+ * @jsxImportSource @solidjs/web
+ */
+import { describe, expect, test } from "vitest";
+import { renderToString, ssrElement, useHead } from "@solidjs/web";
+
+const noKeys = (html: string) => html.replace(/ _hk=[\w-]+/g, "");
+
+// Only `on` + an uppercase letter (`onClick`) is an event handler in 2.0.
+// Lowercase `on*` names are plain attributes, so the server renders them.
+describe("lowercase on* names on the server", () => {
+ test("a lowercase on* expression renders as an escaped attribute", () => {
+ const code = `say("a & b")`;
+ const html = renderToString(() => (
+
+ x
+
+ ));
+ expect(noKeys(html)).toBe('x ');
+ });
+
+ test("a camelCase handler still renders nothing beside it", () => {
+ const html = renderToString(() => (
+ {}}
+ />
+ ));
+ expect(noKeys(html)).toBe(' ');
+ });
+
+ test("a function value is stringified and escaped like the client's setAttribute", () => {
+ const html = renderToString(() => (
+ console.log("not a handler")}
+ />
+ ));
+ const value = noKeys(html).match(/^<\/button>$/);
+ expect(value).not.toBeNull();
+ expect(value![1]).toContain(""not a handler"");
+ });
+
+ test("useHead renders lowercase on* as attributes and skips camelCase handler names", () => {
+ function Head() {
+ useHead({
+ tag: "meta",
+ props: { name: "lowercase-on", content: "x", onload: "go()", onLoad: "skipped()" }
+ });
+ return null;
+ }
+ const html = renderToString(() => (
+
+
+
+
+
+
+ ));
+ const meta = html.match(/ ]*name="lowercase-on"[^>]*>/);
+ expect(meta).not.toBeNull();
+ expect(meta![0]).toContain('onload="go()"');
+ expect(meta![0]).not.toContain("skipped");
+ });
+
+ test("spread: lowercase on* keys render, camelCase keys do not", () => {
+ const props: Record = { onclick: "run()", onClick: () => {} };
+ const html = renderToString(() =>
);
+ expect(noKeys(html)).toBe('
');
+ expect(ssrElement("div", { oninput: "a {} }, undefined, false).t).toBe(
+ '
'
+ );
+ });
+});