May 29, 2026
Simplifying TSRX
We listened to your feedback, TSRX is becoming smaller, clearer, and easier to debug.
PR#1177is a big cleanup pass for TSRX. The goal is simple: keep the parts that make UI authoring nicer, and remove the parts that made the language feel unfamiliar.
This came from feedback from humans and companies experimenting with TSRX. It also came from evaluations of AI-generated output. The same pattern kept showing up: people and tools liked inline UI structure, but got confused by new concepts when debugging, especially the lack of a normal return statement, early-return magic, and too many TSRX-only syntax points.
Components are just functions
Before this cleanup, TSRX introduced its owncomponentform. It removed the visible return statement, which made components look less like
ordinary TypeScript.
1 component Profile({ user }: { user: User | null }) {
2 if (!user) {
3 return;
4 }
5
6 const displayName = user.name.trim();
7
8 if (user.isAdmin) {
9 <p>{displayName} can manage settings.</p>
10 }
11
12 const title = <tsx><strong>{displayName}</strong></tsx>;
13 <Card {title} />
14 }Now TSRX is an expression you return from an ordinary function. A component can return a single element directly when that is all it needs.
1 function Button({ label, onClick }: Props) {
2 return <button {onClick}>{label}</button>;
3 }We also removed the plain<tsx>island. TSRX itself is expression-based now: native elements and native fragments can
be assigned, passed as props, or returned directly. The old<tsrx>wrapper is not needed either because TSRX is already the default in.tsrxfiles.
1 function Profile({ user }: { user: User }) {
2 const displayName = user.name.trim();
3 const title = <strong>{displayName}</strong>;
4
5 return <Card {title} />;
6 }Native fragments offer the same benefits<tsx>was meant to provide. Keeping multiple ways to do the same thing did not add much once
one path was clearer, more consistent, and easier for tools to explain.
When you return a fragment, its body is statement-driven: you can declare values,
branch withif, loop withfor...of, and emit UI in order.
1 function Dashboard({ items }: Props) {
2 return <>
3 const visibleItems = items.filter((item) => !item.hidden);
4
5 <ul>
6 for (const item of visibleItems; key item.id) {
7 <li>{item.label}</li>
8 }
9 </ul>
10
11 try {
12 <ActivityFeed />
13 } pending {
14 <p>Loading activity...</p>
15 } catch (error) {
16 <p>Could not load activity.</p>
17 }
18 </>;
19 }All the useful TSRX control flow remains as-is: lists can still be rendered withfor...of, async boundaries can still usetry / pending / catch, and the template still reads in the order the UI is produced.
No more return magic inside templates
The earliercomponentmodel also allowed template bodies to bail out with a return. That looked convenient,
but it made debugging strange because UI output and component exit behavior were mixed
together.
1 component Profile({ user }: { user: User | null }) {
2 if (!user) {
3 return;
4 }
5
6 <h1>{user.name}</h1>
7 }Now guard returns live before TSRX opens, just like normal TypeScript and JSX. Inside
templatefor...ofloops,continuestill skips the current item.
1 function Profile({ user }: { user: User | null }) {
2 if (!user) {
3 return null;
4 }
5
6 return <h1>{user.name}</h1>;
7 }React and Preact still get one useful target-specific compiler pass here. If a guard return appears before a hook, TSRX can keep the source simple while compiling the hook path into a shape that preserves hook ordering.
1 function Profile({ user }: { user: User | null }) {
2 if (!user) {
3 return null;
4 }
5
6 useEffect(() => {
7 trackProfile(user.id);
8 }, [user.id]);
9
10 return <ProfileCard {user} />;
11 }Fewer special cases
Ripple had accumulated a few TSRX-only shortcuts inside the oldcomponentshape: raw HTML blocks, ref blocks, and style class expressions. They worked, but each
one was another rule to remember and another thing for tools to explain.
1 component Article({ contentHtml, inputRef }: Props) {
2 <article>{html contentHtml}</article>
3 {html contentHtml}
4 <input {ref inputRef} />
5 <Child class={style 'card'} />
6
7 <style>
8 .card { padding: 1rem; }
9 </style>
10 }Those are now regular props. Ripple, Solid, and Vue useinnerHTMLon DOM elements. Ripple and Solid can also use<Fragment innerHTML={...} />when you want to insert HTML without a wrapper element. React and Preact usedangerouslySetInnerHTML. Refs useref={...}, and scoped classes can be shared with children throughconst styles = <style>...</style>.
React TSRX does not rewrite an authoredclassprop intoclassName. UseclassNamewhen your React component expects it; the compiler will not hide that difference for
you.
For scoped styles on React host elements, TSRX still emitsclassNamefor the generated CSS hash. That is generated output, not a rewrite of a class you
wrote yourself.
Removing the oldclasstoclassNamemagic makes generated code easier for humans and LLMs to predict.
The style expression is just a value. Put it before the returned content, then passstyles.cardanywhere that value is in scope.
1 function Article({ contentHtml, inputRef }: Props) {
2 const styles = <style>
3 .card { padding: 1rem; }
4 </style>;
5
6 return <>
7 // Ripple, Solid, and Vue host elements
8 <article innerHTML={contentHtml} />
9
10 // Ripple and Solid fragment output
11 <Fragment innerHTML={contentHtml} />
12
13 // React and Preact host elements
14 <article dangerouslySetInnerHTML={{ __html: contentHtml }} />
15
16 <input ref={inputRef} />
17
18 // Ripple, Preact, Solid, and Vue component props
19 <Child class={styles.card} />
20
21 // React component props
22 <Child className={styles.card} />
23 </>;
24 }Styling stays optional
The<style>block is still there, but its mental model is clearer: styles are scoped to the TSRX
fragment that contains them, not to a separate component declaration form. It works
the same way as before for colocated styles.
Inside<style>, write static CSS. The TSRX rules for JavaScript statements and expressions inside
elements do not apply there: noif,for, or{expr}. When a style value needs to change at runtime, set a CSS custom property on the
element and read it withvar(...).
1 function Card({ title, tone }: Props) {
2 return <>
3 <article class="card" style={{ '--card-tone': tone }}>
4 <h2>{title}</h2>
5 </article>
6
7 <style>
8 .card {
9 padding: 1rem;
10 border-radius: 0.75rem;
11 border-color: var(--card-tone);
12 }
13 </style>
14 </>;
15 }When you move styles into a value before the returned template, the model is
intentionally more likeStyleXand other popular CSS-in-JS solutions: the CSS is still static, but you get a
declarative class map, and you pass those class names explicitly where you need them.
1 const styles = <style>
2 .card { padding: 1rem; }
3 .title { font-weight: 700; }
4 </style>;
5
6 function ArticleCard({ title }: Props) {
7 // Ripple, Preact, Solid, and Vue host elements:
8 return <article class={styles.card}>
9 <h2 class={styles.title}>{title}</h2>
10 </article>;
11
12 // React host elements:
13 // return <article className={styles.card}>
14 // <h2 className={styles.title}>{title}</h2>
15 // </article>;
16 }This is optional. If your team prefers Tailwind, CSS modules, vanilla CSS, or a design
system class API, keep using that. TSRX does not require colocated<style>blocks.
1 function Card({ title }: Props) {
2 return <article class="rounded-xl p-4">{title}</article>;
3 }Why this matters
TSRX should make UI code easier to read, not harder to reason about. These changes make the language closer to TypeScript, closer to JSX, and easier for both people and AI tools to debug.
The result is a smaller language with the same core idea: open TSRX from an expression, then write clear statement-based UI inside it.