Static-tag intrinsic: `<element tag={tag}>` for elements whose tag is fixed at definition time
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 36.1k
- Forks
- 1.1k
- Avg merge
- 9h 18m
- Merged PRs (30d)
- 195
Description
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
Contributor 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.
Research direction
Start with the Babel and Oxc compiler entry points and the existing DOM, SSR, and universal lowering for literal tags and spread roots. Trace the related hydration parity tests and IntrinsicElements typings; done means the tag form is validated and lowered consistently, missing tags fail, typing is strict, and the hydration scenario passes across all renderers.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- babel, typescript
- Domain
- compilers, frontend, testing-qa
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100