polidog / rosanna-state
Server-side state for Rosanna: hooks, signed actions, CSRF, snapshots, storage and rosanna.js
Requires
- php: >=8.5
- ext-session: *
- polidog/rosanna: ^0.1
- polidog/rosanna-defer: ^0.1
Requires (Dev)
- composer/composer: ^2.8
- friendsofphp/php-cs-fixer: ^3.93
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.0
- vimeo/psalm: ^6.14
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-04 11:01:08 UTC
README
Server-side state for Rosanna, named like it after the TOTO song "Rosanna", famous for Jeff Porcaro's shuffle.
polidog/rosanna-state adds components in the shape of React Hooks to Rosanna's rendering. The server owns the state, and buttons and forms work without JavaScript. Loading rosanna.js turns them into partial updates.
This package holds the hooks (fc(), useState and the rest), actions, signatures, CSRF, Snapshot, Storage and rosanna.js. Defer lives in polidog/rosanna-defer, which this package requires. H, Element, Renderer and the PSX compiler live in polidog/rosanna, which this package requires. A site that only renders does not need this package.
It has no router, no DI and no events. Choosing a page from a URL belongs to the host (a framework, or one PHP file per page). Together with polidog/rosanna it is a rewrite of usePHP; see the migration steps.
- PHP 8.5+ with the
sessionandmbstringextensions.polidog/rosannarequiresnikic/php-parserfor the PSX compiler. Rendering does not load it. - The current version is v0.1.0. It depends on
^0.1ofpolidog/rosannaandpolidog/rosanna-defer.
Installation
composer require polidog/rosanna-state
vendor/bin/rosanna-state publish # copies rosanna.js and rosanna-defer.js to public/
To publish on every composer install, add this to composer.json:
{
"scripts": {
"post-install-cmd": ["Polidog\\Rosanna\\State\\Installer\\AssetInstaller::publish"],
"post-update-cmd": ["Polidog\\Rosanna\\State\\Installer\\AssetInstaller::publish"]
}
}
Initialization
Initializing the state layer takes two things and nothing else:
composer require polidog/rosanna-statenew RosannaState()
There is no bootstrap call. Composer's autoload.files loads the hooks and connects the state layer to polidog/rosanna.
RosannaState also has the rendering API of Rosanna (renderElement(), registerComponent(), loadComponentManifest(), setArgumentResolver()), so it is the only object your app holds.
What the host still does
Initialization does not connect the state layer to your requests. The host has to do these four things, all of which appear in the minimal example:
| What | How | Without it |
|---|---|---|
| Pass POSTs to the state layer | $app->actions()->handle($request, replay: ...), then send() the response if it is not null |
Buttons and forms do nothing |
| Render inside the request's scope | RenderContext::run($app->scope($request), fn() => $app->renderElement(...)) |
renderElement() builds a scope from the PHP globals on its own, but a fc() called before that has none: Snapshot throws LogicException, a deferred component stays a placeholder, and a Request you built is ignored |
| Serve the JavaScript | RosannaState::renderClientScript() in the page, and vendor/bin/rosanna-state publish to place the file |
No partial updates, and defers show only their fallback. Elements that need rosanna.js do nothing |
| Set the secret, if you use Snapshot | $app->setSnapshotSecret($secret) (The secret) |
Rendering a Snapshot component throws LogicException |
Depending on what you use, the host also:
- calls
$app->handleDeferred($request)before page handling and routes/_defer/*to it (Defer); - compiles
.psxfiles withvendor/bin/rosanna-state compilerather thanvendor/bin/rosanna compile(PSX); - calls
RequestLifecycle::reset()between requests in a worker (Worker mode); - checks access to the page before
handle()(The host checks access).
Minimal example
A function wrapped in fc() is a component, and useState holds its state.
<?php // public/index.php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\Runtime\RenderContext; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; use function Polidog\Rosanna\State\fc; use function Polidog\Rosanna\State\useState; require __DIR__ . '/../vendor/autoload.php'; $Counter = fc(function (array $props): Element { [$count, $setCount] = useState($props['initial'] ?? 0); return H::div(className: 'counter', children: [ H::span(children: "Count: {$count}"), H::button(onClick: fn() => $setCount($count + 1), children: '+'), ]); }, key: 'counter'); $app = new RosannaState(); $app->setSnapshotSecret((string) getenv('ROSANNA_SNAPSHOT_SECRET')); $request = Request::fromGlobals(); $page = fn(): Element => H::main(children: [$Counter(['initial' => 0])]); $response = $app->actions()->handle($request, replay: fn(bool $partial): Element => $page()); if ($response !== null) { $response->send(); return; } $body = RenderContext::run($app->scope($request), fn(): string => $app->renderElement($page())); echo '<!doctype html><html><head><meta charset="utf-8">', RosannaState::renderClientScript(), '</head><body>'; echo $body; echo '</body></html>';
ROSANNA_SNAPSHOT_SECRET=$(php -r 'echo bin2hex(random_bytes(32));') php -S localhost:8000 -t public
Each press sends a POST that handle() turns into a state update.
- Without JavaScript: a 303 back to the same URL and a full page render.
- With
rosanna.js: only the component's fragment comes back and is morphed into the DOM. Unsent input, focus and selection survive.
handle() returns null when the request is not a Rosanna action. It sends no headers and no body (the one exception is the cookie PHP sends when a session starts), so the host calls send() or converts the response for its framework. Always render before any output (see Do not render after output).
$setCount($count + 1) does not mean "add". It is an action meaning "set it to this value", fixed at render time. The closure runs while rendering, not on click.
An event closure must return an Action. Anything else, including a closure with no return, throws InvalidArgumentException while rendering. Code in the closure does not wait for the click: onClick: function () use ($db) { $db->delete(1); } deletes when the page is rendered, and the exception comes after it has run once.
- Return the result of a
useStatesetter, and put side effects inuseEffect, depending on the state that setter changes. - To leave an element without an action, write the condition at the prop, not inside the closure:
onClick: $can ? fn() => $set(1) : null. PHP cannot tell a closure returningnullfrom one with noreturn, sofn() => $can ? $set(1) : nullthrows when$canis false. - A closure with a side effect that also returns an
Actioncannot be detected. The side effect runs on every render, without an error.
Runnable samples are in examples/.
Instance keys
State belongs to an instance ID. The ID looks like FC@<file name>:<start line>#<key> and does not include the URL.
<?php // public/index.php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\Runtime\RenderContext; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; use function Polidog\Rosanna\State\fc; use function Polidog\Rosanna\State\useState; require __DIR__ . '/../vendor/autoload.php'; $Task = fc(function (array $props): Element { [$likes, $setLikes] = useState(0); return H::div(children: [ H::span(children: "{$props['title']}: {$likes}"), H::button(onClick: fn() => $setLikes($likes + 1), children: 'Like'), ]); }, key: 'task'); $app = new RosannaState(); $app->setSnapshotSecret((string) getenv('ROSANNA_SNAPSHOT_SECRET')); $request = Request::fromGlobals(); $page = fn(): Element => H::main(children: [ $Task(['key' => 'a', 'title' => 'first']), $Task(['key' => 'b', 'title' => 'second']), ]); $response = $app->actions()->handle($request, replay: fn(bool $partial): Element => $page()); if ($response !== null) { $response->send(); return; } echo RenderContext::run($app->scope($request), fn(): string => $app->renderElement($page()));
- We recommend passing a key to
fc(). Without one, instances are told apart by their order under the same name, so a change of order swaps their state. The ID does not include the file's path, so closures on the same line of same-named files in different directories get the same name and only a key can separate them. - For per-row or per-record state, pass a
keyprop (string or int). Combined with thefc()key it becomestask/a. - Rendering one ID twice shares its state. This is not detected.
- An unkeyed child inside a class component is numbered within that instance. Its ID looks like
FC@a.php:3#<parent ID>/0, so it grows with the parent's ID (mind the 255-byte limit). - A component has a wrapper
<div>, so it cannot be a direct child oftable,tbody,tr,selectand the like: rendering throwsRuntimeException. The parser would move the<div>out and partial updates would break the table. Put it inside atd, or make one component own the whole table. - Moving
fc()to another line changes the closure's ID, and the state starts over. - Actions for an ID over 255 bytes or containing control characters get a 400.
Hooks
useState($initial)returns[value, setter]. State is null, scalars and arrays of those only. Objects,NAN,INFand nesting beyond 512 levels throw at render time.$set(null)stores null.useFormAction(['field name' => $setter])copies submitted fields into hooks.useEffect($callback, $deps)runs synchronously while rendering: every time fornull, once for[], and when a value changes by!==for an array. Returning a\Closuremakes it a cleanup, which only ever runs within the same request.
Calling a hook outside a component throws LogicException. Hooks are identified by call order, so do not change their order with conditionals.
Array deps in useEffect start a session
Array deps are stored so that the next request can compare them. In a Session component, array deps in useEffect (including []) start a session and a cookie, even on an anonymous visitor's page. null deps are not stored and start nothing. Reading useState alone starts no session. On pages meant for a CDN, avoid array deps or use Memory or Snapshot.
<?php // public/index.php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\Runtime\RenderContext; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; use function Polidog\Rosanna\State\fc; use function Polidog\Rosanna\State\useEffect; require __DIR__ . '/../vendor/autoload.php'; $Article = fc(function (array $props): Element { useEffect(function () use ($props): void { error_log("viewed {$props['id']}"); }, [$props['id']]); return H::p(children: "Article {$props['id']}"); }, key: 'article'); $app = new RosannaState(); $app->setSnapshotSecret((string) getenv('ROSANNA_SNAPSHOT_SECRET')); $request = Request::fromGlobals(); echo RenderContext::run($app->scope($request), fn(): string => $app->renderElement($Article(['id' => 1])));
Form input
<?php // public/index.php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\Runtime\RenderContext; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; use function Polidog\Rosanna\State\fc; use function Polidog\Rosanna\State\useFormAction; use function Polidog\Rosanna\State\useState; require __DIR__ . '/../vendor/autoload.php'; $Profile = fc(function (): Element { [$name, $setName] = useState(''); return H::form(onSubmit: useFormAction(['name' => $setName]), children: [ H::input(name: 'name', value: $name), H::button(children: 'Save'), H::p(children: "Saved: {$name}"), ]); }, key: 'profile'); $app = new RosannaState(); $app->setSnapshotSecret((string) getenv('ROSANNA_SNAPSHOT_SECRET')); $request = Request::fromGlobals(); $response = $app->actions()->handle($request, replay: fn(bool $partial): Element => $Profile()); if ($response !== null) { $response->send(); return; } echo RenderContext::run($app->scope($request), fn(): string => $app->renderElement($Profile()));
Which field goes into which hook is signed, but the values are client input. A value that is not a string, is over 65,535 bytes or is invalid UTF-8 rejects the whole request with a 400. A field that was not sent (an unchecked checkbox) becomes an empty string.
Use useFormAction on a form's onSubmit, or on onChange / onInput of an input, select or textarea with a name (its own field only).
Saving a form and clearing the input
The usual "type a title, press Add, the row is saved, the input clears" takes a hidden field and one choice of value. examples/11-todo is the same thing as a runnable page.
<?php // public/index.php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\Runtime\RenderContext; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; use function Polidog\Rosanna\State\fc; use function Polidog\Rosanna\State\useEffect; use function Polidog\Rosanna\State\useFormAction; use function Polidog\Rosanna\State\useState; require __DIR__ . '/../vendor/autoload.php'; $Todos = fc(function (): Element { [$title, $setTitle] = useState(''); [$nonce, $setNonce] = useState(''); $invalid = $nonce !== '' && (trim($title) === '' || preg_match('/^.{1,40}$/su', $title) !== 1); useEffect(function () use ($nonce, $title, $invalid): void { if ($nonce !== '' && !$invalid) { $_SESSION['todos'][] = trim($title); // stands in for $db->insert() } }, [$nonce]); return H::div(children: [ H::form(onSubmit: useFormAction(['title' => $setTitle, 'nonce' => $setNonce]), children: [ H::input(type: 'hidden', name: 'nonce', value: bin2hex(random_bytes(8))), H::input(name: 'title', required: true, value: $invalid ? $title : ''), $invalid ? H::p(children: 'Enter 1 to 40 characters.') : null, H::button(children: 'Add'), ]), H::ul(children: array_map(fn(string $todo): Element => H::li(children: $todo), $_SESSION['todos'] ?? [])), ]); }, key: 'todos'); $app = new RosannaState(); $app->setSnapshotSecret((string) getenv('ROSANNA_SNAPSHOT_SECRET')); $request = Request::fromGlobals(); $response = $app->actions()->handle($request, replay: fn(bool $partial): Element => $Todos()); if ($response !== null) { $response->send(); return; } echo RenderContext::run($app->scope($request), fn(): string => $app->renderElement($Todos()));
- The deps are
[$nonce], not[$title]. With[$title], adding the same title twice in a row saves once, because the deps did not change. The hidden field sends a different value from each render, and$nonce !== ''skips the first view. - The input renders
'', not$title. Withrosanna.jsthe fields of a submitted form take the rendered value after the morph (rosanna.js); without it the 303 leads to a new page. Text typed while the request is on its way is kept. An invalid value renders$title, so what was typed stays. - The effect runs while rendering. With
rosanna.jsthat is during the POST, in its partial update; without it, during the GET after the 303. Reading the list after the effect shows the new row in that same response. The save waits for a render: if nothing renders the component, nothing is saved until something does. If the effect throws, its deps are not stored and the next render runs it again, so a failed save is retried by whoever renders next. - Sending again does not save again, with Session storage. With
rosanna.jsthe second click of a double click with the same values is not sent; without it, the second POST has an old version. A reload after the 303 is a GET with unchanged deps. A form from another tab, or a saved POST sent again with its old nonce, has an old version and is not applied (Submissions from a stale page): withrosanna.jsthe latest rows are drawn and the typed text stays, so pressing Add again saves it. Without JavaScript that text is lost, because the 303 leads to a new page with an empty input. - Use Session storage for this. The comparison above only runs on the server for Session. With Snapshot or Memory, sending the same POST again is applied and saves again, and without JavaScript a Snapshot page's reload asks to do exactly that. A session handler that does not lock can also let concurrent requests through.
- The nonce is not a security token. Its value is client input like the title, so validate in the effect, on the server (
requiredonly stops the browser sending an empty input, such as on a late second click). Anyone can open the page and submit a new form as often as they like, and each is a new save. Limits, ownership and "this may exist only once" belong to your database, for example a unique constraint. - An invalid title and its message stay in the session, so a reload or a later visit shows them again until the next submission. Array deps start a session on the first view (Array deps in useEffect start a session).
H and attributes
H is part of polidog/rosanna; see its README for children and attributes. RosannaState renders the same elements. What this package adds is the event props.
<?php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\State\RosannaState; require __DIR__ . '/vendor/autoload.php'; $items = ['one', 'two']; echo new RosannaState()->renderElement(H::div(className: 'card', children: [ H::h2(children: 'Title'), $items !== [] ? H::ul(children: array_map(fn(string $item) => H::li(children: $item), $items)) : null, H::button(...['aria-label' => 'Close', 'data-id' => '1'], children: 'x'), H::textarea(name: 'body', value: 'text'), ]));
- The events are
onClick,onChange,onInputandonSubmit.onFocus,onBlur,onLoadandonErrorthrow. Values are anActionor a closure without required parameters that returns anActiononly. Function-name strings are refused without being run.
| Event | Without JavaScript |
|---|---|
onClick on button and input[type=submit|image] |
Works |
onSubmit on form |
Works |
onClick, onChange, onInput on other elements (onInput waits for 300ms without input) |
Needs rosanna.js |
Class components
This is the basic shape of embedding in a host. render() returns HTML with the wrapper div.
<?php // public/index.php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\State\Component\BaseComponent; use Polidog\Rosanna\State\Component\Component; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; require __DIR__ . '/../vendor/autoload.php'; #[Component] final class Counter extends BaseComponent { public function render(): Element { [$count, $setCount] = $this->useState(0); return H::div(children: [ H::span(children: "Count: {$count}"), H::button(onClick: fn() => $setCount($count + 1), children: '+'), ]); } } $app = new RosannaState(); $app->register(Counter::class); $request = Request::fromGlobals(); $response = $app->actions()->handle($request, replay: fn() => $app->render(Counter::class)); if ($response !== null) { $response->send(); return; } echo $app->render(Counter::class);
render()andcreateElement()take the registered name or the class name. Anything unregistered throwsLogicException.- The second argument of
$app->render(Counter::class, 'item-1')is the key. - Registered classes are created with
newand no arguments. Do not register a class whose constructor needs arguments; let the host build itsElement. The storage in#[Component]still applies. #[Component(name: '...', storage: 'snapshot', persist: 'sessionStorage')]sets the name and storage.- This example has no secret, so the signing key lives in the session and opening the page starts one. Call
setSnapshotSecret()to avoid that.
When the host combines components with its own layout, build the Element inside RenderContext::run($app->scope($request), ...) and call renderElement(), as in the minimal example.
Storage
| Type | Where | On reload | Good for |
|---|---|---|---|
StorageType::Session (default) |
$_SESSION |
Kept | Forms, carts |
StorageType::Memory |
Within the request | Back to initial | Temporary UI |
StorageType::Snapshot |
Signed JSON in the page | Back to initial (persist keeps it) |
No state on the server |
<?php // public/index.php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\Runtime\RenderContext; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; use Polidog\Rosanna\State\Storage\SnapshotPersist; use Polidog\Rosanna\State\Storage\StorageType; use function Polidog\Rosanna\State\fc; use function Polidog\Rosanna\State\useState; require __DIR__ . '/../vendor/autoload.php'; $view = function (string $label): Element { [$count, $setCount] = useState(0); return H::div(children: [ H::span(children: "{$label}: {$count}"), H::button(onClick: fn() => $setCount($count + 1), children: '+'), ]); }; $InSession = fc(fn() => $view('session'), key: 'in-session'); $InMemory = fc(fn() => $view('memory'), key: 'in-memory', storageType: StorageType::Memory); $InSnapshot = fc(fn() => $view('snapshot'), key: 'in-snapshot', storageType: StorageType::Snapshot); $Persisted = fc( fn() => $view('persisted'), key: 'persisted', storageType: StorageType::Snapshot, persist: SnapshotPersist::SessionStorage, ); $app = new RosannaState(); $app->setSnapshotSecret((string) getenv('ROSANNA_SNAPSHOT_SECRET')); $request = Request::fromGlobals(); $page = fn(): Element => H::main(children: [$InSession(), $InMemory(), $InSnapshot(), $Persisted()]); $response = $app->actions()->handle($request, replay: fn(bool $partial): Element => $page()); if ($response !== null) { $response->send(); return; } echo RenderContext::run($app->scope($request), fn(): string => $app->renderElement($page()));
- Session starts the session on the first write, never on a read.
- Memory only changes through partial updates, so it needs JavaScript. A redirect goes back to the initial value. Each action starts from an empty store, so components with several
useStatelose the other values. - Snapshot requires
setSnapshotSecret(); without it, rendering throwsLogicException. Without JavaScript the POST is answered with the full page (Cache-Control: no-store), so a reload makes the browser ask to resend. - With
persist,rosanna.jssaves tosessionStorage(per tab) orlocalStorage(shared) and redraws withrestoreon load.window.Rosanna.forgetSnapshots()clears it.
Keeping snapshots outside the page
Set this with behavior on handle() and with restoreSnapshots(), called before rendering a GET.
<?php // public/index.php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\State\Component\BaseComponent; use Polidog\Rosanna\State\Component\Component; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; use Polidog\Rosanna\State\Snapshot\SnapshotBehavior; require __DIR__ . '/../vendor/autoload.php'; #[Component(storage: 'snapshot')] final class Cart extends BaseComponent { public function render(): Element { [$count, $setCount] = $this->useState(0); return H::div(children: [ H::span(children: "Items: {$count}"), H::button(onClick: fn() => $setCount($count + 1), children: 'Add'), ]); } } $app = new RosannaState(); $app->setSnapshotSecret((string) getenv('ROSANNA_SNAPSHOT_SECRET')); $app->register(Cart::class); $request = Request::fromGlobals(); $behavior = SnapshotBehavior::Persistent; $response = $app->actions()->handle($request, replay: fn() => $app->render(Cart::class), behavior: $behavior); if ($response !== null) { $response->send(); return; } $app->restoreSnapshots($request, $behavior); echo $app->render(Cart::class);
| Behavior | Read from | Written after an action to |
|---|---|---|
SnapshotBehavior::Isolated (default) |
The page | Nowhere |
SnapshotBehavior::Persistent |
?_rosanna_snapshot= |
The redirect URL |
SnapshotBehavior::Session |
$_SESSION |
The same; bound to the session ID |
- Persistent carries one instance only. An action on another instance replaces it, with a warning in the log. Use Session for pages with several instances.
- A Persistent URL holds up to 8000 bytes of path and query. Beyond that, the redirect goes to the same URL without the snapshot, with a warning in the log. The page opens, but its state goes back to the initial one, not the previous one. Use Session for pages whose state can grow.
- Session holds up to 64 instances; beyond that the oldest goes back to its initial state. It does not read
_rosanna_snapshotfrom the URL. - Defer endpoints do not read the session slot.
Submissions from a stale page
An action carries the version it was rendered with, signed. A submission from a stale page is not applied over newer state; the latest state is drawn instead. The second click of a double click is the same, so it does not add up. This is not suitable for counting clicks.
- Session compares on the server. A partial update gets the latest fragment with 200 and
X-Rosanna-Stale: 1; a plain POST gets a 303. - Snapshot compares in
rosanna.jsagainst a copy insessionStorage. A stale form is not sent, andrestoreredraws. The copy is dropped on reload and normal navigation, so a reload withoutpersistgoes back to the initial value. WithoutsessionStoragethe comparison uses page memory and cannot work after a reload or going back. - In both cases
rosanna.jsfires a cancelablerosanna:staleevent and keeps the input of the submitted form. - Memory does not compare.
A session handler that does not lock cannot stop concurrent requests. The Session check compares and writes while the session is active, so PHP's default file sessions serialize them with a lock. With a non-locking setup such as Redis or Memcached, or a host that calls session_write_close midway, concurrent requests in one session can both pass and the later write wins.
The Snapshot check prevents conflicts; it is not a security boundary. It only runs on the client, so it cannot stop a submission without JavaScript or a hand-made request with an old saved snapshot. Do not keep single-use rights (coupons, payment completion) in a Snapshot.
PSX
A .psx file is PHP in which you can write tags like HTML. docs/PSX.md has the details of the syntax, the manifest and errors.
<?php // components/Counter.psx declare(strict_types=1); namespace App; use function Polidog\Rosanna\State\fc; use function Polidog\Rosanna\State\useState; return fc(function (array $props) { [$count, $setCount] = useState($props['initial'] ?? 0); return ( <div className="counter"> <span>Count: {$count}</span> <button onClick={fn() => $setCount($count + 1)}>+</button> </div> ); }, key: 'counter');
<?php // public/index.php declare(strict_types=1); use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\Runtime\PsxRenderer; use Polidog\Rosanna\Runtime\RenderContext; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; require __DIR__ . '/../vendor/autoload.php'; $app = new RosannaState(); $app->setSnapshotSecret((string) getenv('ROSANNA_SNAPSHOT_SECRET')); $app->loadComponentManifest(__DIR__ . '/../var/cache/psx/' . PsxRenderer::MANIFEST_FILENAME); $request = Request::fromGlobals(); $page = fn(): Element => RenderContext::scope()->psx->render('App\\Counter', ['initial' => 5]); $response = $app->actions()->handle($request, replay: fn(bool $partial): Element => $page()); if ($response !== null) { $response->send(); return; } echo RenderContext::run($app->scope($request), fn(): string => $app->renderElement($page()));
vendor/bin/rosanna-state compile components
- A
.psxwhose file name starts with an uppercase letter is a component, and its FQCN isnamespace\FileName. Other.psxfiles call it as<Counter initial={5} />. - Output goes to
var/cache/psx/, never into the source tree. Add it to.gitignore. - Build and run at the same path. The manifest holds absolute paths to the compiled files.
- Compile with
vendor/bin/rosanna-state compile.RosannaState::loadComponentManifest()throws when the deferred manifest is missing or its build ID does not matchmanifest.php.vendor/bin/rosanna compileis also installed, but it does not look at defer and removes any deferred manifest it finds, so an app usingRosannaStatehas to runrosanna-state compileagain after it. - Component files containing the string
Deferare executed at compile time. Do not put code with side effects at their top level. - Make the cache directory writable by the build role only, not by HTTP requests. Never pass a request-derived path to
loadComponentManifest().
Embedding in a host
The order is:
- The host checks access to the page.
$app->handleDeferred($request)(if you use defer).$app->actions()->handle($request, replay: ...).- If both return
null, render.
replay redraws after an action. It returns an Element or finished HTML. $partial in fn(bool $partial) is true for a partial update. A partial has to find the component, so return fc() components as an Element or pass them through renderElement(). If it is not found, the response is a redirect, and an Isolated Snapshot loses its state with a warning in the log.
Exceptions from replay reach the host. The state may already be applied.
The host checks access
Rosanna has no router, so protecting a page is the host's job. Check before calling handle().
<?php // public/index.php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\Runtime\RenderContext; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; use function Polidog\Rosanna\State\fc; use function Polidog\Rosanna\State\useState; require __DIR__ . '/../vendor/autoload.php'; $request = Request::fromGlobals(); $isAdmin = ($request->cookies['role'] ?? '') === 'admin'; if ($request->pagePath() === '/admin' && !$isAdmin) { http_response_code(403); echo 'Forbidden'; return; } $Panel = fc(function (): Element { [$count, $setCount] = useState(0); return H::button(onClick: fn() => $setCount($count + 1), children: "Runs: {$count}"); }, key: 'panel'); $app = new RosannaState(); $app->setSnapshotSecret((string) getenv('ROSANNA_SNAPSHOT_SECRET')); $response = $app->actions()->handle($request, replay: fn(bool $partial): Element => $Panel()); if ($response !== null) { $response->send(); return; } echo RenderContext::run($app->scope($request), fn(): string => $app->renderElement($Panel()));
A signature is bound to the page it was rendered for, so posting a protected page's form to another page gets a 400. But signatures treat paths differing only in leading and trailing / as the same page. Comparing the raw path, as in $request->path === '/admin', is bypassed by a POST to /admin/. Compare $request->pagePath(), which uses the same unit as the signature, as in the example, or check after a router that treats them the same way.
One route is outside this protection: /_defer/{name} (see Security notes).
Worker mode
A host that serves several requests from one process, such as FrankenPHP or RoadRunner, must call RequestLifecycle::reset() between requests. Otherwise the previous visitor's Memory and Snapshot state, the render scope and the instance counters remain for the next request, and the next visitor can enter the previous visitor's session. reset() writes and closes the session and releases its ID; the next start uses the ID in $_COOKIE. If your host calls session_start() itself after reset(), pass that ID with session_id() first.
<?php // worker.php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\Runtime\RenderContext; use Polidog\Rosanna\Runtime\RequestLifecycle; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; use Polidog\Rosanna\State\Storage\StorageType; use function Polidog\Rosanna\State\fc; use function Polidog\Rosanna\State\useState; require __DIR__ . '/vendor/autoload.php'; $Counter = fc(function (): Element { [$count] = useState(0); return H::span(children: "Count: {$count}"); }, key: 'counter', storageType: StorageType::Memory); $app = new RosannaState(); $app->setSnapshotSecret((string) getenv('ROSANNA_SNAPSHOT_SECRET')); $handle = function () use ($app, $Counter): void { $request = Request::fromGlobals(); echo RenderContext::run($app->scope($request), fn(): string => $app->renderElement($Counter())); }; while (frankenphp_handle_request($handle)) { RequestLifecycle::reset(); }
A RosannaState instance can be reused across requests; it keeps no per-request information.
Do not render after output
The session cookie can only be sent before output. If you print HTML and then render, the session can neither start nor resume, and:
- Session state looks like its initial value. Even for a visitor with a cookie the saved value cannot be read. This is not an error.
- Writing that state throws
LogicException. - Forms have no CSRF token, so a POST from a visitor with a cookie gets a 403.
- Without a secret the signing key cannot be kept in the session, so forms have no signature and the POST gets a 400 (call
setSnapshotSecret(), or render before output).
Put the rendered result in a variable before printing the layout as in the minimal example, or use ob_start(). Calling session_start() yourself first also works.
Framework CSRF and TLS
If your framework validates CSRF, opt out with $app->disableCsrfProtection(). For your own forms, $app->csrf()->token() returns the token.
Behind a TLS-terminating proxy $_SERVER['HTTPS'] is empty and valid POSTs get a 403. Pass it with something like fastcgi_param HTTPS on, or call $app->trustProxyHeaders(). Use the latter only when your proxy always overwrites the client's X-Forwarded-*. Otherwise an attacker can get around the same-origin check.
Defer
Fetch per-visitor parts, such as a login name or cart count, in a separate request after the page loads, so that the page itself can live on a CDN. The work is done by polidog/rosanna-defer, which also works without RosannaState if you do not need state.
<?php // public/index.php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\Runtime\RenderContext; use Polidog\Rosanna\State\Component\BaseComponent; use Polidog\Rosanna\State\Component\Component; use Polidog\Rosanna\Defer\Defer; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; use Polidog\Rosanna\State\Storage\StorageType; use function Polidog\Rosanna\State\fc; require __DIR__ . '/../vendor/autoload.php'; #[Component(name: 'AnnouncementBar', storage: 'memory')] #[Defer(name: 'announcement-bar', cacheControl: 'public, s-maxage=60', localCache: true, localCacheTtl: 60)] final class AnnouncementBar extends BaseComponent { public function render(): Element { return H::p(children: 'Sale today'); } } $UserHeader = fc( fn(array $props): Element => H::header(children: 'Hello ' . (string) ($props['name'] ?? 'guest')), key: 'user-header', storageType: StorageType::Memory, defer: new Defer(name: 'user-header', cacheControl: 'private, no-store', reloadable: true), ); $app = new RosannaState(); $app->setSnapshotSecret((string) getenv('ROSANNA_SNAPSHOT_SECRET')); $app->register(AnnouncementBar::class); $app->registerComponent('App\\UserHeader', $UserHeader); $app->registerDeferred('user-header', 'App\\UserHeader', 'private, no-store'); $request = Request::fromGlobals(); $deferred = $app->handleDeferred($request); if ($deferred !== null) { $deferred->send(); return; } $page = fn(): Element => H::main(children: [ H::defer('announcement-bar', fallback: H::p(children: '...'), localCache: true, localCacheTtl: 60), $UserHeader(['name' => 'Ann', 'fallback' => H::p(children: 'Loading')]), ]); echo RenderContext::run($app->scope($request), fn(): string => $app->renderElement($page()));
- The host routes
{prefix}/{name}to the app and callshandleDeferred()before page handling. The prefix is/_defer;setDeferPrefix('/api/_d')changes it, and thenRosannaState::renderClientScript('/rosanna.js', '/api/_d')needs the same value. register()also registers the endpoint of a#[Defer]class. For PSX,loadComponentManifest()registers from the deferred manifest.- Props other than
fallbackandkeybecome the query. Scalars only, and they arrive as strings.keyis not accepted from the URL. - Names match
[A-Za-z0-9_-]+, and registering a name twice throws. - Without JavaScript only the fallback is visible.
With one PHP file per page
There is no built-in router, so when embedding in per-page PHP files the web server must send /_defer/* to an entry file. An example .htaccess for Apache:
RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^_defer/ defer.php [L,QSA]
<?php // public/defer.php declare(strict_types=1); use Polidog\Rosanna\Html\H; use Polidog\Rosanna\Runtime\Element; use Polidog\Rosanna\Defer\Defer; use Polidog\Rosanna\State\Http\Request; use Polidog\Rosanna\State\RosannaState; use Polidog\Rosanna\State\Storage\StorageType; use function Polidog\Rosanna\State\fc; require __DIR__ . '/../vendor/autoload.php'; $Notice = fc( fn(): Element => H::p(children: 'Notice'), key: 'notice', storageType: StorageType::Memory, defer: new Defer(name: 'notice'), ); $app = new RosannaState(); $app->registerComponent('App\\Notice', $Notice); $app->registerDeferred('notice', 'App\\Notice'); $response = $app->handleDeferred(Request::fromGlobals()); if ($response === null) { http_response_code(404); return; } $response->send();
Caching
cacheControldefaults toprivate, max-age=0. Even with a shared-cache policy such aspublic, s-maxage=60, if rendering used the session or set a cookie the header becomesprivate, no-storewith a warning in the log. A host that callssession_start()on every request gets no shared caching.- Reading per-visitor data directly from
$_COOKIEis invisible to Rosanna. Setprivateyourself in that case. - Do not put action buttons in publicly cached fragments. A visitor with a session who submits a cached form fails signature verification.
localCache: truesaves tolocalStorage, andlocalCacheTtlis in seconds. Fragments containing a form, CSRF token or snapshot are not written tolocalStorageeven withlocalCache.window.Rosanna.clearDeferCache(nameOrUrl)clears it.reloadable: truekeeps the region refetchable, throughwindow.Rosanna.reloadDefer(nameOrUrl)ordata-rosanna-reload-defer="name"(after a successful partial update on a form, on click elsewhere).
rosanna.js
RosannaState::renderClientScript($src, $deferPrefix, $nonce, $deferSrc) prints a <script defer> and an inline fallback that fetches defers once if the external file cannot load. Under a strict CSP, pass a nonce as the third argument.
- After a morph a field's value is, in order: what the user edited during the request; the server's value if it rendered something different from last time; the rendered value for fields of the submitted form; otherwise the user's value. Hidden fields and a
button'svaluefollow the server. - Give rows of reordered or deleted lists an
id. Without one they are matched by position. A row that is a component needs noid, because its wrapper is matched bydata-rosanna. Pass each row akeyprop; without one the ID comes from the order of appearance, so values stay with the position rather than the row. - A component and its ancestors and descendants update in turn; siblings do not wait. A fixed-value action during a submission is dropped. A
useFormActionaction is resent with the latest value afterwards. - A failed partial update is not resent, because it may already be applied. A cancelable
rosanna:submit-error(detail.responseordetail.error) fires; if not cancelled, the wrapper getsdata-rosanna-error. - A form's token and signatures are tied to the session, so a login (
session_regenerate_id()) or logout in another tab makes the forms of a tab left open get rejected with a 403 or 400. On that rejectionrosanna.jsfirst fetches the page once with a GET, morphs each wrapper and firesrosanna:stale, before anyrosanna:submit-error. Input is kept, and pressing again works.- A rejection that keeps failing refreshes only once, not again until the next success, and fires
rosanna:submit-errorfrom the second time on. So a 403 from the host or a 400 for bad input does not fire this event the first time. - Nothing outside wrappers changes. A page that draws the user's name outside a wrapper shows the old one until it is reloaded. The page reloads when the refreshed page does not have the submitted instance, or when its GET is not 2xx or is redirected.
- With
disableCsrfProtection()a session change cannot be told apart, so the refresh does not fix it androsanna:submit-errorfires. Reload as in the example below. - Without JavaScript this recovery does not run. The plain 400 or 403 response is shown, and the page has to be opened again.
- A rejection that keeps failing refreshes only once, not again until the next success, and fires
document.addEventListener('rosanna:submit-error', (e) => { e.preventDefault(); // a rejection the refresh did not fix: a session change or an expired CSRF token if ([400, 403].includes(e.detail.response?.status)) location.reload(); });
CLI
vendor/bin/rosanna-state publish # copy rosanna.js to public/ vendor/bin/rosanna-state compile [paths...] # compile .psx and write the deferred manifest (default: ./components) vendor/bin/rosanna-state compile --check # write nothing, exit 1 if stale; for CI vendor/bin/rosanna-state compile --watch # recompile on change vendor/bin/rosanna-state compile --clean # empty the output directory vendor/bin/rosanna-state compile --cache=PATH # output directory (default: var/cache/psx) vendor/bin/rosanna-state help
A missing path, an unknown option or an unknown command exits 1.
polidog/rosanna installs vendor/bin/rosanna (compile and help) next to it. Its compile removes the deferred manifest, and --check reports one as stale, so use only rosanna-state compile in an app using RosannaState.
Security notes
Signatures, CSRF and URL filtering guarantee how values travel. The following is up to your application.
useStatevalues are client input. The server computes a setter's value, but a user can resend any action rendered for them as often as they like. Do not derive authorization, amounts, payment completion or user IDs from hook state. Read those from$_SESSIONor a database while rendering.- Snapshots appear in HTML in plain text. The HMAC stops tampering, not reading. Keep secrets and personal data out.
- Snapshots can be replayed and do not expire. They stay valid until the key changes (or the session ID, for session-bound ones). Persistent lets anyone with the link reproduce the state. The client also chooses what to send in
_rosanna_snapshots[]. - Do not trust snapshot values through an ancestor's props. On a partial update of a child,
rosanna.jscarries the snapshots of its ancestors in_rosanna_ancestors[]and the child renders with those values. As long as a snapshot is signed, the client can choose a value from any point in time. Carrying anything but a descendant of the target in_rosanna_snapshots[]gets a 400, but that check does not stop ancestor values. A plain POST withoutX-Rosanna-Partialskips this check, because a full-page answer without JavaScript needs the page's snapshots to travel. Do not use an ancestor's state for an authorization decision passed down to a child. - With the Session behavior, the POST route still accepts a snapshot from another session.
_rosanna_snapshotin the URL is not read, but_rosanna_snapshotin an action that passes verification can write an unbound value into your own slot. CSRF protection applies, so only your own session is affected; this falls under hook state being client input. /_defer/{name}is a public GET without authentication, outside the host's protection, even for a defer on a protected page. Check access inside the component. The query becomes props and is untrusted input. Validate it, and never let it reach tag names, attribute names oronClickand friends.HandRendererhave their own output rules, such as the unescaped content of<style>and<script>and the handling of tag and attribute names. See the notes inpolidog/rosanna.
The secret
php -r 'echo bin2hex(random_bytes(32));'
Generate it once and read it from your configuration. Use the same value across requests and servers. Changing it invalidates all valid snapshots and forms. Do not commit it. A key shorter than 16 bytes throws InvalidArgumentException: signatures appear in the HTML, so a short key can be brute-forced offline. Only the length is checked, so use a random value like the one above.
Signatures and CSRF
- An action's signature is bound to the page, session, instance and action JSON. Unsigned actions get a 400.
- A form inside a defer is bound to the defer's name and is accepted on a POST to any page. Anyone can fetch a fresh form from the endpoint, so this gives nothing away. The host's protection of the page being posted to still runs.
- A form in HTML inserted into another page gets a 400. Make it a defer, or name the POST target with
$app->signer()->sign($id, $json, $pagePath). - A POST whose
Origin(or elseReferer) is not the same origin gets a 403, and so does one with neither. If a session exists,_rosanna_csrfis compared too. - A
_rosanna_actionthat is not a JSON object is not treated as Rosanna's and passes through to the host.
Session cookies
Rosanna does not set cookie flags. Set them in php.ini or in session_start(). Calling session_regenerate_id(true) right after authentication is also the application's job.
session.cookie_httponly = 1 session.cookie_secure = 1 session.cookie_samesite = Lax
Known limitations
Even with a whole list in one useState, values travel as diffs, so HTML size is proportional to the row count. These shapes stay quadratic in the row count:
- With Snapshot, an
onSubmitform,input[type=submit|image]or abuttonwith your ownnameorvalueon each row. Each individual form carries a snapshot. - An operation that changes the whole value, such as reordering, on each row. The whole value is included.
- A full-page answer without JavaScript for Snapshot row components. Each form carries the other instances' snapshots.
- CPU time.
onClickclosures run while rendering.
Others:
- Double clicks and updates from several tabs do not add up; they are redrawn (Submissions from a stale page).
- Only PHP's native session is supported.
- A Persistent snapshot travels in the URL, so it is not carried when the URL would exceed 8000 bytes (Keeping snapshots outside the page). The figure follows the defaults of nginx (
large_client_header_buffers, 8k) and Apache (LimitRequestLine, 8190). Behind a proxy with a shorter limit the redirect still gets a 414, so use Session there. component()(Polidog\Rosanna\State\component()) renders no wrapper and always uses Session, so its actions reload the page instead of updating in place.- PSX output depends on absolute paths (PSX).
- Attribute spread (
<div {...$a} />) and namespaced attribute names are not available in PSX. - With a secret key, the first action of a visitor without a cookie starts the session. Other forms that were not redrawn have no CSRF token, so after that response (
X-Rosanna-Session-Started: 1)rosanna.jsfetches the page again with a GET and morphs each wrapper to get new forms. Fields being edited, focus and Snapshot state are kept. If the page cannot be fetched, it reloads. Clicks in the meantime follow the in-flight lock: fixed values are dropped anddata-rosanna-with-valueis resent with the latest value. For the same reason, Session actions from forms without a token are not sent at the same time. A stale form in another tab does the same refresh once after a 403 or 400 and firesrosanna:stale(rosanna.js covers logins and logouts). That action was not applied, so pressing again works. What remains:- The refresh costs one more GET, plus a GET per resolved defer holding a form and a
restorePOST per Snapshot it puts back. - Forms outside wrappers that the host built in JS are not renewed. Submitting one gets a 403, so reload on
rosanna:submit-error. - Without JavaScript, the plain POST's answer renews the forms of that tab, but another open tab gets a 403 and needs a reload.
- If a shared cache answers a request with a cookie using the anonymous page, the forms stay old. As with logged-in pages, do not serve requests with a session cookie from a cache.
- The refresh costs one more GET, plus a GET per resolved defer holding a form and a
- A host that builds a
Requestby hand should keepRequest::$cookiesand$_COOKIEthe same. Action checks readRequest::$cookies, while Session hooks resume the session from$_COOKIE.Request::fromGlobals()keeps them the same.
Migrating an app that used state on polidog/rosanna
Replacing new and use is not enough. An app that uses state goes through all of these steps:
- Run
composer require polidog/rosanna-state. - Change
new Rosanna()tonew RosannaState().setSnapshotSecret(),actions(),scope(),restoreSnapshots(),handleDeferred()andrenderClientScript()now belong toRosannaState, as doregister(),render()andcreateElement(). - Change
use function Polidog\Rosanna\Runtime\useState;and the like, in.psxand PHP, toPolidog\Rosanna\State\, following the table below. Change calls ofH::component()toState\component(). - Recompile with
vendor/bin/rosanna-state compile. Generated code copies theuselines of the.psx, so every.psxwith state needs recompiling. Only the shape of the component calls the compiler generates (RenderContext::scope()->psx->render) stays the same. - Change the namespace of
AssetInstaller::publishin thescriptsofcomposer.jsontoState\Installer\.publishitself moves too: usevendor/bin/rosanna-state publish.
Before (Polidog\Rosanna\) |
After (Polidog\Rosanna\State\) |
|---|---|
Runtime\fc, Runtime\useState, Runtime\useFormAction, Runtime\useEffect |
fc, useState, useFormAction, useEffect |
Html\H::component() |
component() |
Runtime\Action, Runtime\FunctionComponent, Runtime\ComponentState, Runtime\RendererFactory and the rest of the state classes in Runtime\ |
The same names directly under State\ |
Runtime\Snapshot |
Snapshot\Snapshot |
Protocol\*, Snapshot\*, Storage\*, Component\*, Http\*, Installer\* |
The same names under State\ |
RenderContext::scope()->renderers, ->signer, ->signingScope |
StateScope::of(RenderContext::scope())->renderers and so on |
Html\H, Html\createElement, Runtime\Element, Runtime\Renderable, Runtime\RenderContext, Runtime\RequestLifecycle, Runtime\PsxRenderer and Psx\* stay in polidog/rosanna under their names.
Migrating from usePHP
composer remove polidog/use-php composer require polidog/rosanna-state
- Replace
Polidog\UsePhpandUsePHPfollowing the table above: state goes toPolidog\Rosanna\State\, rendering toPolidog\Rosanna\, andUsePHPbecomesRosannaState. A plain text replacement toPolidog\Rosannais not enough. If you useSimpleRouteroruseRouter, move to your host's router or per-page PHP files. Relayer users change theuseofuseRouterto Relayer's. - Fix the
AssetInstallernamespace in thescriptsofcomposer.jsontoPolidog\Rosanna\State\Installer\. - Change
vendor/bin/usephptovendor/bin/rosanna-state. - Recompile PSX with
vendor/bin/rosanna-state compile. - Run
vendor/bin/rosanna-state publishto placerosanna.js, and fix theusephp.jsreference in your HTML. - If your own JS or templates mention
_usephp_*,data-usephp-*,X-UsePHP-Partialorwindow.usePHP, change them to_rosanna_*,data-rosanna-*,X-Rosanna-Partialandwindow.Rosanna. - Purge your CDN cache on deploy. Old and new names are never accepted together; old HTML and JS stop working.
A site that uses no hooks and no event props can move to polidog/rosanna alone; see its README.
The compiler is part of polidog/rosanna, so vendor/bin/rosanna-state compile also works after composer install --no-dev, in a build step or on the production server. Rendering compiled files does not load the compiler or nikic/php-parser.
API mapping
| usePHP | Rosanna |
|---|---|
$app->run(), getRouter(), disableRouter(), middleware |
None; the host does this (Embedding in a host) |
$app->handleAction() |
$app->actions()->handle($request, replay: ...), returning Response or null |
$app->handleDeferred() |
$app->handleDeferred($request), returning Response or null |
isolatedSnapshot(), persistentSnapshot(), sessionSnapshot() |
behavior on handle() and restoreSnapshots() |
sharedSnapshot() |
None; use SnapshotBehavior::Session |
$app->getCsrfToken() |
$app->csrf()->token() |
$app->signAction(), verifyActionSignature() |
$app->signer()->sign(), verify() |
$app->getSnapshotSerializer() |
$app->snapshotSerializer(), returning null instead of throwing |
$app->redirect() |
None; the redirect target is always the current URL |
$app->installPsxErrorHandler() |
None; use StackTraceRewriter::formatException() |
RenderContext::getApp(), setApp() |
RenderContext::run($app->scope($request), ...), RenderContext::scope() |
H::component() |
Polidog\Rosanna\State\component() |
CompileCommand::MANIFEST_FILENAME |
PsxRenderer::MANIFEST_FILENAME |
#[Component(requireKey: ...)] |
None; remove it, or it is an unknown named argument error |
Changes in behavior:
- A
BaseComponentinstance inchildrennow reads the storage in#[Component]and renders the same wrapper asfc(). The HTML changes. - Reading
useStateas an anonymous visitor does not start a session, and initial values are not stored. - A
_rosanna_actionthat is not JSON passes through to the host instead of getting a 400. _usephp_action_sigis gone; the value of_rosanna_actionis JSON holding the action and its signature.- Hooks outside a component throw
LogicException. - String attributes such as
<defer localCache="true" />are compile errors. Write{true}.
Development
composer install vendor/bin/phpunit vendor/bin/phpstan analyse --no-progress vendor/bin/psalm --no-progress vendor/bin/php-cs-fixer fix --dry-run --diff node --test tests/js/*.test.mjs npm ci && npx playwright install chromium && npx playwright test
License
MIT. See LICENSE.