A component is a class extending Tether\Component. Its state is its properties; render()
turns them into HTML. It lives on the server for as long as it is on the page.
namespace App;
use Tether\Component;
final class TodoList extends Component
{
/** @var array<int, string> */
public array $items = [];
public string $draft = '';
private int $next = 1;
public function type(string $text): void
{
$this->draft = $text;
}
public function add(): void
{
if ('' !== trim($this->draft)) {
$this->items[$this->next++] = trim($this->draft);
$this->draft = '';
}
}
public function render(): string
{
$items = '';
foreach ($this->items as $id => $text) {
$items .= $this->child(TodoItem::class, ['text' => $text, 'onRemove' => fn () => $this->remove($id)], key: (string) $id);
}
$draft = $this->e($this->draft);
return <<<HTML
<section>
<ul>{$items}</ul>
<input value="{$draft}" tether-input="type" tether-keydown.key-enter="add">
</section>
HTML;
}
private function remove(int $id): void
{
unset($this->items[$id]);
}
}- Returns HTML that starts with one element, the component's root: nothing before it, not
even a comment (
render()throws, and says what it started with). Tether adds atether-idattribute to it: that is how the browser knows which part of the page is which component. - Returns HTML: escape everything that comes from users, with
$this->e()(htmlspecialchars()for text and quoted attributes). - Is called whenever the component must be shown again; it should only read state. Loading
data belongs in
mount(), event handlers orrun(). $this->child(Class::class, $props, key: ...)places a child component and returns its HTML.$this->request()is the PSR-7 request of the tab (see State).$this->isLive()is false while the page's first HTML is rendered and true in the live tab.
What the browser keeps. A render only changes what the server changed. What the page did to
an element, a typed value, checked, a chosen option, <details open>, a class added by a
script, stays as it is, as long as the server's HTML for that attribute is the same as in its
last render. When the server's HTML for it changes, the server wins. tether-keep="class style"
lists attributes the server never overwrites once the element is on the page.
A child's props are its public properties, set by the parent on every render:
$this->child(TodoItem::class, ['text' => $text]) sets $item->text. A prop that is not a
public property of the child is an error. $tetherId is taken: it is the component's id in the
tab (its root element's tether-id).
A child is identified by its class and key, or, without a key, by its class and position
among its siblings of that class. It keeps its state (its own properties, its coroutines) for
as long as the parent keeps placing it; when a render no longer places it, it is unmounted,
with its own children. Give children rendered in a loop a key, so each keeps its state when the
list changes. A component is unmounted by dispose(), which runs after its coroutines are cancelled:
release what mount() took that is not garbage collected (a subscription, a file).
A child is rendered again when it is new, when its props changed, or when it asked to be
(requestRender()). Props compare with ===, so an object, which is never === to a new one,
counts as changed; share one object between components (a context, a store) and the parent's
render updates its children. propsChanged($old) runs on a child whose props differ, before it renders. Otherwise the parent's frame reuses its last HTML and the browser leaves
its part of the page alone: focus, selection, a half-typed input survive a parent's update.
Telling the parent something: pass a closure. When the child calls it, the parent renders
afterwards, as Blazor's EventCallback does:
// in the parent's render()
$this->child(TodoItem::class, ['text' => $text, 'onRemove' => fn () => $this->remove($id)], key: (string) $id);
// in TodoItem
public ?\Closure $onRemove = null;
public function remove(): void // an event handler
{
($this->onRemove)();
}The page's root component gets its props from what the route returns: $t->mount($class, $props)
in Tether::from(), or the Page of an App. They are PHP values of any kind, and
never leave the server: the live tab runs the route again. (The older Tether::page() takes
JSON props, which go to the browser and come back signed.)
Tether::from() writes the page's document itself. To use your own layout, pass shell:, a
closure fn (string $root, string $scripts, Page $page): string returning the whole HTML. Put
$scripts in the <head>: it is one module script (Tether's client), and modules wait for the
document, so your own defer scripts, which register hooks, run after it.
- First render. The page is requested over HTTP: the root and its children are created,
mount()runs for each, and the HTML goes out as a normal page. Nothing is live yet. - Live. The browser opens a WebSocket and says what it shows (its URL, or the root's
props). Tether creates the components again,
mount()runs again, and the fresh HTML replaces the first. From now on, each component'srun()runs, and events reach handlers. - Unmount. A component leaves when its parent stops placing it, when navigation replaces
the root, or when the tab closes (or reloads, or loses its connection). Its coroutines,
run()and those started withgo(), are cancelled.
Because the first render and the live tab are two instances, state in properties does not carry
over from 1 to 2, and anything mount() does, it does twice. That is harmless for reading
(loading a channel's messages) and wrong for side effects (posting a "joined" message): put
live-only work in run().
After a lost connection, the browser reconnects and mounts from scratch: property state is gone,
and so is what the user typed. State that must survive a reconnect, a reload or a deploy belongs
in storage or the session. For a Tether::from() page the route runs again for the URL the page
was rendered for, not for what the address bar shows now: keep the address current with
history.replaceState() in a hook if the route reads it.
Runs once per instance, with the props set, before the first render: load what the component shows.
public function mount(): void
{
$this->messages = Message::recent($this->room, 50);
}It runs inside a render, so keep it short. browser(), go() and navigate() throw a
LogicException here and in render(): nothing is in the browser yet. mount() is
synchronous on purpose: the first frame has the data.
While mount() waits (a query, a request), the component and its parents are not on screen, and
nothing else of the page is sent until it returns. For slow data, render a placeholder and load
in run():
public bool $loading = true;
public function run(): void
{
$this->messages = Message::recent($this->room, 50);
$this->loading = false;
$this->requestRender();
}Runs in a coroutine of its own while the component is live, and is cancelled when it leaves.
use phasync;
use Tether\Component;
final class Clock extends Component
{
public string $time = '';
public function run(): void
{
while (true) {
$this->time = date('H:i:s');
$this->requestRender();
phasync::sleep(1);
}
}
public function render(): string
{
return "<p>{$this->time}</p>";
}
}Wait with phasync's functions: phasync::sleep(), and phasync::readable($stream) or
phasync::writable($stream) before reading or writing a stream yourself. They let the worker's
other tabs run meanwhile, and they work the same with and without phasync-ext. Plain sleep(),
usleep() and blocking reads hold up every tab in the worker; so does a query through a driver
that blocks. Wait with phasync's functions in everything you write.
Cancellation arrives as a phasync\CancelledException at the next wait. Let it pass: catching it
and carrying on would keep a component running that is no longer on the page. Use finally
for cleanup; code there may still write to the database, publish, and wait:
public function run(): void
{
Presence::join($this->room, $this->user);
try {
// ...
} finally {
Presence::leave($this->room, $this->user); // the tab closed, or the component left
}
}run() may return: the component stays, it just has nothing more to do in the background.
A failure in run() (an exception it doesn't catch) is the component's: see Errors.
$this->go(fn () => ...) starts another coroutine of the component's, from an event handler,
run() or another of its coroutines. It is cancelled when the component leaves, and a failure
in it is the component's, as for run(). Following several topics at once takes one each:
use Swerve\Swerve;
public function run(): void
{
$this->go(function () {
foreach (Swerve::subscribe("room:{$this->room}") as $json) {
$this->messages[] = json_decode($json, true);
$this->requestRender();
}
});
$this->go(function () {
foreach (Swerve::subscribe("typing:{$this->room}") as $name) {
$this->typing[$name] = microtime(true);
$this->requestRender();
}
});
}A coroutine started with phasync::go() is not the component's: it belongs to the tab's
request, runs until it ends or the tab closes, and its failures are only logged. Use go().
Event handlers render their component by themselves when they return. Anywhere else (in
run(), in a coroutine, in a callback) call $this->requestRender() after changing state.
A render that gives the HTML the browser already has sends nothing, except after an event of a
form field (input, change, submit, key events): the field may hold what the handler did not accept,
and the patch resets it. A handler that changes nothing
the page shows can skip the render with #[Tether\NoRender]; the component still renders
when anything else marks it.
It does not render at once: it marks the component for the tab's next frame. Each tab has one writer coroutine that wakes when something is marked, renders every marked component once, parents first, and sends them in one message; then it waits at least 1/30 s before the next. A component that changes 1,000 times a second is sent 30 times a second, with its latest state. A client that reads slowly makes the writer wait, and changes keep collecting: it gets fewer frames, never a backlog.
Fields that set a property as the user types: mark it #[Bind] and put bind() in the tag.
#[Bind]
public string $name = '';
#[Bind]
public bool $subscribe = false;
public function render(): string
{
return "<div><input name=\"name\" {$this->bind('name')}> <input type=\"checkbox\" {$this->bind('subscribe')}></div>";
}bind() writes the value (checked for a bool) and the event that sends the new one; a radio
button takes the option it stands for, bind('plan', 'free'). A <select> takes the same, with
selected on the option, and a textarea's text goes between its tags. The property is a string,
int, float, bool, array of strings or a backed enum, each nullable; a value that does not fit
(a letter in an int) is refused: nothing changes, the field shows the value the server last
rendered again, and the page hears it in tetherrefused. Only #[Bind] properties can be set this way.
bound() is the handler behind it; $tab->call('bound', ['name', 'Ada']) in a test.
Public methods of the component's class are its event handlers, called from the browser: see
Events and JavaScript. That includes public methods it inherits from
your own base classes and traits. Component's own public methods are not handlers, except
bound(), which sets #[Bind] properties. Everything public is callable by anyone who has the page, with any arguments of the
right types: see Security. Keep helpers protected or private.
render() returns a string, so any template engine will do: the core never knows which. The
rules are the same for all of them.
- The output is raw HTML: escape what users wrote, with the engine's own escaping.
- It must start with one root element, with nothing before it, not even a template comment.
- A child is placed by calling
child(), which is protected: hand the engine a closure. tether-argsholds JSON:@json($args)in Blade (its default flags escape the quotes) or|json_encodein Twig, in a single-quoted attribute, since the JSON has double quotes.- What the framework keeps per request (the current user, the locale, the container) is the framework's job: see State. A framework may ship its own base component that wires its engine in.
Blade, as in playground/laravel:
abstract class BladeComponent extends Component
{
public function render(): string
{
$variables = (fn () => get_object_vars($this))->call($this);
return view('live.' . Str::kebab(class_basename($this)), $variables + ['component' => $this, 'child' => $this->child(...)])->render();
}
}@use('App\Live\TodoItem')
<section>
@foreach ($items as $id => $text)
{!! $child(TodoItem::class, ['text' => $text, 'onRemove' => fn () => $component->remove($id)], key: (string) $id) !!}
<button tether-click="react" tether-args='@json([$id, "👍"])'>👍</button>
@endforeach
</section>With Twig, register the closure as a function:
$twig->addFunction(new TwigFunction('child', $this->child(...), ['is_safe' => ['html']])), and call
{{ child('Item', {text: text}, id) }}.