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=. An unresolved tag is a hard CompileError. 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. It also 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; 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

The channel carries a value down the hierarchy 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. 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, renaming count to count__Tally0 / count__Tally1 and cloning the handler bodies, the way C++ instantiates a template. It still emits 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. It 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. The private names may still come out spelled differently. 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. 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.