Skip to content

[🐞] V2 SSR: q:key containing reserved encoder chars (?, =, @, etc.) corrupts client vnode-data β†’ "Missing refElement" / "Did not materialize"Β #8591

@lapinponpin

Description

@lapinponpin

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

  1. SSR emits q:vnode data with the @ directive (VNodeDataChar.KEY = 64) followed by the raw key value:

    ...@/some/path?layer=silver`965^412[966|...
    
  2. 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.

  3. 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).

  4. vnode_locate(rootVNode, 'layer') does parseInt('layer') β†’ NaN, qVNodeRefs.get(NaN) β†’ undefined, asserts Missing refElement.

  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions