Static-tag intrinsic: `<element tag={tag}>` for elements whose tag is fixed at definition time
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 throughdynamic()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 beforespread()sees it, and static attributes are walked instead of compiled. - Hand-rolling it means an
isServerfork over internals (getNextElement,runHydrationEvents,sharedConfig.hydratingvsssrElement), 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:
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 throughinsert. - SSR:
ssrElement(tag, [filtered, { class }], () => props.children, true)— one walk (#3418),_hkon 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>withouttagis 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 (as→Dynamic,tag→element), and theIntrinsicElements.elementdoc 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'sIntrinsicElementsor 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, notname:nameis 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 calledtag.isis 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 isDynamic. - A runtime
element(tag)factory. Not needed once the syntax exists;h/htmlalready 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
Source: solidjs/solid