solidjs / solidjs/solid

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

Open
#3,429 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement
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 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:

  • 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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.