Summary
When a JSX element is given a key={...} whose string value contains any of Qwik's vnode-data encoder chars (VNodeDataChar), the SSR-emitted vnode-data string places the literal value after @ (the KEY directive) without escaping. The browser-side decoder then mis-parses the very next reserved char inside that value as a new directive, causing cascading lookups against bogus vnode-ref ids and finally one of two asserts:
Internal assert, this is likely caused by a bug in Qwik: Missing refElement. (in vnode_locate, after parseInt('layer') β NaN β qVNodeRefs.get(NaN) === undefined)
Internal assert, this is likely caused by a bug in Qwik: Did not materialize. (in ensureMaterialized, downstream of the broken locate)
The errors look like Qwik bugs (the assert message literally says "this is likely caused by a bug in Qwik") rather than user code, so the cause is non-obvious.
Reproduction
import { component$ } from '@qwik.dev/core';
export default component$(() => {
const items = [
{ href: '/foo?bar=baz', label: 'A' },
{ href: '/foo?bar=qux', label: 'B' },
];
return (
<ul>
{items.map((item) => (
<li key={item.href}>{item.label}</li>
))}
</ul>
);
});
Render this server-side, then trigger any chore that walks the subtree (e.g. signal mutation that causes a re-render of an ancestor). Console fires the two asserts above.
Workaround: replace key={item.href} with key={item.label} (or any value that contains no encoder-reserved chars). That fully eliminates the asserts.
Root cause
-
SSR emits q:vnode data with the @ directive (VNodeDataChar.KEY = 64) followed by the raw key value:
...@/some/path?layer=silver`965^412[966|...
-
Client-side consumeValue() (core.mjs, ~L8296) reads chars while they satisfy:
peek() <= 58 || peek() === 92 || peek() === 95
|| (peek() >= 65 && peek() <= 90)
|| (peek() >= 97 && peek() <= 122)
? is char 63 β NOT in the accepted set, so it terminates the key value at /some/path.
-
The next iteration sees ? and dispatches into VNodeDataChar.SLOT_PARENT handling (core.mjs, ~L8562):
else if (peek() === VNodeDataChar.SLOT_PARENT) {
...
vParent.slotParent = vnode_locate(container.rootVNode, consumeValue());
}
consumeValue() happily consumes layer (all lowercase, in the accepted set) and stops at = (char 61, also reserved β the ID directive).
-
vnode_locate(rootVNode, 'layer') does parseInt('layer') β NaN, qVNodeRefs.get(NaN) β undefined, asserts Missing refElement.
-
The cascading mis-decode of subsequent directives causes ensureMaterialized to throw Did not materialize on a sibling subtree.
Empirical proof from a real app: dumped document.documentElement.qVnodeData and grep'd for the literal URL inside an @ directive value:
!~1!~!~1!~{1|q:type|C<514>964@/backend/data/tables?layer=silver`965^412[966||376A=300}{1|...
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
literal URL inside @ directive value
Reserved encoder chars (from core.mjs VNodeDataChar)
; (59) SCOPED_STYLE
< (60) RENDER_FN
= (61) ID
> (62) PROPS
? (63) SLOT_PARENT
@ (64) KEY
[ (91) SEQ
\ (92) β backslash escape
] (93) CONTEXT
^ (94) SEQ_IDX
` (96) BACK_REFS
{ (123) OPEN
| (124) SEPARATOR
} (125) CLOSE
~ (126) SLOT
Any of these inside a q:key value is potentially affected.
Suggested fix
In the SSR encoder (wherever @ + key value is emitted), escape any byte in the reserved set. The decoder already supports backslash escape (char 92) inside consumeValue, so the fix can be small:
function encodeKeyValue(v) {
let out = '';
for (let i = 0; i < v.length; i++) {
const ch = v.charCodeAt(i);
if (
(ch >= 59 && ch <= 64) || // ; < = > ? @
(ch >= 91 && ch <= 94) || // [ \ ] ^
ch === 96 || // `
(ch >= 123 && ch <= 126) // { | } ~
) {
out += '\\' + v[i];
} else {
out += v[i];
}
}
return out;
}
Alternatively, validate key values at SSR time and warn loudly if a reserved char is present (loud failure rather than silent corruption).
Why this matters in practice
Using a URL/href as the key is a very common pattern. Any app with <NavLink key={item.href} href={item.href}>... and even one query-string href anywhere in the tree will trigger the asserts.
Environment
@qwik.dev/core 2.0.0-beta.32
@qwik.dev/router 2.0.0-beta.32
- Vite 8.0.10
- Node 22 LTS
- Reproduces in
pnpm dev (SSR + client hydration). Production build not tested.
Summary
When a JSX element is given a
key={...}whose string value contains any of Qwik's vnode-data encoder chars (VNodeDataChar), the SSR-emitted vnode-data string places the literal value after@(theKEYdirective) without escaping. The browser-side decoder then mis-parses the very next reserved char inside that value as a new directive, causing cascading lookups against bogus vnode-ref ids and finally one of two asserts:Internal assert, this is likely caused by a bug in Qwik: Missing refElement.(invnode_locate, afterparseInt('layer')βNaNβqVNodeRefs.get(NaN) === undefined)Internal assert, this is likely caused by a bug in Qwik: Did not materialize.(inensureMaterialized, downstream of the broken locate)The errors look like Qwik bugs (the assert message literally says "this is likely caused by a bug in Qwik") rather than user code, so the cause is non-obvious.
Reproduction
Render this server-side, then trigger any chore that walks the subtree (e.g. signal mutation that causes a re-render of an ancestor). Console fires the two asserts above.
Workaround: replace
key={item.href}withkey={item.label}(or any value that contains no encoder-reserved chars). That fully eliminates the asserts.Root cause
SSR emits
q:vnodedata with the@directive (VNodeDataChar.KEY = 64) followed by the raw key value:Client-side
consumeValue()(core.mjs, ~L8296) reads chars while they satisfy:?is char63β NOT in the accepted set, so it terminates the key value at/some/path.The next iteration sees
?and dispatches intoVNodeDataChar.SLOT_PARENThandling (core.mjs, ~L8562):consumeValue()happily consumeslayer(all lowercase, in the accepted set) and stops at=(char61, also reserved β theIDdirective).vnode_locate(rootVNode, 'layer')doesparseInt('layer')βNaN,qVNodeRefs.get(NaN)βundefined, assertsMissing refElement.The cascading mis-decode of subsequent directives causes
ensureMaterializedto throwDid not materializeon a sibling subtree.Empirical proof from a real app: dumped
document.documentElement.qVnodeDataand grep'd for the literal URL inside an@directive value:Reserved encoder chars (from
core.mjsVNodeDataChar);(59)SCOPED_STYLE<(60)RENDER_FN=(61)ID>(62)PROPS?(63)SLOT_PARENT@(64)KEY[(91)SEQ\(92) β backslash escape](93)CONTEXT^(94)SEQ_IDX`(96)BACK_REFS{(123)OPEN|(124)SEPARATOR}(125)CLOSE~(126)SLOTAny of these inside a
q:keyvalue is potentially affected.Suggested fix
In the SSR encoder (wherever
@+ key value is emitted), escape any byte in the reserved set. The decoder already supports backslash escape (char92) insideconsumeValue, so the fix can be small:Alternatively, validate
keyvalues at SSR time and warn loudly if a reserved char is present (loud failure rather than silent corruption).Why this matters in practice
Using a URL/href as the key is a very common pattern. Any app with
<NavLink key={item.href} href={item.href}>...and even one query-string href anywhere in the tree will trigger the asserts.Environment
@qwik.dev/core2.0.0-beta.32@qwik.dev/router2.0.0-beta.32pnpm dev(SSR + client hydration). Production build not tested.