Components

Templates

The JSX a component returns is its template. Its expressions, text, conditionals, lists and fragments compile to each framework's own template syntax.

A component returns its template: one element, or one fragment that holds several. The compiler lowers that JSX to each framework's template. Vue, Svelte, Angular and Astro have template languages of their own, so the compiler accepts the JSX that all seven read alike, and reports anything else with a code that names the reason.

A component is an exported function declaration. One written as an arrow function in a const is UF1102, with a fix that declares it as a function. A returned conditional or list is UF1102 too, with a fix that wraps it in <>…</>, which renders the same, and so is an empty fragment.

Expressions

An expression in braces can be a child, an attribute's value, a condition, a list's source or key, a part of a class or a style, or the source of a spread. It can read:

  • the component's props;
  • the item and the index of each list around it;
  • the parameters of the arrow functions inside it, as in items.filter((item) => item.done);
  • a few pure globals: undefined, NaN, Infinity, Math, Number, String, Boolean, Array, Object, JSON, parseInt, parseFloat, isNaN, isFinite, encodeURIComponent, decodeURIComponent, encodeURI and decodeURI.

Any other name is UF3020: Angular's templates see only the component's members, and the outputs may run where window does not exist. Setup code (ref, computed, local constants) lands in M2.

A list's or an arrow function's parameter cannot take a name the outputs rewrite or declare (UF3024): a prop's, the props parameter's, an outer list variable's or a global's, props, rawProps, Fragment, or a name that starts with $, or with _ and more (_ alone is accepted). Nor can it take one of Angular's expression keywords, such as as, which its templates cannot read as a name.

OrderSummary.uf.tsx
<h2>Order {String(orderNumber).padStart(6, "0")}</h2>
<p>Customer: {customer.trim().toUpperCase()}</p>
<p>{`${quantity} item${quantity === 1 ? "" : "s"} at ${unitPrice.toFixed(2)} each`}</p>
<p>Subtotal: {(unitPrice * quantity).toFixed(2)}</p>
<p>Discount: {Math.min(Math.max(discount, 0), 50)}%</p>

Each target rewrites the names an expression reads, and keeps the rest as you wrote it:

OrderSummary.tsx
<h2>Order {String(orderNumber).padStart(6, "0")}</h2>
<p>Customer: {customer.trim().toUpperCase()}</p>
<p>{`${quantity} item${quantity === 1 ? "" : "s"} at ${unitPrice.toFixed(2)} each`}</p>
<p>Subtotal: {(unitPrice * quantity).toFixed(2)}</p>
<p>Discount: {Math.min(Math.max(discount, 0), 50)}%</p>

Solid reads a prop as props.name, because its props are reactive getters. Angular's template sits inside a TypeScript template literal, so its output writes a template literal as a concatenation, and it declares each global an expression reads as a member of the component:

order-summary.ts
protected readonly Math = Math;
protected readonly String = String;

Angular's output also writes literals in the spellings its template lexer reads. In a regular expression, for example, ' becomes \x27 and ; becomes \x3b.

The syntax every target reads

  • Literals: strings, decimal numbers, booleans, null, regular expressions and template literals.
  • Member access (a.b, a[b], a?.b), calls and optional calls.
  • Array literals, and object literals with identifier or string keys, both with spreads.
  • Arrow functions, as a call's argument, with plain parameters and an expression body.
  • The unary operators !, -, + and typeof, the arithmetic, equality and comparison operators, &&, || and ??, the conditional operator and parentheses.

Angular's template parser rejects or misreads most of the rest, so it waits as UF1002. Where a rewrite is mechanical, the diagnostic carries a fix:

Not yetWhyFix
An arrow function with a block bodyAngular: "Multi-line arrow functions are not supported"{ return e; } becomes e
Arrow parameters with patterns, defaults, types or restAngular's parser rejects each
as, !, satisfies and type argumentsAngular's templates have no TypeScript syntax
0x10, 0o7 and 0b1Angular: "Unexpected token"The decimal value
\u{…} escapesAngular: "Invalid unicode escape"\uXXXX pairs
Holes in arrays, and Array(…)Angular rejects holes, and holes iterate differently
Computed keys, methods, getters and setters in object literalsAngular rejects them
Names outside ASCIIAngular's lexer reads ASCII only
new and the bitwise operatorsAngular rejects new, and | is its pipe
in, instanceof, tagged templates and BigInt literalsNo case needs them yet

Narrowing

A condition narrows what it tests, as TypeScript does. In the branch of member && …, an optional member is present, so the branch reads member.email:

AccountSummary.uf.tsx
{member && <p>Signed in as {member.email}</p>}

A union narrows the same way, by Array.isArray, typeof or a discriminant:

FilterSummary.uf.tsx
<p>Tags: {Array.isArray(tags) ? tags.join(", ") : tags}</p>
<p>Limit: {typeof limit === "number" ? limit.toFixed(0) : limit.toLowerCase()}</p>
{result.ok ? <p>Found {result.match}</p> : <p>No match: {result.error}</p>}
FilterSummary.tsx
<p>Tags: {Array.isArray(tags) ? tags.join(", ") : tags}</p>
<p>Limit: {typeof limit === "number" ? limit.toFixed(0) : limit.toLowerCase()}</p>
{result.ok ? <p>Found {result.match}</p> : <p>No match: {result.error}</p>}

Every target's checker narrows as TypeScript narrows the source, and the compiler follows the same tests: a test of the value itself, through !, && and ||, a comparison with null, undefined or a value, typeof, a member read through ?., Array.isArray, and a discriminant of a union, as plan.kind === "paid" narrows plan. Where a value can be neither null nor undefined, by its type or by such a test, a?.b and a ?? b do nothing, and Angular's compiler rejects them: UF3023, with a fix that removes the operator.

A destructured prop that a conditional child narrows stays narrowed in that branch's lists and arrow functions, on every target. TypeScript forgets a property's narrowing inside a callback, though, where Angular's loops keep it. So where the checkers read a value apart, a use that relies on a narrowing some of them do not see is UF1002:

  • a member, or a prop of the object form, that a condition outside a list narrows, used inside the list's callback: test it inside the callback;
  • a prop that a conditional inside an expression narrows, used in an arrow function in that expression: read it through ?., or give it a value with ??, as in item.includes(query ?? "");
  • a narrowed prop that a list's key uses, because Angular's track reads the input again: give it a value where it may be absent, as in (count ?? 0) + index.

Testing the value, reading it through ?., giving it a value with ??, comparing it for equality and writing it into a string read alike everywhere, so those uses are accepted. An equality with a value that is no literal (box.inner?.title === label) is a test the compiler does not follow: a ?. or ?? that Angular may then reject is UF1002 too. A relational comparison or a call narrows nothing on any checker, so the source's own type check judges what follows it.

Pure and deterministic

A component renders on the server and again in the browser, and a framework renders when it decides to, sometimes twice. So an expression must not change anything, and must give the same result everywhere:

  • UF3021: no assignment, ++, delete, void, comma operator, await, yield, this, function or class expression, and no call of a method that changes its receiver (sort, reverse, splice, push, …). The fix writes toSorted, toReversed and toSpliced.
  • UF3019: no Date, Intl, crypto, performance, globalThis or Math.random, and no locale methods (toLocaleString, localeCompare, …). Pass such a value in as a prop.

Text

An expression child renders its value as text: a string as it is, and a number as String(n). null and undefined render nothing, and so do {""} and a comment, {/* … */}.

A boolean, an array or an object is UF3016: JSX and the template languages render them differently. Turn the value into text first: done ? "Done" : "Open", tags.join(", "), or a member of the object.

Whitespace

Text between tags follows JSX's rules: each line is trimmed, lines that hold only whitespace are dropped, and the lines that remain join with one space. Character references decode as JSX decodes them: the named references of XHTML 1.0 (&amp;, &nbsp;) and numeric ones. A reference only HTML knows, such as &check;, stays as written and warns (UF3011).

A string literal child is a JavaScript string: it is neither trimmed nor decoded. Write a significant space or line break as one, {" "} or {"\n"}:

ShippingLabel.uf.tsx
<span class="shipping-label-city">{city}</span>{" "}
<span class="shipping-label-postcode">{postcode}</span>{" "}
<span class="shipping-label-country">United Kingdom</span>

Each target writes that space the way its compiler keeps it:

ShippingLabel.tsx
<span className="shipping-label-city">{city}</span>{" "}
<span className="shipping-label-postcode">{postcode}</span>{" "}
<span className="shipping-label-country">United Kingdom</span>

Each output breaks its lines only where its framework drops the whitespace. Svelte keeps the whitespace between tags, so its output closes a tag at the start of the next line (</p and then ><p>), which adds no text to the page.

A line feed that starts the text of a <pre>, a <textarea> or a <listing> is UF3017: the HTML parser drops it, and React's server renderer adds one. So is a line feed after a conditional or a list that can render nothing: when it does, React's and Astro's servers write nothing before the text, and the other targets a comment.

Escaping

Text and strings render exactly as written, on every target. The compiler escapes what a template language would read as its own syntax, such as {{, {#if}, @if or <:

TemplateTip.uf.tsx
<p>Mustache: {"{{ " + path + " }}"}</p>
TemplateTip.tsx
<p>Mustache: {"{{ " + path + " }}"}</p>

Vue would end the interpolation at the first }}, so its output writes the braces as escapes. Angular's template is a TypeScript template literal, so its backslashes are doubled.

Conditionals

c && <A /> renders <A /> when c is truthy. The test is truthiness, as v-if does it: 0, "" and NaN render nothing, never the value. Plain JSX would render the 0 on React, Solid and Astro, but the source means what v-if means, on every target.

InboxSummary.uf.tsx
{unread && <p>{unread} unread</p>}
InboxSummary.tsx
{unread ? <p>{unread} unread</p> : null}

c ? <A /> : <B /> chooses between two branches, and a conditional in the else branch extends the chain:

OrderStatus.uf.tsx
export interface OrderStatusProps {
  status: "pending" | "shipped" | "delivered" | "cancelled";
  carrier?: string;
  eta?: string;
}

export default function OrderStatus({ status, carrier, eta }: OrderStatusProps) {
  return (
    <p class="order-status">
      {status === "pending" ? (
        "Waiting for payment."
      ) : status === "shipped" ? (
        <>
          <strong>Shipped</strong> with {carrier ?? "our courier"}, arriving {eta ?? "soon"}.
        </>
      ) : status === "delivered" ? (
        <strong>Delivered</strong>
      ) : (
        <em>Cancelled</em>
      )}
    </p>
  );
}
OrderStatus.tsx
export interface OrderStatusProps {
  status: "pending" | "shipped" | "delivered" | "cancelled";
  carrier?: string;
  eta?: string;
}

export default function OrderStatus({ status, carrier, eta }: OrderStatusProps) {
  return (
    <p className="order-status">
      {status === "pending" ? (
        "Waiting for payment."
      ) : status === "shipped" ? (
        <>
          <strong>Shipped</strong> with {carrier ?? "our courier"}, arriving {eta ?? "soon"}.
        </>
      ) : status === "delivered" ? (
        <strong>Delivered</strong>
      ) : (
        <em>Cancelled</em>
      )}
    </p>
  );
}

A branch can be text, an element or several nodes in a fragment. A conditional between two strings, c ? "a" : "b", has no JSX, so it is an expression: it renders text.

A branch often reads what its condition tests. Solid's <Show> and <Match> take the condition as a prop, by which TypeScript does not narrow their children, so Solid's output hands the narrowed value to the branch through a keyed callback: the value itself, or an object of the values the branch reads, built inside the source's own condition and destructured. A keyed branch renders again when its value changes:

AccountSummary.uf.tsx
{member && <p>Signed in as {member.email}</p>}
{member ? <p>Role: {member.admin ? "administrator" : "member"}</p> : <p>Role: guest</p>}
{!member ? <p>No profile</p> : <p>Profile of {member.name}</p>}
{member && member.team && <p>Team: {member.team.name}</p>}
{uptime !== undefined && <p>Uptime {uptime.toFixed(1)}%</p>}
AccountSummary.tsx
<Show keyed when={props.member}>
  {(member) => <p>Signed in as {member.email}</p>}
</Show>
<Show keyed when={props.member} fallback={<p>Role: guest</p>}>
  {(member) => <p>Role: {member.admin ? "administrator" : "member"}</p>}
</Show>
<Show keyed when={props.member} fallback={<p>No profile</p>}>
  {(member) => <p>Profile of {member.name}</p>}
</Show>
<Show
  keyed
  when={props.member && props.member.team ? { team: props.member.team } : undefined}
>
  {({ team }) => <p>Team: {team.name}</p>}
</Show>
<Show keyed when={props.uptime !== undefined ? { uptime: props.uptime } : undefined}>
  {({ uptime }) => <p>Uptime {uptime.toFixed(1)}%</p>}
</Show>

x || <B /> and x ?? <B /> are UF3025: they render either the value of x or an element, which no template language writes as one conditional. Write x ? x : <B /> for ||, and x != null ? x : <B /> for ??, which renders 0 and "" as ?? does. Where x is a prop rendered as text, the fix writes it.

Lists

A list is a .map child whose callback returns one element with a key:

StepList.uf.tsx
export interface StepListProps {
  title: string;
  steps: string[];
}

export default function StepList({ title, steps }: StepListProps) {
  return (
    <ol class="step-list" aria-label={title}>
      {steps.map((step, index) => (
        <li key={index} data-step={index + 1}>
          Step {index + 1}: {step}
        </li>
      ))}
    </ol>
  );
}
StepList.tsx
export interface StepListProps {
  title: string;
  steps: string[];
}

export default function StepList({ title, steps }: StepListProps) {
  return (
    <ol className="step-list" aria-label={title}>
      {steps.map((step, index) => (
        <li key={index} data-step={index + 1}>
          Step {index + 1}: {step}
        </li>
      ))}
    </ol>
  );
}
  • The callback is an arrow function with one or two plain parameters: the item, and its index. Read the item's members as item.name, rather than destructuring it, and an entry of Object.entries as entry[0] and entry[1] (UF3015).
  • It returns one element. Filter out an item that renders nothing first: items.filter((item) => item.done).map(…). An item that renders one element or another keeps one keyed element that holds the conditional, and several elements are wrapped in one.
  • The element has a key that reads the item or its index (UF3013, UF3018). It is a string or a number, unique in the list. It is written key, in lower case: in JSX, KEY is an attribute (UF3004, with a fix). A second key is UF3007, and key anywhere else is UF3014.
  • The source is an array. When it can be absent, default it: (items ?? []).map(…). For items?.map(…), the compiler reports UF3018 with a fix that writes the default, or, where items is never absent, UF3023 with a fix that writes .map.
BuildLog.uf.tsx
{(warnings ?? []).map((warning) => (
  <li key={warning}>{warning}</li>
))}
BuildLog.tsx
{(warnings ?? []).map((warning) => (
  <li key={warning}>{warning}</li>
))}

Each target writes its own list. Solid's <For> takes no key, and Astro renders on the server only, so its output leaves the key out. An unread index is left out too.

A list renders its items' content in the array's order on every target. Keeping each item's DOM element when the array is reordered is not yet guaranteed: Solid keys its items by reference, and Astro has no client.

Fragments

A fragment groups nodes without an element. It can be the component's root, a branch of a conditional, or a run of children, which it flattens into its parent:

ArticleHeader.uf.tsx
export interface ArticleHeaderProps {
  title: string;
  subtitle?: string;
  draft: boolean;
  tags: string[];
}

export default function ArticleHeader({ title, subtitle, draft, tags }: ArticleHeaderProps) {
  return (
    <>
      <h2>{title}</h2>
      {subtitle && <p class="article-subtitle">{subtitle}</p>}
      {draft ? (
        <>
          <p class="article-badge">Draft</p>
          <p>Only editors can see this article.</p>
        </>
      ) : (
        <>
          <p class="article-badge">Published</p>
          <p>Everyone can read this article.</p>
        </>
      )}
      <p>
        Tagged <>{tags.join(", ")}</>
      </p>
    </>
  );
}

Each target groups a branch's nodes its own way:

ArticleHeader.tsx
{draft ? (
  <>
    <p className="article-badge">Draft</p>
    <p>Only editors can see this article.</p>
  </>
) : (
  <>
    <p className="article-badge">Published</p>
    <p>Everyone can read this article.</p>
  </>
)}

On Angular, a root fragment renders inside the component's host element, which is styled display: contents (see Targets).

Where nodes can go

The HTML parser repairs some markup, and server-rendered HTML then differs from a client's DOM. The nesting rules (UF3003) look through conditionals, lists and fragments:

  • rows that a .map renders directly in a <table> would get a <tbody>: write it;
  • an expression renders text, so it cannot sit where text cannot, such as a table part, a <select> or a <datalist>;
  • a <textarea>'s content is its value, so it holds only static text until form state lands in M3;
  • an <iframe>'s content is raw text, which the servers would escape and a browser never shows: remove it.

Values outside the contract

The outputs render the same DOM for every value a prop's type allows, with these exceptions. Avoid them: the compiler cannot see them, and the targets render them differently.

  • Two items with the same key in one list, and arrays with holes.
  • NaN and the infinities, wherever they would render: as text, in an attribute or in a style. (As a condition, NaN is falsy everywhere.)
  • A number outside a numeric attribute's range, such as rows={0}.
  • An empty string bound to a URL attribute, and a bound javascript: or data: URL.
  • A string that starts with a line feed as the first child of a <pre> or a <listing>, or after an expression that renders "" there.
  • A class name that two parts of one class both produce.
  • A style value that holds ; or !important.
  • An object passed to a spread with keys its type does not declare: they are not rendered.

The decisions behind this page are ADR-0002, ADR-0030, ADR-0035 and ADR-0036.

Copyright © 2026