The unified idiom¶
Experimental — opt in per mount
The unified idiom is a working spike, enabled with mount_component(..., unified=True) and proven by the /mail demo + its e2e. The surface may still change (the ~ glyph in particular is under review). The base dialect is not going anywhere.
The problem it removes¶
In the base dialect you hand-sort every template token by where it executes: Jinja {{ t.subject }} (server, frozen) one attribute away from :text="count" (client, live). That execution-location axis is exactly the thing situ derives everywhere else. The axis that deserves your attention is different: what crosses the wire, and what is live — because that governs payload, exposure, and latency.
The unified idiom dissolves the first axis and makes the second legible — in plain source text, no editor tooling required.
One interpolation¶
<span>filtering: { search }</span> <!-- depends on a Local → live client binder -->
<button>{ t.sender } — { t.subject }</button> <!-- row/server state → server-rendered -->
Write { expr } everywhere. The compiler reads the expression's dependencies and their sites: touching any Local/Synced signal → a live binder; touching only server/url/row state → server-rendered. You never choose {{ }} vs :text again. ({ } is JSX/Svelte's glyph, picked because Jinja owns {{ }}.)
The wire projection¶
# in the component .py — the projection's name is a TypedDict declaring each field's type
from typing import TypedDict
class Message(TypedDict):
id: int
subject: str
sender: str
unread: bool
<ul :each="t in mailbox ~ Message[id, subject, sender, unread]">
<li :show="search.lower() in t.subject.lower()"
:class="open: open_id == t.id">
<button @click="open_id = t.id; open_msg(t.id)">
{ t.sender } — { t.subject } { "●" if t.unread else "" }
</button>
</li>
</ul>
<div data-region class="reading-pane">
{% if open_message %}<pre>{ open_message.body }</pre>{% endif %}
</div>
The ~ Message[...] clause is the complete list of per-row fields allowed to cross to the browser, and its name is the row's type: Message refers to a TypedDict in the .py, so t.id is decoded to an int and open_id == t.id is a real integer comparison. (The named projection is exactly the data-row="Message" declaration a hand-written server row makes — the desugarer emits it for you.) The desugarer emits the server loop with data-* attributes for exactly the projected fields, and referencing anything else is a build failure (real output):
CompileError: t.body is referenced in the :each over 'mailbox', but 'body' is
not in its client projection ~[id, subject]. It never crosses to the browser.
Add it to the projection (it will be shipped), or load it on demand via a
server read into a data-region.
So the classic leak — render every message body and CSS-hide all but one — is impossible to compile. The mail demo browser-verifies the contract: no body text in the initial DOM, client search filters with zero requests, and opening a message fetches exactly one body via the open_msg command.
Every way of reading a row is checked¶
A fence with a bypass is not a fence, so the rule is stated over the whole loop body, not over the places a leak was expected to appear: every mention of the row variable in any code region — a binder attribute (either quote style), a {{ … }} interpolation, or a Jinja {% … %} block — must be a literal read of a projected field. Each of these is a CompileError:
{% set leaked = t.body %}{{ leaked }} <!-- laundered through a Jinja block -->
{{ t['body'] }} <!-- subscript instead of attribute -->
{% for c in t.body %}{{ c }}{% endfor %} <!-- iterated -->
{% if t.body %}…{% endif %} <!-- tested -->
{{ t }} <!-- the whole row -->
{{ t[key] }} <!-- a key the compiler can't resolve -->
The last two are rejected because they cannot be checked field-by-field: handing over the row object, or a field chosen at render time, would ship everything the projection exists to hold back. Plain text is not a code region — a literal t.body in prose renders as that string and is fine.
(Several of these compiled before 2026-07-13, and three of them leaked the field's full value into the HTML. They were found by probing every access path rather than trusting the two the tests covered; see notes/34.)
Why this is worth a new construct¶
The projection is written at the data source, so wire cost is readable off the template; it doubles as a per-row schema (the fields and, eventually, their types — the groundwork for derived typed templates); and it turns an OWASP-catalogued defect class (excessive data exposure through over-rendering) into a compile error. It is the one construct in situ's surface with no template-language ancestor — the lineage is SQL's π and GraphQL selection sets, moved into the template.
Current limits¶
- Conditionals are still Jinja
{% if %}(a site-derived:ifis the designed next step). - The interpolation translator covers the common expression subset and fails closed on the rest.
- A client interpolation SSRs empty and fills at boot (no first-paint value yet).
- A purely server-rendered list (no client binders inside) is ordinary SSR and needs no projection.