Skip to content

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

Description

@ryansolid

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:

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:

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions