#3429·solid

Static-tag intrinsic: `<element tag={tag}>` for elements whose tag is fixed at definition time

Author: ryansolidCreated Sep 14, 2026Updated Sep 14, 2026
Labelsenhancement

Problem

Libraries that build components around a native element whose tag is fixed at definition time — styled.li, styled("button"), any design-system primitive factory — have no good way to render that element:

  • dynamic(() => tag) is the only isomorphic path from a tag string to an element. It pays a factory memo plus a per-instance memo (and a hydration id) for a value that never changes. In yak-bench's browser hydrate matrix, the lane that renders the styled element through dynamic() is 1.47× slower (geomean) than the same code with the element compiled from a literal tag; everything else in the two lanes is identical. One memo per element is the entire gap.
  • A runtime factory (element(tag) returning a component) removes the memos but can't take spreads natively: a use-site {...rest} has to be merged into a props object and crossed through a component boundary before spread() sees it, and static attributes are walked instead of compiled.
  • Hand-rolling it means an isServer fork over internals (getNextElement, runHydrationEvents, sharedConfig.hydrating vs ssrElement), which is what next-yak/next-yak#644 did.

Profiling the composition-heavy SSR cases (tabs) shows Solid's own rendering at ~7% of time and props plumbing (merge, omit, proxies, and the GC they generate) at ~55%; the styled-element seam is where most of that plumbing is introduced.

Proposal

A reserved intrinsic whose tag comes from an in-scope identifier:

typescript
function styled(tag) {
  return props => (
    <element tag={tag} {...filtered(props)} class={cls(props)}>
      {props.children}
    </element>
  );
}

The compiler lowers it exactly as it would a literal tag, with createElement(tag) in place of the template clone:

  • DOM: hydrating ? getNextElement() : createElement(tag); spread(el, [filtered, { get class() {…} }]) (the sources array from #3419/#3423); insert(el, () => props.children); runHydrationEvents() when hydrating. Two reactive nodes with children, one without, zero memos. Static child subtrees compile to their own templates and are appended; dynamic children go through insert.
  • SSR: ssrElement(tag, [filtered, { class }], () => props.children, true) — one walk (#3418), _hk on the element. Hydration ids stay aligned because both sides do exactly what a compiled spread root does today.
  • Universal: renderer createElement(tag) + spread.

The string element never reaches the DOM or the SSR output; the compiler consumes it. <element> without tag is a compile error, not a fall-through to a real tag.

Static rule

tag is an expression evaluated once, untracked, when the element is created — the same contract as <Provider value>. It is never reactive, and the compiler does not try to prove it constant (a binding in an enclosing scope can be reassigned; constancy isn't globally provable, so a scope rule would restrict real code while guaranteeing nothing). The lowering reads it exactly once (createElement(tag) / ssrElement(tag, …)); later changes to whatever it was read from are ignored, as with value.

  • <element> without tag is a compile error, not a fall-through to a real tag.
  • dynamic() / <Dynamic component> remain the answer for a tag that can change; the per-instance memo is the price of being dynamic. The two features stay vocabulary-distinct (asDynamic, tagelement), and the IntrinsicElements.element doc comment states the read-once rule so it shows on hover.

Why not <element:tag>

The namespaced form was considered first — it has the nice property that JSXNamespacedName can only hold a bare identifier, so props.as is syntactically impossible. Both parsers accept it and TS 6.0.3 types it through a template-literal index signature. But TS treats element:tag as the string "element:tag": the variable is never bound, so noUnusedLocals/noUnusedParameters flag tag (TS6133, verified), unbound identifiers aren't errors, and rename/go-to-definition are blind. That is exactly the use: directive experience. Putting the tag in an expression position (tag={tag}) gives TS the binding; the read-once rule is a documented contract, as it is for <Provider value>.

Naming

  • element: not in HTML, SVG, or MathML, nor in Solid's IntrinsicElements or either compiler's tag tables. The only history is the Web Components v0 declarative <element name="x-foo">, removed from the spec in August 2013 and never shipped unflagged. Custom elements can't claim it (hyphen required).
  • tag, not name: name is a real attribute on the elements most likely to be wrapped (input, button, select, textarea, form, iframe, …) and is routinely forwarded through the spread; there is no HTML/SVG/MathML attribute called tag. is is out for the same reason (customized built-ins; forwarded).

Typing

IntrinsicElements.element: HTMLAttributes<HTMLElement> & { tag: string }. Necessarily generic (no per-tag narrowing — the tag is a variable), ref is HTMLElement. Attribute checking, children, and ref all work under --strict; the doc comment carries the static rule so it shows on hover.

Non-goals

  • Compile-time folding of fully static styled elements into strings (what yak does for its React build). Separate question.
  • A reactive or async tag. That is Dynamic.
  • A runtime element(tag) factory. Not needed once the syntax exists; h/html already take string tags.

Plan

Babel + Oxc: DOM, SSR, universal lowering (missing tag is an error), shared test expectations (parity). IntrinsicElements.element typing. Parity-harness hydration scenario (server ssrElement ↔ client getNextElement + spread sources). yak-bench prim-element overlay switched to it for the before/after against the dynamic() lane.

Claude via Cursor