[🐞] v2: `on*$` handler on a dynamic tag (`<Tag onClick$>` with `Tag = "button"`) is rendered as an attribute instead of a listener
Maintainers usually reply within 1 day
Assessment
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Newbie friendliness
- 25/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Active
- Tech stack
- javascript, typescript
- Domain
- frontend
Research direction
Start in packages/qwik/src/core/shared/jsx/jsx-internal.ts: compare _jsxSorted with _jsxSplit, where on*$ props become q-e:* only when type is a string. Check how the optimizer chooses between them for non-literal tags. Done when a dynamic tag holding 'button' gets a working click listener in both SSR and client render.
Written by the indexing model from the issue text.
Description
Which component is affected?
Qwik Runtime
Describe the bug
If the JSX tag name is a variable that holds an HTML tag string (const Tag = props.tag; <Tag onClick$={...}>), the on*$ handler is never registered as a listener. The element gets a plain attribute holding the handler's stringified source instead. With a literal tag (<button onClick$={...}>) the handler works. In v1 (1.20) both work.
const Dynamic = component$((props: { tag: 'button' | 'a' }) => {
const count = useSignal(0);
const Tag = props.tag as any;
return (
<Tag onClick$={() => count.value++}>
Dynamic tag, click count: {count.value}
</Tag>
);
});
const Literal = component$(() => {
const count = useSignal(0);
return (
<button onClick$={() => count.value++}>
Literal tag, click count: {count.value}
</button>
);
});
Expected: clicking either button increments its count.
Actual (v2): the dynamic-tag button never increments. Its rendered attributes are:
<button :="ga_0" onclick$="async function(...args) {
return qrlCallFn.call(qrlFn, this, ...args); ...">
In v1 the same element gets on:click="...#Dynamic_component_onClick_..." and the click works.
The optimizer can't know at compile time that Tag is an HTML element, so it emits _jsxSorted(tag, { onClick$: ... }, ...) and leaves the prop as onClick$. That is correct when Tag is a component. At runtime, _jsxSorted (jsx-internal.ts#L116) creates the node without converting event props. Only _jsxSplit converts on*$ to q-e:*, and only when typeof type === 'string' (#L161). When the tag turns out to be a string, nothing converts the prop, so it is rendered as an attribute. One possible fix is for _jsxSorted (or the place where an element's props are applied) to convert on*$ props when type is a string. Another is for the optimizer to emit _jsxSplit for non-literal tags that have event props.
This happens with vite dev and with a production build (vite build + vite preview), in SSR output and in client renders. It happens on 2.0.0-rc.4 and on current main (pkg.pr.new/QwikDev/qwik/@qwik.dev/core@main, tested 2026-11-01).
Reproduction
v2 playground (bug): next.qwik.dev/playground
v1 playground (works): qwik.dev/playground
The two playgrounds use the same code. Only the import source differs (@qwik.dev/core vs @builder.io/qwik).
Steps to reproduce
- Open the v2 playground link.
- Click "Dynamic tag" and then "Literal tag".
- The dynamic-tag count stays at
0. The literal-tag count goes to1. - Inspect the dynamic-tag button. It has an
onclick$attribute containing function source, and no Qwik listener. - Do the same in the v1 playground link. Both counts go up.
System Info
System:
OS: macOS 27.0.1
CPU: (14) arm64 Apple M4 Max
Binaries:
Node: 22.22.0
pnpm: 10.11.1
Browsers:
Chrome: 156.0.8078.74
npmPackages:
@qwik.dev/core: https://pkg.pr.new/QwikDev/qwik/@qwik.dev/core@main => 2.0.0-rc (also 2.0.0-rc.4)
@qwik.dev/router: https://pkg.pr.new/QwikDev/qwik/@qwik.dev/router@main => 2.0.0-rc (also 2.0.0-rc.4)
typescript: 5.9.3
vite: 8.3.5
Additional Information
I hit this while porting a sidenav component from v1 to v2. One component renders either <mat-drawer> or <mat-sidenav> through a variable host tag. Its onKeyDown$, which closes the drawer on Escape, stopped working.
Workaround: register the listener with useOn('keydown', ...). That attaches it to the component's host element without going through JSX props.
- Dominant language
- TypeScript
- Stars
- 22.1k
- Forks
- 1.4k
- Avg merge
- 2d 5h
- Merged PRs (30d)
- 99
Getting set up
Starts the project's dev container in your browser, under your own GitHub account.
- Ships a Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from QwikDev/qwik
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
docs router V2
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
Maintainers usually reply within 1 day
-
DX enhancement V2
Difficulty 2/5 1-3 hours Newbie friendliness 64/100
QwikDev/qwik#8480 · 2 comments ·
Maintainers usually reply within 1 day
-
[🐞] v2: bare boolean attribute (`<button disabled>`) on a client-created element becomes `disabled="true"`Possibly taken @intellix claimed this 1 day ago. Open
Difficulty 2/5 1-3 hours Newbie friendliness 25/100
Maintainers usually reply within 1 day
-
[🐞] Function `ref` runs before the element is connected, has its later attributes, or has childrenOpen
Difficulty 4/5 3-5 days Newbie friendliness 30/100
Maintainers usually reply within 1 day
Similar issues
-
area: cli bug flake ready-for-agent
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
gazetta-studio/gazetta-studio#1772 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
-
needs-triage
Difficulty 2/5 1-3 hours Newbie friendliness 66/100
microsoft/playwright#43875 · 1 comment ·
Maintainers usually reply within 1 day
-
documentation good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
Maintainers usually reply within 1 day
-
feature
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
calagopus/panel#285 · 1 comment ·
Maintainers usually reply within 1 day