-
Notifications
You must be signed in to change notification settings - Fork 2
Description
Summary
Introduce a Ref system that provides type-safe reactive references, eliminates namespace collisions via auto-generated component keys, and lets developers build expressions using Python operators and method chains that compile to the {{ }} expression DSL.
Supersedes #150 (namespace scoping) and #156 (type-safe refs) — the Ref system dissolves both problems with a single design.
The problems this solves
Namespace collisions (#150): Today, name="email" is both a human label and a global state key. Two components with name="email" clobber each other. Scoping (dot-path prefixes from ancestor containers) was one fix, but it creates fragile references that break when the component tree is refactored — {{ signup.email }} stops working if you move the form into a different parent.
Stringly-typed bindings (#156): {{ }} template strings have no IDE autocomplete, no typo detection, and no way to trace references through code.
Design
Core concept
Every component that holds a value gets an auto-generated unique key (e.g. input-a7f3). The developer never picks a name for binding purposes — they hold a Python reference to the component and access .ref to get a Ref object that serializes to the correct {{ }} expression.
slider = Slider(min=0, max=100)
slider.ref # Ref object for this slider's value
str(slider.ref) # "{{ slider-a7f3 }}"Usage rules
Two rules, no exceptions:
-
Bare ref when the entire value is reactive:
Metric(value=slider.ref) Metric(value=slider.ref.currency()) DataTable(rows=filtered) # filtered is a Ref from Computed
-
f-string when mixing refs with literal text:
Text(f"Revenue: {(revenue.ref / 1_000_000).round(2)}M") Text(f"Showing {items.length()} {items.length().pluralize('result')}")
Operators → expression AST
Python operators on Ref/ExprNode build expression trees that serialize to the DSL:
slider.ref * 2 # {{ slider-a7f3 * 2 }}
slider.ref * qty.ref # {{ slider-a7f3 * slider-b2e1 }}
price.ref > 100 # {{ price-c3d4 > 100 }}Supported operators: +, -, *, /, ==, !=, >, >=, <, <=
Boolean logic (Python doesn't allow overloading and/or/not):
ref_a.and_(ref_b) # {{ key-a && key-b }}
ref_a.or_(ref_b) # {{ key-a || key-b }}
ref_a.not_() # {{ !key-a }}Ternary:
(status.ref == "active").then("Online", "Offline")
# {{ status-x1y2 == 'active' ? 'Online' : 'Offline' }}Pipes → method chains
Every registered pipe in the expression engine maps to a method on Ref/ExprNode:
revenue.ref.currency() # {{ key | currency }}
revenue.ref.currency("EUR") # {{ key | currency:'EUR' }}
revenue.ref.round(2) # {{ key | round:2 }}
name.ref.upper() # {{ key | upper }}
items.ref.length() # {{ key | length }}
items.ref.first() # {{ key | first }}
items.ref.selectattr("done") # {{ key | selectattr:'done' }}Chains compose:
(revenue.ref * 1000).round(2).currency()
# {{ key * 1000 | round:2 | currency }}Dot-path access
user = Ref("user")
user.name # {{ user.name }}
user.address.city # {{ user.address.city }}
user.created_at.date("long") # {{ user.created_at | date:long }}Uncalled attribute access = dot-path. Called = pipe invocation. Ambiguous cases (like .length) need a clear resolution rule — probably: treat as dot-path on attribute access, use .length() (called) for the pipe.
Auto-generated keys
Components auto-generate unique keys using a prefix + short random suffix:
Slider(...)→ keyslider-a7f3Input(...)→ keyinput-b2e1Select(...)→ keyselect-c3d4
Opt-in human-readable name via name= parameter still works for:
- Form submission (server expects known field names)
- Interop with external state
- Manual
{{ }}templates (ForEach$item, etc.)
When name= is provided, it's used as the key. When omitted, auto-generated.
Computed returns a Ref
filtered = Computed(
tool="filter_sales",
args={"category": category.ref, "min_rev": min_rev.ref},
)
# filtered is a Ref — use it directly
DataTable(rows=filtered)
Metric(value=filtered.length())Runtime variables use explicit Ref()
Loop variables and event values don't exist at Python authoring time:
with ForEach(each=items, item_name="item"):
item = Ref("$item")
Text(f"{item.name}: {item.price.currency()}")Ref("$item"), Ref("$event"), Ref("$index"), Ref("$error") — the escape hatch for renderer-injected variables.
Implementation scope
Python side
Refclass:__str__,__format__,__getattr__, arithmetic/comparison operatorsExprNodeclass: intermediate expression tree with pipe methods, serializes to DSL stringComponent.refproperty: returnsRefbound to the component's key- Auto-key generation in
Component.__init__(whennameis not provided) - Pydantic
BeforeValidatoronstrfields to acceptRef/ExprNode(callsstr())
Renderer side
- No changes to expression evaluation — it already handles
{{ }}with dot-paths, operators, and pipes - Component state read/write uses whatever key is in the JSON (auto-generated or explicit)
JSON wire format
- No changes — the JSON carries the resolved key strings as it does today
Full example
from prefab_ui.app import PrefabApp
from prefab_ui.components import *
from prefab_ui.components.charts import BarChart, ChartSeries
from prefab_ui.actions import Computed
with Column(gap=6) as view:
H2("Sales Dashboard")
with Row(gap=4):
category_select = Select(placeholder="All Categories")
with category_select:
for cat in ["Electronics", "Clothing", "Food"]:
SelectOption(cat, value=cat)
min_rev = Slider(min=0, max=10000, value=0).ref
filtered = Computed(
tool="filter_sales",
args={"category": category_select.ref, "min_rev": min_rev},
)
with Row(gap=4):
Metric(label="Results", value=filtered.length())
Metric(label="Revenue", value=filtered.sum("revenue").currency())
BarChart(
data=filtered,
x_axis_key="product",
series=[ChartSeries(data_key="revenue", name="Revenue")],
)
DataTable(
columns=[
DataTableColumn(key="product", header="Product", sortable=True),
DataTableColumn(key="revenue", header="Revenue", sortable=True),
],
rows=filtered,
searchable=True,
)
app = PrefabApp(view=view)Open questions
- Dot-path vs pipe ambiguity:
ref.length(dot-path to a.lengthproperty) vsref.length()(the| lengthpipe). Proposed: uncalled = dot-path, called = pipe. Need to ensure this is unambiguous for all existing pipes. - Should
Ref.__format__emit{{ }}wrapping, or just the expression? (With wrapping: f-strings work naturally. Without: more explicit but requires manual wrapping.) - Key generation: random suffix? Incrementing counter? Content hash? Need uniqueness within an app, stability across re-renders.