Documentation menu

Common Pitfalls

A collection of bugs that are easy to hit and hard to diagnose. Each one describes what goes wrong, why it happens, and how to fix it.

Broadcast re-renders other clients with stale data

You call $app->broadcast() after an action, but other clients' views show outdated values.

Why: When broadcast() fires, every context in the scope re-runs its view closure. If the view reads from a per-client signal (e.g. $votes->getValue()), each client reads its own signal — which was only updated for the client that triggered the action. Clients that didn't trigger the action still have stale signal values.

Fix: Read shared state directly inside the view closure instead of from a per-client signal. $app->globalState() is always correct for all clients:

// ❌ stale for other clients
$c->view(function () use ($votes) {
    $count = $votes->int(); // only correct for the client that called $votes->setValue()
});

// ✅ correct for everyone
$c->view(function () use ($app) {
    $count = (int) ($app->globalState('vote_count') ?? 0);
});
Shared counter uses globalState + manual broadcast instead of scoped signals

You track shared state with $app->globalState() and a Signal, manually keeping both in sync and calling $app->broadcast() yourself. The action is verbose and error-prone.

Why: Scoped signals already are shared state. When a context has a non-TAB scope, every $c->signal() call for the same name returns the same Signal object across all contexts in that scope. Calling setValue() updates the single shared instance and auto-broadcasts by default. Mirroring the value into globalState creates two sources of truth you must synchronize manually.

// ❌ dual state — globalState + signal kept in sync manually
$c->scope(Scope::GLOBAL);
$count = $c->signal($app->globalState('count'));

$increment = $c->action(function () use ($app, $count): void {
    $newVal = $app->globalState('count') + 1;
    $app->setGlobalState('count', $newVal);
    $count->setValue($newVal);
    $app->broadcast(Scope::GLOBAL);
});

// ✅ scoped signal is the single source of truth
$c->scope(Scope::GLOBAL);
$count = $c->signal(0);

$increment = $c->action(
    fn () => $count->setValue($count->int() + 1)
    // auto-broadcasts because signal scope != TAB
);

When is globalState appropriate? For server-side values that are not signals — e.g. a status string read inside a view closure, or data shared across different routes that don't have a common scope. If the value drives a data-text or data-bind in the browser, use a scoped signal instead.

data-text uses the wrong signal ID

You write data-text="$votes.tab" in your template, but the counter never updates (or updates with empty/undefined).

Why: Signal IDs are namespaced. A signal named votes inside a component with namespace poll gets the ID poll.votes — but inside a component the dot is rendered as-is in Datastar's signal store. If you hardcode the ID you'll likely get it wrong.

Fix: Always use $signal->id() in PHP and pass it to the template. Never hard-code signal IDs in HTML:

$c->view(function () use ($votes, $twig) {
    return $twig->render('poll.html.twig', [
        'votes_id' => $votes->id(), // e.g. "poll.votes"
    ]);
});
<!-- ❌ hard-coded, will break if namespace changes -->
<span data-text="$poll.votes.tab">0</span>

<!-- ✅ dynamic, always correct -->
<span data-text="${{ votes_id }}.tab">0</span>
Full-page patch overwrites component updates

You use $c->component() to build isolated sub-contexts but on every broadcast the entire page is replaced, wiping out or replacing the fresher component patches.

Why: A page-level $c->view() with a Twig template re-renders the whole page HTML on every broadcast and sends it as an elements patch with no selector — Datastar morphs the entire body. Component patches target specific #c-... divs, but the page patch can arrive after and overwrite them with stale embedded HTML.

Fix: If your page doesn't use components and the template has a named block around the dynamic content, use block: to send only that block on updates:

// Preferred: send only the 'demo' block on SSE updates
$c->view(fn() => $c->render('page.html.twig', $data), block: 'demo');

If the page does use components, make the page-level view a no-op on updates instead. Components own their own patches:

$c->view(function (bool $isUpdate) use ($c, $data): string {
    if ($isUpdate) {
        return ''; // components handle their own patches
    }
    return $c->render('page.html.twig', $data);
});
Variable is null inside a view closure

You get a fatal "Call to a member function on null" inside a $c->view() closure.

Why: PHP closures don't capture outer variables automatically. The use list on the outer page handler and the inner view closure are separate. A variable available in the route handler (e.g. $twig, $app) is not available inside the view closure unless explicitly listed in its use.

Fix: Add the variable to both the route handler's use and the inner view closure's use:

// ❌ $twig is null inside the view closure
$app->page('/', function (Context $c) use ($shell): void {
    $c->view(function () use ($twig) { // $twig was never captured by the outer closure
        return $twig->render('home.html.twig');
    });
});

// ✅
$app->page('/', function (Context $c) use ($shell, $twig): void {
    $c->view(function () use ($twig): string {
        return $twig->render('home.html.twig');
    });
});
Scoped signals return stale values after broadcast

You create a signal with a non-TAB scope (e.g. Scope::routeScope('/')). After calling broadcast(), some clients re-render with the old value.

Why: Scoped signals are shared objects. If the view is cached (default for ROUTE/SESSION/GLOBAL scopes), later clients reuse the cached HTML without re-rendering. The cached HTML contains the old value baked in. Only the first render after a cache miss picks up the new value.

Fix: Disable view caching for views that must always reflect current state:

$c->view('presence.html.twig', ['count' => count($app->getClients())], cacheUpdates: false);
PatchElementsNoTargetsFound on partial block rendering

You use Twig block rendering to send only a fragment on updates (e.g. $c->render('page.html.twig', $data, 'content')), but the browser console shows PatchElementsNoTargetsFound and nothing updates.

Why: Datastar's merge-fragments mode reads the id from the root element in the fragment and looks for a matching element in the DOM. If the block content has no root element with an id, there's nothing to match against.

Fix: Make sure block content has a single root element with a unique id:

{# template.html.twig #}
{% block demo %}
<div id="todo-list">
    {# ✅ Datastar reads id="todo-list" from the fragment and morphs that element #}
    {% for item in items %}
        <li>{{ item.text }}</li>
    {% endfor %}
</div>
{% endblock %}
$c->view(fn () => $c->render('template.html.twig', $data), block: 'demo');
Partial update replaces the entire page (duplicate id="content")

You render a block on update whose outermost element uses id="content", but so does the <main> wrapper in your shell template. After the first broadcast, the entire page (header, source panel, layout) disappears and only the fragment remains.

Why: Datastar's merge-fragments mode looks for a DOM element matching the root element's id in the patch. If your fragment and the shell both use id="content", Datastar replaces the outer <main> — wiping out everything around the fragment.

Fix: Give the block's root element a unique ID that doesn't collide with the shell:

{# ❌ collides with <main id="content"> in the shell #}
{% block demo %}
<div id="content">...</div>
{% endblock %}

{# ✅ unique ID — Datastar morphs only this element #}
{% block demo %}
<div id="todo-list">...</div>
{% endblock %}
Timer keeps firing after every client disconnects

You set up a $app->setInterval() timer on server start that broadcasts to a route. Even when no clients are on that route, the timer keeps running and broadcasts uselessly.

Why: setInterval runs for the lifetime of the server, not per client. It has no built-in awareness of connected clients.

Fix: Guard broadcasts with a client check, or use onClientConnect and onClientDisconnect hooks instead of a timer for presence-style updates:

// Guard the timer
$app->setInterval(function () use ($app): void {
    if (empty($app->getClients())) {
        return;
    }
    $app->broadcast(Scope::GLOBAL);
}, 1000);

// Or eliminate the timer entirely with connect/disconnect hooks
$app->onClientConnect(function (Context $c) use ($app): void {
    $app->broadcast(Scope::routeScope($c->getRoute()));
});
$app->onClientDisconnect(function (Context $c) use ($app): void {
    $app->broadcast(Scope::routeScope($c->getRoute()));
});
Shell templates don't support Twig — use page-level inheritance instead

You try to use {% extends %}, {% block %}, or other Twig tags inside a shell template (the HTML file passed to withShellTemplate() or setShellTemplate()), but nothing happens or you get a parse error.

Why: Shell templates are processed by HtmlBuilder using a plain str_replace(), not the Twig engine. They only understand the six literal placeholders: {{ signals_json }}, {{ context_id }}, {{ base_path }}, {{ head_content }}, {{ content }}, and {{ foot_content }}. Twig tags are invisible to this processor and will be emitted as raw text.

Fix: Don't put Twig logic in shell templates. Instead, move the layout into a proper Twig base template and have your page templates extend it. The full HTML document (including <!DOCTYPE html>, nav, footer) lives in the base template; page templates fill in {% block content %}. HtmlBuilder detects a rendered <html> response and passes it through unchanged:

{# templates/shells/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
    <meta data-signals='{"via_ctx":"{{ contextId }}","_disconnected":false}'>
    <title>{% block title %}{% endblock %}</title>
</head>
<body>
    <nav>...shared nav...</nav>
    <main>{% block content %}{% endblock %}</main>
</body>
</html>

{# templates/pages/home.html.twig #}
{% extends 'shells/base.html.twig' %}
{% block title %}Home{% endblock %}
{% block content %}
    <h1>Hello</h1>
{% endblock %}

The key variable is {{ contextId }} (injected automatically by Context::render()) — not {{ context_id }} or {{ via_ctx }} which are the HtmlBuilder placeholders.

Action returns HTTP 400 "Bad Request"

You click a button or trigger a keyboard shortcut and the action request returns 400 Bad Request. Nothing happens in the UI.

Why: Every action request includes a via_ctx signal that identifies the server-side context. The server looks up this ID in its in-memory context registry. A 400 means the ID is missing or doesn't match any active context. Common causes:

  • The server was restarted — all contexts from the previous process are gone, but the browser still has stale action URLs and signal values from the old session.
  • The SSE connection dropped and the context was cleaned up via onDisconnect, but the UI wasn't refreshed.
  • The via_ctx signal was omitted from the request body or query params.

Fix:

  • Stale context after restart or disconnect: Refresh the page. This creates a new context with a fresh ID. If this happens frequently, check that your SSE connection is stable — a flaky connection causes repeated context creation and teardown.
  • Custom payload in e.g. @post(): If you pass a custom object to actions like @post('/action', {some: 'payload'}), the custom object replaces the signal store — via_ctx and all other signals are no longer included. Either omit the second argument and let Datastar send the full store, or include via_ctx explicitly in your custom payload.

Debugging tips

  • Add <pre data-json-signals></pre> to your template to see the live Datastar signal store in the browser. Remove before deploying.
  • Check signal IDs with var_dump($signal->id()) — namespace prefixing can produce unexpected IDs.
  • Run the server with LOG_LEVEL=debug to see every render, cache hit, and broadcast in the terminal output.
  • If the view closure is never called, confirm $c->view() is actually reached — missing or mis-ordered use variables silently skip execution if a required call throw for a null object.

See also

  • Broadcasting — scope targets and update strategies
  • Lifecycle — connect, disconnect, timers
  • Views — caching behaviour and update cycles
  • Signals — scope types and sharing state