Common Pitfalls
Bugs that are easy to hit and hard to diagnose. Each one describes what goes wrong, why it happens, and how to fix it.
Pages that do not connect
The page renders but nothing reacts
The page loads, but clicks do nothing, data-text never changes and the browser opens
no request to /_sse.
Why: The page has no via_head, which opens the SSE stream, or no
via_foot, which loads Datastar. php-via logs a warning once for such a shell or route.
Fix: Write the bootstrap where it belongs:
<!-- shell.html -->
<head>
<meta charset="UTF-8">
{{ via_head }}
...
</head>
<body>
...
{{ via_foot }}
</body>In a Twig layout write {{ via_head() }} and
{{ via_foot() }}, and in a callable that returns a whole
document $c->viaHead() and $c->viaFoot(). See
Templates → The page bootstrap.
Datastar stops working under a Content-Security-Policy
With a policy in place, the console shows EvalError or GenerateExpression
errors, or scripts sent with execScript() never run.
Why: Without data-nonce on the <html> element, Datastar
runs every data-* expression through the Function constructor, which needs
'unsafe-eval', and a nonce policy blocks the scripts the server sends.
Fix: Set a nonce in the via.csp_nonce request attribute and allow the
nonce alone. via_head and via_foot write it on their tags, and the default shell
writes data-nonce on <html>. A custom shell needs
<html{{ via_html_attrs }}>, a Twig layout
<html{{ via_html_attrs() }}>. See
Templates → Content-Security-Policy.
Action returns HTTP 400 "Bad Request"
A click posts to the action, the response is 400 Bad Request, and nothing happens.
Why: Every action carries the via_ctx signal, which names the tab's
context. A 400 means it is missing, or names a context php-via neither holds nor can rebuild. A tab that
was away briefly is rebuilt (revival); these cases are not:
- The server restarted, and the records it rebuilds tabs from went with it.
- The tab was away longer than the revival window, 10 minutes by default (see
withContextTimeouts()). - A form posted with Datastar's
contentType: 'form'sends its fields instead of the signals, and has novia_ctxfield.
Fix: After a restart or a long absence, reload the page; the tab's next stream reconnect does so by itself. Give a form the field:
<form enctype="multipart/form-data" data-on:submit__prevent="@post('{{ upload.url }}', {contentType: 'form'})">
<input type="hidden" name="via_ctx" value="{{ contextId }}">
<input type="file" name="avatar">
<button>Upload</button>
</form>Scopes and broadcasts
signal() throws after scope()
$c->signal(0, 'count') throws a LogicException on a page that called
$c->scope(Scope::ROUTE).
Why: The primary scope from scope() is the target of
$c->broadcast(), not a default for signals. A signal without a scope is private to the
tab, and php-via throws rather than let a page that meant to share it silently keep it per tab.
Fix: Name the scope where the signal is declared:
$c->scope(Scope::ROUTE);
$count = $c->signal(0, 'count', Scope::ROUTE); // shared by every tab on this routeA value private to the tab can still be declared before scope() is called.
$c->broadcast() updates only my own tab
An action changes shared state and calls $c->broadcast(), but only the tab that posted
it renders again.
Why: $c->broadcast() broadcasts the context's primary scope, and the
default primary scope is TAB, so it syncs that one tab. Scopes the tab joined with
addScope() or a scoped signal are not its primary scope. Dev mode warns once when a tab
that joined other scopes broadcasts this way.
Fix: Broadcast the scope you mean, or set a shared primary scope:
$app->broadcast('room:' . $room); // every tab in the room
// or, for the whole page:
$c->scope(Scope::ROUTE);
// ...
$c->broadcast(); // every tab on this routeA scoped signal needs neither: a write that changes it broadcasts its scope.
Other tabs show stale values after a broadcast
You broadcast after an action, but the other tabs still show the old value.
Why: A broadcast renders every tab's view again. A view that reads a TAB signal shows each tab its own copy, and the action changed only the copy of the tab that posted it.
Fix: Keep the shared value where every tab reads the same one, in a scoped signal or in global state:
// ❌ each tab reads its own copy
$votes = $c->signal(0, 'votes');
// ✅ every tab on the route reads one value, and a write broadcasts the route
$votes = $c->signal(0, 'votes', Scope::ROUTE);Shared state kept in globalState and a signal at once
You track a shared value with $app->globalState() and a signal, keep both in
step by hand and call $app->broadcast() yourself.
Why: A scoped signal already is shared state: every context that declares a signal
with the same name and scope shares one value, and a write that changes it broadcasts the scope. A copy in
globalState is a second source of truth to keep in step.
// ❌ two sources of truth, kept in step by hand
$count = $c->signal($app->globalState('count', 0), 'count', Scope::GLOBAL);
$c->action(function () use ($app, $count): void {
$app->setGlobalState('count', $app->globalState('count', 0) + 1);
$count->setValue($app->globalState('count'));
$app->broadcast(Scope::GLOBAL);
}, 'increment');
// ✅ the scoped signal is the only source
$count = $c->signal(0, 'count', Scope::GLOBAL);
$c->action(fn () => $count->increment(), 'increment'); // atomic across workers, broadcastsWhen is globalState right? For server-side values that no
data-text or data-bind shows, such as a status a view reads, or data shared
between routes without a common scope.
Every tab of a route pattern shares one ROUTE scope
On /chat/{room}, a message posted in /chat/lobby shows up in
/chat/kitchen too.
Why: Scope::ROUTE resolves to the route pattern,
Scope::routeScope('/chat/{room}'), the same for every URL it matches.
Fix: Use a custom scope per room:
$app->page('/chat/{room}', function (Context $c, string $room): void {
$messages = $c->signal([], 'messages', 'room:' . $room);
// ...
});A shared render shows one user's data to everyone
With view(..., shareRender: true), every tab shows the name or the cart of one visitor.
Why: A shared render renders each update once and sends the same HTML to every tab of the view. A view that reads a TAB signal, the session, per-user data or a component renders the first tab's version for all of them.
Fix: Leave shareRender out of views that differ per tab. Keep it for
views that are the same for everyone, such as a game board, and move what differs into a separate view
or into signals. See Views.
Timer keeps broadcasting when nobody is watching
A $app->setInterval() timer broadcasts to a route every second, also when nobody has
it open.
Why: The timer runs for the life of the server and knows nothing about clients.
Fix: Skip the work when countClients() finds nobody. It counts the
connected tabs a broadcast of the scope reaches, on every worker:
$app->setInterval(function () use ($app): void {
if ($app->countClients(Scope::routeScope('/ticker')) === 0) {
return;
}
$app->broadcast(Scope::routeScope('/ticker'));
}, 1000);In the browser
Two buttons that post the same action cancel each other
Pressing Enter in a field and clicking Send at once runs the action only once, or a quick second click drops the first.
Why: Datastar cancels a request still in flight when any element sends another one with the same method and URL. A request cancelled before it reaches the server never runs its action.
Fix: Tell the requests apart with a query, or turn cancellation off where every request counts:
<input data-bind="{{ message.class="hl-property">id }}" data-on:keydown="evt.key === 'Enter' && @post('{{ send.url }}?from=enter')">
<button data-on:click="@post('{{ send.url }}?from=button')">Send</button>
<button data-on:click="@post('{{ paint.url }}', {requestCancellation: 'disabled'})">Paint</button>A dialog opened with showModal() closes on the next update
A confirmation dialog closes by itself as soon as a broadcast or a reconnect renders the view.
Why: A morph sets each element's attributes to those of the server's markup, and
every update and every stream connect renders the view again. The open attribute that
showModal() set is not in the server's markup.
Fix: List it in data-preserve-attr, in the markup the server renders:
<dialog id="confirm" data-preserve-attr="open">...</dialog>A checkbox set by a script does not update its signal
A script ticks a checkbox and dispatches change, but the bound signal keeps its value.
Why: Datastar 1.0.4 updates a signal bound to a checkbox or a radio button on the
input event.
Fix: Dispatch input, or bind with the __event.change
modifier:
box.checked = true;
box.dispatchEvent(new Event('input', { bubbles: true }));A value the browser posts is ignored
The browser shows a new value in a bound input, but the action sees the old one, and the next sync puts the old value back.
Why: php-via writes a posted value into a signal only when the signal takes client
values: TAB signals do unless withStrictTabSignals() is on, scoped signals only with
clientWritable: true. The value must also have the type of the signal's initial value; a
lossless form such as '5' for an int is converted, anything else is refused. Dev mode warns
once per signal.
Fix: Declare the signal clientWritable: true, give it an initial value
of the type the browser sends, or declare it with null to take any type:
$note = $c->signal('', 'note', Scope::ROUTE, clientWritable: true);
$limit = $c->signal(10, 'limit'); // '25' from a text input arrives as 25
$pick = $c->signal(null, 'pick'); // any typedata-text uses the wrong signal id
You write data-text="$votes", and the element never updates or shows nothing.
Why: A signal's browser id is not its name. TAB and component signals get an id that is unique per tab, and the exact form is not part of the API.
Fix: Let php-via write it: ref() for expressions, id() or
bind() for bindings.
$c->view(fn (): string => "<span data-text=\"{$votes->ref()}\">{$votes->int()}</span>");<span data-text="{{ votes.class="hl-property">ref }}">{{ votes.int }}</span>Views and components
Full-page patch overwrites component updates
You use $c->component(), but every broadcast to the page puts the components back to
how they looked at page load.
Why: A page view renders the whole page on every broadcast that reaches it, and on a
reconnect or a revival, and Datastar morphs the page with it. Component HTML rendered once in the
handler, such as 'counter' => $counter() in an array passed to view(), goes
out with each of those frames.
Fix: Call the component callables inside the view callable, so each page render carries their current HTML:
$c->view(fn (): string => $c->render('page.html.twig', ['counter' => $counter()]));A component in a loop throws about its namespace
$c->component(Item::class, 'item') in a loop throws an
InvalidArgumentException on the second pass.
Why: A namespace prefixes the component's signals and action URLs, so it is unique on
its page and takes letters, digits, _ and - only.
Fix: Build it from a key:
foreach ($items as $item) {
$rows[] = $c->component(Item::class, 'item-' . $item->id);
}The view shows the values from page load
Updates arrive, but the view keeps showing what it showed at page load.
Why: An array passed to $c->view() is built once, when the page
handler runs. Every later render shows the values it held then.
Fix: Pass the data as a callable, which runs on every render:
$c->view('presence.html.twig', fn (): array => ['count' => $app->countClients(Scope::routeScope('/'))]);Variable is null inside a view closure
A $c->view() closure fails with "Call to a member function on null" or an undefined
variable.
Why: A closure declared with function () captures only the variables in
its use clause, and the page handler's clause and the view's are separate.
Fix: Add the variable to both, or write the view as an arrow function, which captures outer variables by itself:
// ❌ $repo was never captured by the page handler
$app->page('/', function (Context $c): void {
$c->view(function () use ($repo): string {
return '<ul id="list">' . $repo->listItems() . '</ul>';
});
});
// ✅
$app->page('/', function (Context $c) use ($repo): void {
$c->view(fn (): string => '<ul id="list">' . $repo->listItems() . '</ul>');
});PatchElementsNoTargetsFound on block updates
You send only a Twig block on updates with view(..., block: 'demo'), the console shows
PatchElementsNoTargetsFound, and nothing updates.
Why: An update without a selector morphs the element whose id matches
the root element of the patch. A block without a single root element that has an id
leaves Datastar nothing to match. Dev mode logs a warning once per route for such an update.
Fix: Give the block one root element with a unique id:
{% block demo %}
<ul id="todo-list">
{% for item in items %}
<li>{{ item.text }}</li>
{% endfor %}
</ul>
{% endblock %}A block update replaces the whole page (duplicate id="content")
After the first update, the page's layout disappears and only the block is left.
Why: The default shell wraps the view in an element with id="content".
A block whose root element uses id="content" too replaces that wrapper.
Fix: Give the block's root element an id the shell does not use:
{# ❌ collides with the shell's id="content" #}
{% block demo %}<div id="content">...</div>{% endblock %}
{# ✅ #}
{% block demo %}<div id="todo-list">...</div>{% endblock %}Setup
A template view throws that the app has no template engine
$c->view('page.html.twig') throws a LogicException, or
new Via() throws for withTemplateDir().
Why: twig/twig does not come with php-via.
Fix: Install it, or return HTML from a callable view:
composer require twig/twigA Config change after new Via() throws
$config->withStaticDir(...) after new Via($config) throws a
LogicException.
Why: new Via() freezes the Config, so a later change throws instead of
being ignored or half applied.
Fix: Make every with* call before new Via(). A test that
needs a variant works on clone $config, which is not frozen.
Shell templates do not support Twig
Twig tags such as {% extends %} or
{% block %} in a shell (the file passed to
withShellTemplate() or setShellTemplate()) show up as text.
Why: A shell is plain HTML with placeholders that php-via replaces as strings. See Templates → Shells for the placeholders.
Fix: Move the layout into a Twig base template that renders the whole document, and let page templates extend it. php-via then skips the shell:
{# templates/shells/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
{{ via_head() }}
<title>{% block title %}{% endblock %}</title>
</head>
<body>
<main>{% block content %}{% endblock %}</main>
{{ via_foot() }}
</body>
</html>
{# templates/pages/home.html.twig #}
{% extends 'shells/base.html.twig' %}
{% block title %}Home{% endblock %}
{% block content %}<h1>Hello</h1>{% endblock %}Debugging tips
- Open the Dev Bar, on in dev mode: its SSE panel shows every patch that reached the page, and its Logs panel php-via's warnings.
- Add
<pre data-json-signals></pre>to a template to see the browser's signals live. Remove it before you deploy. - Run with
withLogLevel('debug')to see every render, shared render hit and broadcast in the terminal. - In dev mode a failed page shows the exception's class and message instead of "Internal Server Error".
See also
- Broadcasting: scope targets and update timing
- Lifecycle: connect, disconnect, revival and timers
- Views: updates, blocks and shared renders
- Signals: scopes and client writes