# Ripple - AI/LLM Documentation
## Overview
Ripple is a TypeScript-first UI framework and runtime for `.tsrx` files. TSRX is
the shared source language; Ripple is the target that provides fine-grained
reactivity, DOM rendering, server modules, hydration, context, portals, and
reactive collections.
When answering Ripple questions:
- Prefer `.tsrx` component examples.
- Return direct JSX for single-root components: `function Component(props) { return
; }`.
- Use JSX statement containers (`@{...}`) when TypeScript setup belongs next to the rendered output.
- Use JSX text for static text and `{expr}` for dynamic values.
- When a statement container mixes TypeScript setup with rendered output, put
setup first and finish it with one JSX element, JSX fragment, or JSX
control-flow expression. It cannot finish with a bare expression container,
and script statements cannot appear after the final output.
- Use `@if`, `@for`, `@switch`, and `@try` for rendered control flow. Their bodies
are implicit statement containers and must use `{...}` blocks.
- Keep runtime API guidance Ripple-specific, but keep syntax guidance aligned
with TSRX.
For target-neutral TSRX syntax, see https://tsrx.dev/llms.txt.
## Install
```bash
npm install ripple @ripple-ts/vite-plugin
```
```ts
// vite.config.ts
import { defineConfig } from 'vite';
import { ripple } from '@ripple-ts/vite-plugin';
export default defineConfig({
plugins: [ripple()],
});
```
Mount client apps with `mount()`.
```ts
import { mount } from 'ripple';
import { App } from './App.tsrx';
mount(App, {
target: document.getElementById('root'),
props: { title: 'Hello world' },
});
```
`mount()` renders the app under a default `try`/`pending`/`catch` boundary.
Pass `rootBoundary: { pending?, catch? }` to give it components, or
`rootBoundary: false` to render without one: `trackAsync()` must then sit inside
a user `@try` block, and errors that escape a `@try` propagate out of the flush.
Use `hydrate()` for server-rendered HTML that should become interactive.
```ts
import { hydrate } from 'ripple';
import { App } from './App.tsrx';
hydrate(App, {
target: document.getElementById('root'),
});
```
## Components
Components are TypeScript functions. Return a JSX element directly when there is
one root, use a fragment for real multiple-child output, and use a JSX statement
container (`@{...}`) when setup statements belong next to the UI.
```tsrx
export function Button({ text, onClick }: { text: string; onClick: () => void }) {
return {text} ;
}
export function App() {
return console.log('saved')} />;
}
```
When setup code and rendered output share the same scope, use `@{...}`. Setup
comes first and the statement container finishes with one output node: a JSX
element, JSX fragment, or JSX control-flow expression.
```tsrx
import { track } from 'ripple';
export function Counter() @{
const count = track(0);
function increment() {
count.value++;
}
Count: {count.value}
}
```
If output needs multiple siblings, text, or expression containers after setup,
wrap that output in a fragment. Plain text between tags is JSX text, not
JavaScript.
Props are plain objects. ` ` builds `{ a: x, b: 'y' }` once,
when the component is called, and passes it as is: every prop expression is
evaluated at that moment, and the object works everywhere a plain object does
(`Object.keys`, spread, `JSON.stringify`, `structuredClone`, `.ts` helpers, a
call like `{Format(props)}`). A prop only changes what the child shows when the
value itself is live: pass the tracked object (`count={count}`) for state the
child may also write, or a derived (`title={track(() => label + count.value)}`,
a `Derived`) for a computed, read-only value. The child reads `.value`
in both cases:
```tsrx
import { track, type Derived, type Tracked } from 'ripple';
function Card({ title, count }: { title: Derived; count: Tracked }) @{
{title.value} ({count.value})
}
export function App() @{
const count = track(0);
const label = 'Clicks';
<>
label + ': ' + count.value)} {count} />
count.value++}>More
>
}
```
`count={count.value}` passes the current value, a number, and the child never
sees later changes; the types say which it is, since `number` is a value and
`Tracked` is live. Ordinary destructuring (`{ a, ...rest }`) works on
the plain props object.
```tsrx
export function LiteralText() {
return
x = 123
;
}
```
## Text Inference
Ripple infers locally known primitive text by default, including local primitive
annotations, numeric operators, and unshadowed `String()`, `Number()`, `Boolean()`,
`BigInt()`, and `Date()` calls. Do not require `as string` or `String()` just to
render a number. `Date()` returns text; `new Date()` produces an object.
Project-wide TypeScript checking is opt-in:
```ts
// vite.config.ts
import { defineConfig } from 'vite';
import { ripple } from '@ripple-ts/vite-plugin';
export default defineConfig({
plugins: [ripple({ textTypes: { tsconfig: 'tsconfig.json' } })],
});
```
- Requires TypeScript 5.9.3 or newer and `strictNullChecks` (or `strict: true`).
Resolve the tsconfig path from the Vite project root.
- Proves strings, numbers, bigints, and their unions from imported types,
member reads, function return types, and control-flow narrowing. Unproven
values retain local inference; the option does not force children to text.
- Applies only to production builds without watch mode. Development, HMR, and
watched builds keep local inference.
- The plugin manages the checker and shares its type information with Ripple's
automatic server build. Applications do not need manual compiler calls or a
`createTextTypeProject()` setup. Independently run client/server builds must
use the same option, tsconfig, and unchanged source tree.
- The analysis enables `noUncheckedIndexedAccess` internally without editing the
tsconfig, so potentially missing entries do not become non-null text proofs.
## Reactivity
Create reactive state with `track()`. Reading `.value` subscribes to the
tracked object; assigning to `.value` updates it.
```tsrx
import { effect, track, type Tracked } from 'ripple';
export function Counter() @{
const count = track(0);
const double = track(() => count.value * 2);
effect(() => {
console.log('count changed', count.value);
});
<>
Count: {count.value}
Double: {double.value}
count.value++}>Increment
>
}
function CounterValue({ count }: { count: Tracked }) {
return Shared value: {count.value}
;
}
```
`track(fn)` returns a `Derived`: its `value` is read-only in the types, and a
write to it warns in development. Pass a setter, or `true` in the setter
position, when the derived is meant to accept writes
(`track(() => draft, undefined, true)` is a `WritableDerived`); a write then
holds until the next recompute. A `Tracked` satisfies `Derived` where only reads
are needed, so `trackReadOnly(count)` (equivalent to `track(() => count.value)`;
a read-only derived is returned as it is) is the read-only way to hand tracked
state to a child.
Use `untrack(fn)` when an effect or derived value needs to read something without
subscribing to it.
Use `snapshot(value)` to take a plain, detached shallow copy of a reactive array
or object. Values are read without subscribing, so it is safe to call inside an
effect or derived. The copy is a plain array or object (not a proxy); nested
values are shared by reference.
```tsrx
import { RippleObject, snapshot } from 'ripple';
export function Settings() @{
const settings = new RippleObject({ theme: 'dark', fontSize: 14 });
console.log(snapshot(settings))}>{'Log settings'}
}
```
## Reactive Collections
Use Ripple collection classes when collection operations should update rendered
output.
```tsrx
import { RippleArray, RippleMap, RippleObject, RippleSet } from 'ripple';
export function Inventory() @{
const products = new RippleArray(
{ id: 1, name: 'Jacket' },
{ id: 2, name: 'Boots' },
);
const prices = new RippleMap([[1, 120], [2, 95]]);
const selected = new RippleSet();
const totals = new RippleObject({ added: 0 });
<>
@for (const product of products; key product.id) {
{product.name}: ${prices.get(product.id)}
}
selected.add(1)}>Select jacket
Selected: {selected.size + totals.added}
>
}
```
Available collection APIs include `new RippleArray(...)`, `RippleArray.from(...)`,
`RippleArray.of(...)`, `new RippleObject(...)`, `new RippleMap(...)`, and
`new RippleSet(...)`.
## Template Control Flow
Rendered control flow uses directive expressions.
```tsrx
import { track } from 'ripple';
export function StatusBadge() @{
const status = track<'idle' | 'loading' | 'done'>('idle');
<>
@switch (status.value) {
@case 'loading': {
Loading...
}
@case 'done': {
Done
}
@default: {
Idle
}
}
(status.value = 'done')}>Finish
>
}
```
Use `@for (... of ...)` for rendered lists. Filter the iterable before rendering
when some items should be skipped, and use `@empty { ... }` for the no-items
fallback. Direct `continue`, `break`, and `return` statements are not allowed in
`@for` template loop bodies, and are also invalid inside `@if` template branches.
`@switch` cases use `@case` and `@default` clauses with isolated `{...}` blocks.
They do not fall through, and do not use `break` or `return`.
```tsrx
export function UserList({ users }: { users: User[] }) @{
const visibleUsers = users.filter((user) => !user.hidden);
@for (const user of visibleUsers; index i; key user.id) {
{i + 1}. {user.name}
} @empty {
No users
}
}
```
Use ordinary TypeScript `return` for true component exits in setup code.
```tsrx
export function Profile({ user }: { user: User | null }) @{
if (!user) {
return null;
}
{user.name}
}
```
`@try` provides pending and error UI.
```tsrx
export function ProfileBoundary() @{
@try {
} @pending {
Loading...
} @catch (error, reset) {
Error: {error.message}
reset()}>Try again
}
}
```
## Events And Refs
Events use JSX-style handler props. DOM refs can be callback refs, tracked refs,
or local variables.
Import `JSX` and `Ripple` with `import type { JSX, Ripple } from 'ripple'` for
element prop and event types, such as `JSX.IntrinsicElements['input']` and
`Ripple.EventHandler`. These are type namespaces,
not runtime objects.
```tsrx
import { track } from 'ripple';
export function SearchBox() @{
const query = track('');
let input: HTMLInputElement | undefined;
<>
Search
{
query.value = event.currentTarget.value;
}}
/>
input?.focus()}>Focus
>
}
```
## Dynamic Components And Elements
Use the dynamic tag syntax `<{expression}>` when the element tag or component
constructor is chosen at runtime. The expression can be a string tag name, a
component, or a tracked variable holding either. The closing tag repeats the
same expression: `{expression}>`. Ripple host elements use `class`, not
React's `className`.
```tsrx
import { track, type Component } from 'ripple';
type Tag = 'section' | 'article';
function Summary() {
return Summary
;
}
function Details() {
return Details ;
}
export function DynamicPanel() @{
const tag = track('section');
const Body = track(() => Summary, undefined, true);
// Or hold it in a plain Tracked: create it empty and assign the component,
// because track(Summary) would treat the function as a computation.
// const Body = track();
// Body.value = Summary;
<>
<{tag.value} class="panel">
<{Body.value} />
{tag.value}>
{
tag.value = tag.value === 'section' ? 'article' : 'section';
Body.value = Body.value === Summary ? Details : Summary;
}}>
Swap
>
}
```
`track(() => Summary, undefined, true)` holds a component; the third argument
`true` makes the derived writable so `Body.value` can be switched. A function
passed to `track()` always creates a derived, so a plain `Tracked` holding a
component or any other function is created empty and assigned through `.value`.
The tag expression must resolve to an element name: an identifier, member
access, static string, or a runtime expression composed of those. Calls,
spreads, string concatenation, and string interpolation are not valid tag
names. Do not use removed dynamic tag syntax such as `<@tag />`,
`<@Component />`, or the imported `Dynamic` component with an `is` prop. Use
`<{tag}>` instead.
## Styles
The `style` attribute accepts a CSS string or an object with camelCase or
kebab-case keys. Ripple never adds units: write `style={{ width: '24rem',
margin: 0, lineHeight: 1.5, '--accent': 'rebeccapurple' }}`. Nonzero lengths need
explicit units; numbers are accepted for unitless properties and zero lengths.
A `null` or `undefined` property value omits the declaration and removes it on
client updates, allowing conditional styles such as `width: expanded ? '24rem' : null`.
HTML and SVG style objects use property-specific CSS types. Import
`CSSProperties` from `ripple` to type reusable style objects.
CSS strings and custom property values are not validated as CSS syntax.
A `
>
}
```
- A block lives in the children list of an element or fragment, beside the
output it styles. A `@{ ... }` body or an `@if`/`@for`/`@switch`/`@try` branch
renders one output node and a block counts as one, so wrap the block and its
markup in a fragment: `<>
>`.
- Raw CSS in ``,
an ordinary element the compiler leaves alone.
- Blocks among the same children share one hash class and one stylesheet. A
nested element or fragment whose children hold a block is a nested scope with
its own hash; elements carry the hash of every scope around them, outer first.
Outer rules reach nested scopes; inner rules never reach out.
- A block inside an `@if`/`@for` branch styles only the elements that branch
renders, but its CSS always ships, because CSS is static.
- A selector that matches nothing the block can reach, including a selector
that only matches the containing element, is removed as unused and left as a
`/* (unused) ... */` comment.
- A scoped block accepts only the `ref` and `apply` attributes.
- Use `:global(...)` to reach outside the scope: `:global(.x)` is page-wide,
`.card :global(.x)` reaches only below your scoped `.card`, `:global(.dark)
.card` reacts to an ancestor's class, `.card:global(.is-open)` matches a class
another library toggles. `:global { ... }` does the same for every rule inside
it. `:global` may only start or end a selector. Prefer passing a class from a
theme to a child you own; keep `:global` for children you cannot change, with
a scoped selector in front:
| I want to ... | Use ... |
| ---------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| Style my own elements | A `;
// theme.$class -> 'tsrx-1a2b3c4d'
// theme.dark -> 'tsrx-1a2b3c4d dark'
export function Badge() {
return New ;
}
```
- A block that is exported, applied, or whose `$class` is read is a **theme**
and keeps every selector, element and descendant selectors included. A block
only read through its class keys keeps only its standalone class selectors.
- `$class` is reserved; a standalone `` applies the
theme and declares the scope's own block in one tag.
```tsrx
import { theme } from './theme.tsrx';
export function Panel() @{
<>
Purple
Black: the local rule beats the theme's green
>
}
```
- `apply={[a, b]}` applies several themes; `const accent = `)
.replace('', body);
```
### Streaming SSR
With `stream` set, `render` flushes a shell immediately: all synchronous
content, each suspended `@try` boundary showing its `@pending` fallback, and
all CSS registered so far. As each boundary's async work settles, its HTML
streams as a self-contained chunk — carrying its own CSS, serialized
`trackAsync` results, and `` content — and an inline runtime swaps it
into place. Chunks arrive out of order, parents always before children.
Catch-only boundaries stream an empty slot that later resolves to the body or
the server-rendered `@catch` HTML. `hydrate()` handles both arrival orders:
content swapped before hydration hydrates normally, and later chunks activate
their boundary in place without re-rendering.
```ts
import { render, createStream } from 'ripple/server';
const { stream, sink } = createStream();
render(App, {
stream: sink,
rootBoundary: { pending: Loading },
streamTemplate: {
before: '',
between: '',
after: '
',
},
});
return new Response(stream, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
```
Apps built on `@ripple-ts/vite-plugin` enable streaming for render routes in
`ripple.config.ts` instead of calling `render` directly. A root boundary there
names its components by module, like a route `entry` (path, or
`[exportName, path]`), so the browser never imports the config:
```ts
export default {
rootBoundary: { pending: '/src/Loading.tsrx' },
ssr: {
streaming: true,
},
};
```
`index.html` must keep the `` and `` markers;
the plugin falls back to buffered SSR with a warning when they are missing.
### Static Generation
`prerender(component, options?)` is the static-generation counterpart of
`render`: buffered, every `@try` boundary and `trackAsync` settled before it
resolves, and the scoped CSS returned as text.
```ts
import { prerender } from 'ripple/server';
const { head, body, css } = await prerender(App);
const html = template
.replace('', `${head}`)
.replace('', body);
```
Apps built on `@ripple-ts/vite-plugin` mark render routes instead. The build
renders each marked route through the production server entry and writes
`/client/index.html`, which the adapter's static file handler
serves before the server renders anything; the page still hydrates. Only a
static path (no `:param` or `*` segment) can be prerendered.
```ts
import { defineConfig, RenderRoute } from '@ripple-ts/vite-plugin';
export default defineConfig({
router: {
routes: [
new RenderRoute({ path: '/', entry: './src/Home.tsrx', prerender: true }),
new RenderRoute({ path: '/dashboard', entry: './src/Dashboard.tsrx' }),
],
},
});
```
### Client-Only Builds
An app that never hydrates server-rendered HTML tells the Vite plugin so:
```ts
export default defineConfig({
plugins: [ripple({ ssr: false })],
});
```
Components then compile to bare DOM reads instead of the hydration cursor,
`track()` calls carry no serialization hashes, and the runtime's hydration
paths stay out of the bundle. `hydrate()` throws in such a build, and the
option is rejected when `ripple.config.ts` declares render routes.
An app that renders without the default root boundary (`mount(App, { target,
rootBoundary: false })`) can leave the boundary runtime out of its bundle with
`ripple({ rootBoundary: false })`; a `rootBoundary` option on `mount()` or
`hydrate()` then throws, and `trackAsync()` must sit inside a user `@try`.
### Custom Serialization
Set `transport` in `ripple.config.ts` to serialize custom types in `trackAsync`
hydration results and RPC arguments/results in both directions. The handlers
live in their own module, which the config names by path (default export) or
`[exportName, path]` tuple; the browser imports that module, never
`ripple.config.ts`:
```ts
// src/transport.ts
import type { Transport } from 'ripple';
import { Money } from './money';
export const transport: Transport = {
Money: {
encode: (value) => value instanceof Money && [value.amount, value.currency],
decode: ([amount, currency]) => new Money(amount, currency),
},
};
// ripple.config.ts
export default defineConfig({ transport: ['transport', '/src/transport.ts'] });
```
Handlers are synchronous and browser compatible. `encode` returns truthy
serializable data for recognized values, or `false`/`undefined` to skip them;
wrap falsy data in an array or object. `decode` rebuilds the value. Encoders can
claim plain objects too. With no transport or an empty one, plain hydration data
uses raw JSON and other values use devalue's built-in types. A nonempty transport
uses devalue for all hydration results. RPC always uses devalue.
The Vite plugin registers the handlers at startup. For a custom integration,
call `setTransport(transport)` from `ripple/server` before rendering or serving
RPC, and from `ripple` in the browser before `hydrate`, `mount`, or RPC calls.
Registration is application-wide; use the same definitions on both sides.
`setTransport()` or `setTransport({})` restores built-in serialization.
## Raw HTML
Use `innerHTML={trustedHtml}` for trusted markup. Do not pass untrusted strings to
raw HTML props.
```tsrx
export function Article({ markup }: { markup: string }) {
return ;
}
```
## Do And Do Not
Do:
- Use `.tsrx` for component files.
- Return single-root components directly in examples.
- Use `@{...}` when setup appears next to rendered output, and finish the
statement container with one JSX element, JSX fragment, or JSX control-flow expression.
- Use JSX text for literal children.
- Use `@for` for rendered lists and `@if` for conditional rendering.
- Use Ripple collections for reactive collection state.
- Pass a tracked or a derived as a prop when the child must follow a change; a
prop expression is evaluated once, when the component is called.
- Put a `