# 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 ; } export function App() { return } ``` 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={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}

} 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 }); } ``` ## 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: {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

} } } ``` 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}

} } ``` ## 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; <> } ``` ## 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: ``. 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} /> } ``` `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 `

Card

} ``` - 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 `