You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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 (as → Dynamic, tag → element), 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.
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.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.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:
The compiler lowers it exactly as it would a literal tag, with
createElement(tag)in place of the template clone:hydrating ? getNextElement() : createElement(tag);spread(el, [filtered, { get class() {…} }])(the sources array from perf(web): spread() creates fewer reactive nodes and accepts a sources array #3419/feat(compiler): emit the spread/ssrElement sources array for multi-source element spreads #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.ssrElement(tag, [filtered, { class }], () => props.children, true)— one walk (feat(web): ssrElement accepts an array of prop sources and a skip predicate #3418),_hkon the element. Hydration ids stay aligned because both sides do exactly what a compiled spread root does today.createElement(tag)+spread.The string
elementnever reaches the DOM or the SSR output; the compiler consumes it.<element>withouttagis a compile error, not a fall-through to a real tag.Static rule
tagis 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 withvalue.<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
JSXNamespacedNamecan only hold a bare identifier, soprops.asis syntactically impossible. Both parsers accept it and TS 6.0.3 types it through a template-literal index signature. But TS treatselement:tagas the string"element:tag": the variable is never bound, sonoUnusedLocals/noUnusedParametersflagtag(TS6133, verified), unbound identifiers aren't errors, and rename/go-to-definition are blind. That is exactly theuse: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),refisHTMLElement. Attribute checking, children, andrefall work under--strict; the doc comment carries the static rule so it shows on hover.Non-goals
tag. That isDynamic.element(tag)factory. Not needed once the syntax exists;h/htmlalready take string tags.Plan
Babel + Oxc: DOM, SSR, universal lowering (missing
tagis an error), shared test expectations (parity).IntrinsicElements.elementtyping. Parity-harness hydration scenario (serverssrElement↔ clientgetNextElement+spreadsources). yak-benchprim-elementoverlay switched to it for the before/after against thedynamic()lane.— Claude via Cursor