Skip to content

Composition

situ has a full component model — props and events, slots, provide/inject, per-instance state, scoped CSS, recursion — with one unusual property: all of it resolves at compile time. A component tree is spliced into one FrontEnd before emission, producing one page and one island. There is no instance tree, reconciler, or per-component runtime.

The payoff is a tested invariant: however you slice a page into components, the island is the same program. A property test compiles 200 generated trees — random splits, nesting, repeated placements — against the equivalent hand-written monolith and requires them to match up to the renaming of component-private names; with no component owning private state, they are byte-identical. Decomposing a page costs no runtime layer and no generated code.

The one thing decomposition does add is a private namespace, and only where it must: see per-instance state.

Placing a component

A PascalCase tag in a template is a component host:

<Confirm :open="dialog_open" @confirmed="save"/>

<Card>
  <template slot="title">Storage</template>
  You are using 48 GB of 100 GB.
</Card>

Resolution: the Context

Tags resolve through an explicit, immutable registry — never by accident:

from situ import Context, mount_tree
import situ_ui

ctx = Context.from_dir(COMPONENTS)                     # scan *.html siblings; snake_case → PascalTag
ctx = situ_ui.kit().merge(ctx)                         # the whole component kit, registered once
ctx = ctx.with_override("Toast", HERE / "fancy_toast") # re-point one tag (also great for test doubles)

mount_tree(path="/board", root=COMPONENTS / "board", components=ctx,
           template="index.html", meta=META)

mount_component takes the same components= / overrides= (the older explicit children={...} dict still works, deprecated). An unresolved tag is a hard CompileError — and you don't have to wait for boot to see it:

python -m situ.check demos/issues/components/issues --from-dir demos/issues/components
python -m situ.check demos/ui_gallery/components/gallery --kit

situ check statically resolves every tag a tree reaches, reporting every unresolved host — and checks the DataGrid column contract on every <th>. Wire it into your lint loop (this repo runs it inside make lint over nine trees).

Props down, events up

A child declares its contract with two transparent markers:

# confirm.py (the child)
from situ import Prop, Emit

open: Prop[bool]                 # parent → child data
confirmed: Emit[None]            # child → parent event

def ok() -> None:
    confirmed()                  # a handler may raise its Emit

The parent binds them at the placement site: <Confirm :open="dialog_open" @confirmed="save"/>. At splice time the parent's expression is substituted into the child as an AST, and the emit name rewrites to the parent's handler. Missing, extra, duplicate, or unused bindings are CompileErrors.

Names are checked; cross-file types are not

Prop[str] type-checks the child's own body, but the parent↔child match is by name only — a Local[str] bound to a Prop[dict] passes the checkers and surfaces in e2e. Known limitation, stated in the markers' docstring.

Slots

<slot> / <slot name="x"> in the child; content (and <template slot="x"> blocks) in the paired host. Slot content is spliced verbatim in the parent's scope — a @click="close_dialog()" you pass into a <Dialog> calls your handler, by construction. Content given to a slot-less child fails closed. Nested slotted components resolve correctly (each consumes its own <template slot> blocks).

Provide / Inject

A hierarchical value channel without prop-threading:

theme: Provide[str] = "dark"      # an ancestor declares it
theme: Inject[str]                # any descendant reads it like a signal

In the flat island a Provide is simply a shared cell and an Inject a compile-time-verified alias — so reactivity (toggle the provider, every injector re-renders) falls out for free, and an Inject with no ancestor Provide is a CompileError. Demo: /theme.

Per-instance state: monomorphization

A component may own its state (Locals + handlers, no Prop/Emit) — an accordion section, a stepper, a tally. Place it twice and each placement gets its own state:

<Tally/>
<Tally/>      <!-- independent counts -->

The compiler monomorphizes at splice time — countcount__Tally0 / count__Tally1, handler bodies cloned and rewritten — like C++ template instantiation, still one island. Works transitively (a stateful child reached through a layout component, placed twice — demo /nested) and per-row (a stateful child under a top-level :each gets its state routed into the row reconciler).

When the suffix is applied. A component that owns handlers is instance-scoped always — even placed once — so its private names are suffixed. That is what stops a child's bump() from colliding with a parent's bump(): a component's internals are its own. A component that owns only Local state and no handlers is suffixed only when it is actually placed more than once (a single placement then flat-merges, and is byte-identical to the monolith).

So "decomposition is free" means no runtime layer and no extra generated code — not that the private names come out spelled the same. The island a tree emits is the monolith's program, with component-private names renamed where encapsulation requires it.

Scoped CSS

A child component may ship its own <style>; the compiler stamps data-sc="Tag" on the elements it owns and rewrites each selector with a zero-specificity :where([data-sc="Tag"]) clause, hoisting the block once. Styles reach the component's root and descendants; slot content and nested children are parent markup and keep the parent's styling. The mechanism is stamped attributes plus ordinary CSS — it needs no Shadow DOM and no runtime support. A top-level component's <style> fails closed. Demo: /scoped.

Depth, cycles, and recursion

  • Components compose to any finite depth — a <Dialog> built from <Card> + <Btn>, placed by the page (demo /dialog); props and events thread through every level.
  • A placement cycle (A > B > A, or self-placement) is caught statically before splicing, with the path named. Composition is a finite tree.
  • Data-driven recursion is a different thing and fully supported: a component that uses :tree internally renders itself to any depth from the data — one <Comment> definition is the whole reply thread (demo /thread).

The envelope

Everything above is the prop-driven, finite envelope, and every exit from it fails closed with an instructive error: a bare (colon-less) prop on a nested host, an own-Local child under an intermediate's :each, a cycle, a top-level <style>. The strictness is the feature — see Compile errors.