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:
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
:treeinternally 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.