frontage 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- frontage-0.0.1/.gitignore +18 -0
- frontage-0.0.1/DESIGN.md +460 -0
- frontage-0.0.1/LICENSE +202 -0
- frontage-0.0.1/NOTICE +10 -0
- frontage-0.0.1/PKG-INFO +110 -0
- frontage-0.0.1/README.md +82 -0
- frontage-0.0.1/SPEC.md +108 -0
- frontage-0.0.1/examples/hello/hello.py +9 -0
- frontage-0.0.1/examples/hello/index.html +22 -0
- frontage-0.0.1/examples/pyscript.json +10 -0
- frontage-0.0.1/frontage/__init__.py +29 -0
- frontage-0.0.1/frontage/errors.py +18 -0
- frontage-0.0.1/frontage/renderer.py +197 -0
- frontage-0.0.1/frontage/runtime.py +80 -0
- frontage-0.0.1/frontage/version.py +3 -0
- frontage-0.0.1/frontage/view.py +120 -0
- frontage-0.0.1/pyproject.toml +89 -0
- frontage-0.0.1/tests/__init__.py +0 -0
- frontage-0.0.1/tests/browser/test_smoke.py +60 -0
- frontage-0.0.1/tests/test_renderer.py +71 -0
- frontage-0.0.1/tests/test_view.py +60 -0
- frontage-0.0.1/tools/fetch_pyscript.py +40 -0
- frontage-0.0.1/tools/serve.py +58 -0
- frontage-0.0.1/typings/README.md +7 -0
frontage-0.0.1/DESIGN.md
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# Frontage design
|
|
2
|
+
|
|
3
|
+
**Status: draft 4, 2026-09-05.** Draft 1 kept PuePy's shape. Draft 2 was written from Leptos.
|
|
4
|
+
Draft 3 added [Solid](https://github.com/solidjs/solid) (1.x, `dom-expressions`, the store,
|
|
5
|
+
`solid-router`, the 2.0 release candidate), the JavaScript origin of the model and the size
|
|
6
|
+
reference. This draft adds the three Python-first frameworks, [Streamlit](https://github.com/streamlit/streamlit),
|
|
7
|
+
[Shiny for Python](https://github.com/posit-dev/py-shiny) and [Reflex](https://github.com/reflex-dev/reflex),
|
|
8
|
+
with one question asked of each: what happens when it has to run in WebAssembly. Two of them
|
|
9
|
+
already do (stlite, Shinylive), and that evidence shapes section 2b. Decisions marked *open*
|
|
10
|
+
are for David.
|
|
11
|
+
|
|
12
|
+
## 1. The decision
|
|
13
|
+
|
|
14
|
+
Frontage is a **clean-room implementation**, Optersoft's own code, of a fine-grained
|
|
15
|
+
reactive web framework for Python in the browser. The PuePy fork on `main` moves to the
|
|
16
|
+
branch `puepy-reference` and serves as an acceptance test until the rewrite passes its
|
|
17
|
+
examples. Reasons (ownership of copyright, license and story) are in draft 1 and hold.
|
|
18
|
+
|
|
19
|
+
What carries over untouched: the name, the PyPI plan, `pyproject.toml`, the uv/ruff/ty
|
|
20
|
+
toolchain, `ci.yml` with the OIDC publisher, the Cloudflare Pages site, `Makefile.py`.
|
|
21
|
+
|
|
22
|
+
## 2. What the three references teach
|
|
23
|
+
|
|
24
|
+
| | PuePy | Leptos | Solid 1.x | Solid 2.0 RC |
|
|
25
|
+
|---|---|---|---|---|
|
|
26
|
+
| Update model | rebuild page, morph DOM | fine-grained signals | fine-grained signals | fine-grained, async in the graph |
|
|
27
|
+
| Size | 2.5k lines Python | ~80k lines Rust | ~2.4k reactive + ~0.7k DOM + ~1.1k store | 16k lines core alone |
|
|
28
|
+
| Lists | morph by id | keyed diff | keyed diff, DOM moves | same, one `For` with keying modes |
|
|
29
|
+
| Async | none | Resource, Suspense, Action | Resource, Suspense, transitions | async memos, Loading/Errored, actions, optimistic |
|
|
30
|
+
| Templates | Python builder | `view!` macro to typed tree | JSX compiled to `<template>` clones | same |
|
|
31
|
+
| Events | per-element listeners | delegation behind a flag | delegation by default | same |
|
|
32
|
+
|
|
33
|
+
**Solid 1.x is the size proof.** A complete fine-grained framework, with stores, routing
|
|
34
|
+
primitives and server rendering, is about five thousand lines of JavaScript. Frontage's core
|
|
35
|
+
budget of ~4,000 Python lines is realistic because Solid did it.
|
|
36
|
+
|
|
37
|
+
**Solid 2.0 is the direction, not the target.** Its core is seven times larger than 1.x to
|
|
38
|
+
put async, transitions and optimistic writes inside the graph. Frontage takes its *names and
|
|
39
|
+
shapes* (two-phase effects, `Loading` / `Errored`, one `For` with keying modes, draft-based
|
|
40
|
+
store writes, a `Renderer` interface) and leaves the machinery for a later major version.
|
|
41
|
+
|
|
42
|
+
**What transfers from all three** is in the sections below. What does not: Rust's typed
|
|
43
|
+
view tree and arena handles, JSX and the Babel compiler (Python 3.14 template strings do
|
|
44
|
+
that job at runtime), Solid's proxy-based store internals (Python has no `Proxy`; it has
|
|
45
|
+
dunder methods, which are enough), transitions and time slicing.
|
|
46
|
+
|
|
47
|
+
## 2b. The Python-first frameworks, and the WebAssembly test
|
|
48
|
+
|
|
49
|
+
| | Streamlit | Shiny for Python | Reflex |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| Model | the script re-runs top to bottom on every interaction; widgets return values | reactive `Value` / `calc` / `effect` graph on the server; UI functions return HTML; outputs bound by id | `State` classes with typed vars and handler methods on the server; the view compiles to React |
|
|
52
|
+
| Where Python runs | server (Tornado) | server (Starlette, asyncio) | server (FastAPI + websocket); the browser runs generated JavaScript |
|
|
53
|
+
| Size | runtime 23k + elements 46k lines | reactive 2.7k, render 6k, ui 17k, express 4k | 27k lines, plus Node and a React toolchain |
|
|
54
|
+
| Nesting syntax | `with st.sidebar:` | Express: `with ui.card():` | function calls |
|
|
55
|
+
| Partial updates | `@st.fragment` re-runs a subtree | per-output invalidation | state deltas over the websocket |
|
|
56
|
+
| In the browser | **stlite**: the whole runtime in Pyodide in a Web Worker, packages on demand from a 200 MB+ distribution | **Shinylive**: Pyodide plus a service worker, ~13 MB before app code, static export, code-in-URL sharing | none, and none possible: the architecture is Python-on-the-server by construction |
|
|
57
|
+
|
|
58
|
+
**What the WebAssembly test says.** Streamlit and Shiny run in the browser only by carrying
|
|
59
|
+
their *server* into it: a session, a message protocol, an emulated transport, and the full
|
|
60
|
+
Pyodide. They work, they are used, and they start in tens of seconds. Reflex cannot be
|
|
61
|
+
ported at all, and everything un-Pythonic in Reflex (`rx.cond` and `rx.foreach` instead of
|
|
62
|
+
`if` and `for`, `Var` objects that are expressions rather than values, `.to(dict)` casts) is
|
|
63
|
+
the price of Python *not* being present in the browser at runtime. That is the clearest
|
|
64
|
+
argument for Frontage's premise there is: a browser-first framework has no session, no
|
|
65
|
+
transport and no DSL, because the Python is right there.
|
|
66
|
+
|
|
67
|
+
**What transfers, and it is a lot:**
|
|
68
|
+
|
|
69
|
+
1. **`with` blocks for nesting.** Streamlit, Shiny Express and PuePy converged on the same
|
|
70
|
+
syntax independently. The builder gets it (`with h.div(cls="card"): h.p(...)`), and it is
|
|
71
|
+
the natural way to generate UI in a Python loop. The template string stays the primary
|
|
72
|
+
syntax for static structure.
|
|
73
|
+
2. **Decorators are the Pythonic spelling of reactivity.** Shiny's `@reactive.calc` /
|
|
74
|
+
`@reactive.effect` / `@render.text` on named functions read better than lambdas. Frontage's
|
|
75
|
+
`Memo`, `Effect` and `RenderEffect` accept a function, so `@Memo` and `@Effect` work as
|
|
76
|
+
decorators with no extra API, and a decorated `Memo` is an accessor a template hole can
|
|
77
|
+
name. The tutorial teaches this form.
|
|
78
|
+
3. **"Not ready" as control flow.** Shiny's `req()` raises a silent exception that cancels
|
|
79
|
+
the computation quietly; Solid 2.0's `NotReadyError` is the same idea from the other side.
|
|
80
|
+
Frontage: `raise NotReady` (or `require(x)`) inside a compute ends it without error and
|
|
81
|
+
registers with the nearest `Loading` boundary. This is how a hole reads a `Resource`
|
|
82
|
+
without a `None` check.
|
|
83
|
+
4. **Timers as signals.** Shiny's `invalidate_later` and `reactive.poll`, Streamlit's
|
|
84
|
+
`run_every`. Frontage: `interval(seconds)` returns an accessor that ticks; `poll(fn,
|
|
85
|
+
seconds)` a `Resource` on a timer. Small, and every dashboard wants them.
|
|
86
|
+
5. **A widget catalogue.** Both data frameworks ship a fixed set of inputs (text, number,
|
|
87
|
+
slider, select, checkbox, radio, date, file, button) as the beginner's vocabulary. Frontage
|
|
88
|
+
0.2 ships `frontage.widgets`: plain HTML form controls bound to a signal (`text_input(sig,
|
|
89
|
+
label=…)`), unstyled beyond a class hook, the thing the first tutorial chapter can use
|
|
90
|
+
before templates are taught.
|
|
91
|
+
6. **Class-based state as sugar.** Reflex's `State` with typed fields and handler methods is
|
|
92
|
+
a shape many Python developers reach for. Frontage 0.2 offers `State` as sugar over
|
|
93
|
+
signals: `count = field(0)` descriptors (explicit, because MicroPython does not populate
|
|
94
|
+
`__annotations__`), methods as handlers, `@computed` as memos. Optional; the primitives
|
|
95
|
+
stay the foundation.
|
|
96
|
+
7. **Static export and a playground.** Shinylive's `export` command and code-in-URL sharing
|
|
97
|
+
are how its docs and courses work. Frontage: `mk export` (or `frontage export`) writes a
|
|
98
|
+
directory of app, wheel and `pyscript.json`; the site gets a playground page that runs the
|
|
99
|
+
code in the URL fragment. For a company that teaches, the playground is the classroom.
|
|
100
|
+
8. **Fragments are a confession.** Streamlit added `@st.fragment` because re-running the
|
|
101
|
+
whole script does not scale; Solid's `For` and Frontage's holes are the fine-grained
|
|
102
|
+
answer to the same problem, taken from the start.
|
|
103
|
+
|
|
104
|
+
**What does not transfer.** The script-rerun model itself (its simplicity is real, and
|
|
105
|
+
`with` nesting plus module-level `mount()` give a first chapter that reads like a script
|
|
106
|
+
without paying its cost); server sessions and message protocols; Reflex's React component
|
|
107
|
+
wrapping (Frontage's component library is web components used as HTML, section 8.2, with
|
|
108
|
+
Shoelace as the documented example); anything that needs a thread or `time.sleep`, which
|
|
109
|
+
stlite lists as broken in the browser and Frontage's async model never wanted.
|
|
110
|
+
|
|
111
|
+
**Two consequences for the site.** Shinylive and stlite both note that several apps on one
|
|
112
|
+
page means several interpreters; the docs embed a dozen examples per chapter. The examples
|
|
113
|
+
site should load one interpreter per page and mount examples into it, or lazy-load each on
|
|
114
|
+
scroll. And the size story is Frontage's to tell: Shinylive is ~13 MB before the app; a
|
|
115
|
+
MicroPython Frontage app is under a megabyte. That number belongs on the landing page once
|
|
116
|
+
it is measured.
|
|
117
|
+
|
|
118
|
+
## 3. Goals
|
|
119
|
+
|
|
120
|
+
1. **Python only, in the browser.** Reactive UI, no JavaScript toolchain, deploy by serving files.
|
|
121
|
+
2. **Fine-grained.** A state change touches the DOM nodes that read it and nothing else.
|
|
122
|
+
3. **Few bridge calls.** Every DOM operation from Python crosses the Python-to-JavaScript
|
|
123
|
+
bridge, which is the dominant cost in PyScript. The design counts crossings the way a
|
|
124
|
+
database design counts round trips (section 8).
|
|
125
|
+
4. **Two runtimes.** Pyodide and MicroPython, one codebase, both in CI.
|
|
126
|
+
5. **Small and teachable.** Core under ~4,000 lines; a reader can hold it in a day.
|
|
127
|
+
6. **Renderer-agnostic views**: DOM in the browser, HTML string on CPython; server rendering
|
|
128
|
+
stays possible without a rewrite (section 8).
|
|
129
|
+
8. **Python is the control flow.** `if`, `for`, comprehensions and function calls work
|
|
130
|
+
inside a view because the Python is in the browser. `Show` and `For` exist for
|
|
131
|
+
fine-grained efficiency, never as a required DSL. No `cond`, no `foreach`, no `Var`.
|
|
132
|
+
7. **Honest testing**: the reactive core and the view layer test on CPython with no browser;
|
|
133
|
+
the examples run in real browsers under both runtimes.
|
|
134
|
+
|
|
135
|
+
## 4. Non-goals for 0.x
|
|
136
|
+
|
|
137
|
+
Server rendering and hydration as shipped features; transitions and optimistic updates; a
|
|
138
|
+
component library; PuePy import compatibility; PyScript releases older than the pinned one.
|
|
139
|
+
|
|
140
|
+
## 5. Clean-room rules
|
|
141
|
+
|
|
142
|
+
- **Consult:** the documentation and examples of PuePy, Leptos and Solid; the reactive-graph
|
|
143
|
+
algorithms as described in their books and READMEs (Reactively's article, Solid's
|
|
144
|
+
"fine-grained reactivity" guide); PuePy's browser tests as an acceptance specification.
|
|
145
|
+
- **Do not copy:** source, tests, docstrings or prose from any of them. Code is written from
|
|
146
|
+
`SPEC.md` (section 13) with no reference repository open.
|
|
147
|
+
- **Old tree:** branch `puepy-reference`, never merged, deleted after 0.1.0.
|
|
148
|
+
- **Credit** in README: "Frontage's reactive model follows Solid and Leptos; the project began
|
|
149
|
+
as a fork of PuePy." True, courteous, not required.
|
|
150
|
+
- **Every PR states** it was written from the spec without reference source open.
|
|
151
|
+
|
|
152
|
+
## 6. Platform
|
|
153
|
+
|
|
154
|
+
| | |
|
|
155
|
+
|---|---|
|
|
156
|
+
| PyScript | ≥ 2026.7.3; the examples pin one exact release, served locally in CI |
|
|
157
|
+
| Pyodide | 3.14; Python 3.14 semantics incl. template strings (PEP 750) |
|
|
158
|
+
| MicroPython | the build PyScript ships (template strings, `weakref`, `asyncio.Future` since 2026.3.1) |
|
|
159
|
+
| Browsers | evergreen Chromium, Firefox, WebKit |
|
|
160
|
+
| Server | CPython ≥ 3.12 for tests, tooling, the string renderer |
|
|
161
|
+
|
|
162
|
+
The package is written in the MicroPython subset (string annotations, no runtime `typing`,
|
|
163
|
+
no dataclasses). The bridge is `pyscript.document` / `window` / `ffi` / `js_modules` /
|
|
164
|
+
`asyncio`, present on both interpreters; `pyscript.web` is not used. *Open:* MicroPython
|
|
165
|
+
first-class or best-effort. Proposal: first-class.
|
|
166
|
+
|
|
167
|
+
## 7. The reactive core (`frontage.reactive`)
|
|
168
|
+
|
|
169
|
+
The model is Solid's, which Leptos confirmed: signals (sources), memos (derived, cached,
|
|
170
|
+
lazy), effects (subscribers that touch the outside world), all with automatic, dynamic
|
|
171
|
+
dependency tracking under an owner tree.
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from frontage import Signal, Memo, Effect, batch, untrack
|
|
175
|
+
|
|
176
|
+
count = Signal(0)
|
|
177
|
+
double = Memo(lambda: count() * 2) # accessors are callables
|
|
178
|
+
Effect(lambda: double(), lambda v, prev: print("double is", v))
|
|
179
|
+
count.set(2)
|
|
180
|
+
count.update(lambda n: n + 1)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**Accessors are callables.** A `Signal`, a `Memo`, a `Resource` and a plain `lambda` are
|
|
184
|
+
all read by calling them. This is Solid's rule and it is what makes the view layer simple:
|
|
185
|
+
*any callable in a child or attribute position is reactive*; anything else is static.
|
|
186
|
+
|
|
187
|
+
**Propagation** is Solid 1.x's, chosen over Solid 2.0's microtask batching because reads
|
|
188
|
+
after a write return the new value, which is what a beginner expects and what tests can
|
|
189
|
+
assert without an event loop. Three node states, Clean / Check / Dirty. A write marks
|
|
190
|
+
dependents; pure nodes (memos) recompute in topological order within the same batch;
|
|
191
|
+
effects run once at the end of the outermost `batch()`, every write being an implicit
|
|
192
|
+
batch. Memos compare with `==` by default and take `equal=`. `untrack()` reads without
|
|
193
|
+
subscribing. `on(deps, fn)` for explicit dependencies.
|
|
194
|
+
|
|
195
|
+
**Two-phase effects** (Solid 2.0). `Effect(compute, effect)`: `compute` runs tracked and
|
|
196
|
+
returns a value; `effect(value, prev)` runs untracked after the batch and may return a
|
|
197
|
+
cleanup. This makes the two classic mistakes impossible by construction: side effects
|
|
198
|
+
subscribing to signals they happened to read, and writes inside tracked code. A one-argument
|
|
199
|
+
`Effect(fn)` shorthand tracks `fn` and is documented as the rough tool. `RenderEffect` is the
|
|
200
|
+
same with the effect phase run before user effects; the view layer uses it.
|
|
201
|
+
|
|
202
|
+
**Ownership.** `Owner` tree; every effect and memo is created under the current owner; a
|
|
203
|
+
component body and every `For` row run under their own owner (Solid creates a root per row,
|
|
204
|
+
which is what lets one row dispose without touching the others). `owner.dispose()` runs
|
|
205
|
+
`on_cleanup` callbacks, cancels effects, disposes children, **destroys every JavaScript
|
|
206
|
+
proxy registered under it**, and releases delegated-event registrations. `Context`:
|
|
207
|
+
`provide(ctx, value)` / `use(ctx)` walk the owner tree.
|
|
208
|
+
|
|
209
|
+
**Utilities carried from Solid:** `selector(source)` for O(1) "is this row selected"
|
|
210
|
+
checks in a list; `on_mount(fn)`; `get_owner()` / `run_with_owner()`; `children(fn)` to
|
|
211
|
+
resolve a child accessor once. **From Shiny:** `NotReady` (section 2b), `interval(seconds)`
|
|
212
|
+
and `poll(fn, seconds)`. **Decorator form** for `Memo`, `Effect` and `RenderEffect`, which
|
|
213
|
+
falls out of them taking a function and is the form the tutorial teaches.
|
|
214
|
+
|
|
215
|
+
**Stores (`frontage.store`), in 0.1.** Python application state is dicts and lists, so
|
|
216
|
+
nested reactivity is not an optimisation, it is the default case. Solid's design transfers
|
|
217
|
+
without `Proxy`: a `Store` wraps a dict or list in an object whose `__getitem__` /
|
|
218
|
+
`__getattr__` / `__iter__` / `__len__` lazily create one signal node per key on a *tracked*
|
|
219
|
+
read (so untracked keys cost nothing), and re-wrap nested containers on the way out. Writes
|
|
220
|
+
go through a draft: `store.set(lambda s: s["todos"].append(...))`, applied inside a batch
|
|
221
|
+
with the wrapper's `__setitem__` notifying exactly the keys touched (Solid 2.0 made this the
|
|
222
|
+
only write form; 1.x's path syntax `set("user", "name", v)` is offered as `store.set_path`).
|
|
223
|
+
`reconcile(data, key="id")` merges fresh server data into an existing store preserving row
|
|
224
|
+
identity, so a `For` over it moves rows instead of recreating them. `snapshot(store)`
|
|
225
|
+
returns plain data.
|
|
226
|
+
|
|
227
|
+
The whole module is testable on CPython with no browser, and that suite is where the
|
|
228
|
+
framework's correctness lives.
|
|
229
|
+
|
|
230
|
+
## 8. Views (`frontage.view`)
|
|
231
|
+
|
|
232
|
+
### 8.1 Templates first, because of the bridge
|
|
233
|
+
|
|
234
|
+
Solid compiles JSX to a static HTML string per template, creates a `<template>` element
|
|
235
|
+
once, and instantiates it with a single `cloneNode(true)`; only the dynamic holes are then
|
|
236
|
+
touched. Leptos does the same. For PyScript this is not an optimisation to add later; it is
|
|
237
|
+
the difference between one bridge crossing per instance and one per element and attribute.
|
|
238
|
+
|
|
239
|
+
So Frontage's unit of rendering is the **`Template`**: a static HTML skeleton plus a list of
|
|
240
|
+
holes (a text position, an attribute, an event, a child slot), each with a path to its
|
|
241
|
+
node. Instantiation is: clone the skeleton in one call, locate the hole nodes, bind each
|
|
242
|
+
hole with a `RenderEffect` (or a static value). Both front ends below compile to it.
|
|
243
|
+
|
|
244
|
+
**The template string** (Python 3.14, both interpreters), parsed once per call site and
|
|
245
|
+
cached, is the primary syntax and what the tutorial teaches:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
def counter(initial=0):
|
|
249
|
+
count = Signal(initial)
|
|
250
|
+
return html(t"""
|
|
251
|
+
<div class="counter">
|
|
252
|
+
<button on:click={lambda e: count.update(lambda n: n - 1)}>-</button>
|
|
253
|
+
<span>Value: {count}!</span>
|
|
254
|
+
<button on:click={lambda e: count.update(lambda n: n + 1)}>+</button>
|
|
255
|
+
</div>
|
|
256
|
+
""")
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
**The builder** is the fallback and the escape hatch, and it produces the same `Template`
|
|
260
|
+
when its structure is static. It has a call form and a `with` form; the second is what
|
|
261
|
+
Streamlit, Shiny Express and PuePy all arrived at, and the natural one inside a loop:
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
h.div(h.button("-", on_click=dec), h.span("Value: ", count, "!"), h.button("+", on_click=inc), cls="counter")
|
|
265
|
+
|
|
266
|
+
with h.ul(cls="menu"):
|
|
267
|
+
for item in items:
|
|
268
|
+
h.li(item.label, on_click=lambda e, i=item: select(i))
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
*Open:* if template strings prove immature on either interpreter, the builder ships alone
|
|
272
|
+
in 0.1 and the template follows. Proposal: builder in M1, template in M2, tutorial on the
|
|
273
|
+
template.
|
|
274
|
+
|
|
275
|
+
### 8.2 The insert rules
|
|
276
|
+
|
|
277
|
+
A child slot follows Solid's `insert` semantics exactly, because they are complete and
|
|
278
|
+
small: `str`/`int`/`float` → a text node updated in place; `None`/`bool` → nothing; a
|
|
279
|
+
callable → a `RenderEffect` around the same rules; a list → flattened, callables resolved,
|
|
280
|
+
then reconciled against the current nodes by identity (prefix, suffix, swap, then a map;
|
|
281
|
+
existing nodes are moved, not recreated); a view → mounted. A component's output is a view
|
|
282
|
+
or a list of nodes, never a wrapper element.
|
|
283
|
+
|
|
284
|
+
Attributes: a keyword or template attribute whose value is a callable is bound. Prefixes
|
|
285
|
+
name the DOM's real distinctions: `attr:` (default), `prop:` (a DOM property, the one form
|
|
286
|
+
inputs need for `value`), `class:name={bool}` and `class={dict}`, `style:prop`, `on:event`,
|
|
287
|
+
`bind:value` / `bind:checked` / `bind:group` (two-way), `ref={NodeRef()}`. Boolean
|
|
288
|
+
attributes are set and removed, never written as `"false"`.
|
|
289
|
+
|
|
290
|
+
### 8.3 Components and control flow
|
|
291
|
+
|
|
292
|
+
A component is a function of keyword props that runs once under its own owner and returns a
|
|
293
|
+
view. Reactive props are accessors; static props are values; `children` is a callable. No
|
|
294
|
+
base class. Control flow is components:
|
|
295
|
+
|
|
296
|
+
| | |
|
|
297
|
+
|---|---|
|
|
298
|
+
| `Show(when, fallback, children)` | one branch mounted; `children` may be a function of the narrowed value (`keyed=True` re-creates on value change, Solid's rule) |
|
|
299
|
+
| `For(each, children, key=…)` | keyed by identity by default; `key=fn` for an extracted key; `key=False` for index mode where the item is an accessor and rows are reused positionally (Solid 2.0 folded `Index` into `For` this way) |
|
|
300
|
+
| `Switch` / `Match` | first matching branch |
|
|
301
|
+
| `Loading(fallback, children)` | Solid 2.0's name for Suspense: fallback while any `Resource` read beneath is loading; nested boundaries |
|
|
302
|
+
| `Errored(fallback, children)` | Solid 2.0's name for ErrorBoundary: `fallback(error, reset)` |
|
|
303
|
+
| `Dynamic(component, **props)` | component chosen at runtime |
|
|
304
|
+
| `Portal(mount, children)` | render elsewhere in the DOM |
|
|
305
|
+
|
|
306
|
+
### 8.4 The renderer seam
|
|
307
|
+
|
|
308
|
+
The view layer talks to a `Renderer` with Solid's universal-renderer surface, ten methods:
|
|
309
|
+
`create_element`, `create_text`, `replace_text`, `set_property`, `insert_node`,
|
|
310
|
+
`remove_node`, `is_text`, `parent`, `first_child`, `next_sibling`, plus `clone_template`
|
|
311
|
+
and the event hooks. Solid's `universal` package is the proof that a whole framework can
|
|
312
|
+
sit on this seam. Two implementations: `DomRenderer` in the browser and `HtmlRenderer` on
|
|
313
|
+
CPython, which is how views are unit-tested without a browser and the seam server rendering
|
|
314
|
+
would plug into later. Hydration markers are designed now and shipped never, in 0.x.
|
|
315
|
+
|
|
316
|
+
### 8.5 Events
|
|
317
|
+
|
|
318
|
+
Delegated, as in Solid: one listener per event type on the document for the bubbling
|
|
319
|
+
events (click, input, keydown, pointer and touch events, focusin/out, …); the handler walks
|
|
320
|
+
from the target up to the mount root and dispatches to the Python handler registered for
|
|
321
|
+
that element, simulating `currentTarget`, honouring `disabled` and `stopPropagation`.
|
|
322
|
+
Non-bubbling events attach directly. `on:` on the template forces a direct listener;
|
|
323
|
+
`oncapture:` for the capture phase. Handlers may be `async def`. In PyScript this turns one
|
|
324
|
+
`create_proxy` per handler into one per event type, and a disposed owner drops its
|
|
325
|
+
registrations from a dict.
|
|
326
|
+
|
|
327
|
+
### 8.6 A small JavaScript shim, measured
|
|
328
|
+
|
|
329
|
+
*Open, and the one place this design proposes shipping JavaScript.* Two hot paths cross the
|
|
330
|
+
bridge many times per operation when written in Python: locating a clone's hole nodes
|
|
331
|
+
(one call per `firstChild` / `nextSibling` step) and the delegated event walk (one call per
|
|
332
|
+
ancestor). A ~100-line `frontage.js`, loaded through `js_modules` like morphdom was, can do
|
|
333
|
+
each in a single call: `holes(root)` returns the hole nodes of a clone, `dispatch(event)`
|
|
334
|
+
walks to the registered ancestor and calls Python once. The user never sees it. Proposal:
|
|
335
|
+
build pure Python first, measure with the js-framework-benchmark rows example on both
|
|
336
|
+
runtimes, and add the shim only where the numbers say so.
|
|
337
|
+
|
|
338
|
+
## 9. Async (`frontage.reactive`, continued)
|
|
339
|
+
|
|
340
|
+
`Resource(fetcher, source=None)`: `fetcher` is an `async def`; it re-runs when `source`
|
|
341
|
+
changes; the resource is an accessor returning the latest value (or `None`) with
|
|
342
|
+
`.loading`, `.error`, `.state` as accessors and `.refetch()` / `.mutate()` for imperative
|
|
343
|
+
control. Reading it under a `Loading` boundary registers with that boundary's counter
|
|
344
|
+
(Solid's increment/decrement design). `Action(fn)`: `.dispatch(input)`, with `.pending`,
|
|
345
|
+
`.value`, `.input` accessors; what a form submits to. Tasks are spawned with
|
|
346
|
+
`asyncio.create_task` on both interpreters and owned, so a disposed owner cancels them.
|
|
347
|
+
|
|
348
|
+
One rule, documented and enforced with a dev-mode warning because Python makes it easy to
|
|
349
|
+
get wrong: **read every reactive input before the first `await`.** After an `await` the
|
|
350
|
+
tracking context is gone. Solid 2.0 states the same rule for its async memos.
|
|
351
|
+
|
|
352
|
+
Solid 2.0's async-in-the-graph (memos returning awaitables, `is_pending`, `latest`,
|
|
353
|
+
`refresh`, generator-based transactional actions, optimistic values) is the model Frontage
|
|
354
|
+
1.0 should grow toward once 0.x has users. It is written down here so 0.x does not paint
|
|
355
|
+
over it: `Resource` is designed to be replaceable by an async `Memo` without changing the
|
|
356
|
+
`Loading` / `Errored` boundaries.
|
|
357
|
+
|
|
358
|
+
## 10. Router (`frontage.router`)
|
|
359
|
+
|
|
360
|
+
Solid-router's shape, which is Leptos's with data loading co-located:
|
|
361
|
+
|
|
362
|
+
```python
|
|
363
|
+
router = Router(
|
|
364
|
+
Route("/", Home),
|
|
365
|
+
Route(
|
|
366
|
+
"/users",
|
|
367
|
+
Users,
|
|
368
|
+
children=[
|
|
369
|
+
Route("/", UserList),
|
|
370
|
+
Route("/:id", User, preload=preload_user),
|
|
371
|
+
],
|
|
372
|
+
),
|
|
373
|
+
Route("/*any", NotFound),
|
|
374
|
+
mode="history", # or "hash" for the no-server tutorial case, or "memory" for tests
|
|
375
|
+
)
|
|
376
|
+
mount("#app", router)
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
- **Nested routes**; a parent route component receives the matched child as `children`.
|
|
380
|
+
- **Params, query, location** as accessors: `use_params()["id"]` is reactive.
|
|
381
|
+
- **`preload`** per route: called when a route is about to render and, eagerly, when a link
|
|
382
|
+
is hovered; returns nothing, warms `query` caches. Solid's render-as-you-fetch.
|
|
383
|
+
- **`query(fn)`**: a keyed async cache with de-duplication and `revalidate()`; a `Resource`
|
|
384
|
+
reading it gets the cached value first.
|
|
385
|
+
- **`action(fn)`** and **`use_submission()`** for mutations bound to forms, with
|
|
386
|
+
`redirect()` raised from inside.
|
|
387
|
+
- **Plain `<a>` works** (document-level interception); `A(href)` adds relative resolution
|
|
388
|
+
and the active class. `Navigate(to)`, `navigate()`, `use_before_leave()`, scroll
|
|
389
|
+
restoration. **Memory mode** is what makes the router unit-testable.
|
|
390
|
+
|
|
391
|
+
## 11. Errors and development mode
|
|
392
|
+
|
|
393
|
+
Exceptions in a component body or effect route to the nearest `Errored`; with none, to the
|
|
394
|
+
mount root, which in debug mode renders the traceback with the component named and in
|
|
395
|
+
production renders a configured fallback and logs. Dev-mode warnings for the three mistakes
|
|
396
|
+
the design cannot prevent: a signal read after `await`, a write inside a tracked compute,
|
|
397
|
+
and a `For` over unstable keys.
|
|
398
|
+
|
|
399
|
+
## 12. The three questions the measurements answer
|
|
400
|
+
|
|
401
|
+
Before M2 the rows example from js-framework-benchmark runs under both runtimes with a
|
|
402
|
+
harness that counts bridge crossings and wall time for create 1,000 rows, update every
|
|
403
|
+
tenth row, swap two rows, remove a row, clear. It decides: whether the JavaScript shim of
|
|
404
|
+
8.6 is needed and where; whether MicroPython is first-class or best-effort; and whether
|
|
405
|
+
the builder without templates is acceptable in 0.1. Numbers, not opinions.
|
|
406
|
+
|
|
407
|
+
## 13. Testing
|
|
408
|
+
|
|
409
|
+
- `SPEC.md` first: behaviours, one line each, grouped by chapter, in our words.
|
|
410
|
+
- **Reactive core**: exhaustive on CPython: the diamond and branching graphs, dynamic
|
|
411
|
+
dependency changes, cleanup order, batch semantics, store node laziness.
|
|
412
|
+
- **Views** through `HtmlRenderer` (structure) and a recording fake renderer (which
|
|
413
|
+
operations a change caused, and how few).
|
|
414
|
+
- **List reconciliation**: property-based against a brute-force reference: random
|
|
415
|
+
sequences of keys, the DOM order equals the target, operation counts bounded.
|
|
416
|
+
- **Browser suite**: the examples under Playwright, `py` and `mpy`, Chromium every run,
|
|
417
|
+
Firefox and WebKit nightly, PyScript served locally, `autouse` server fixture.
|
|
418
|
+
- **The rows benchmark** of section 12, run in CI for regressions.
|
|
419
|
+
|
|
420
|
+
## 14. Documentation and distribution
|
|
421
|
+
|
|
422
|
+
Docs on `academy.optersoft.com/tool/frontage`, one chapter per concept in the order of this
|
|
423
|
+
document, each with its live example on `frontage.optersoft.com/examples/…`. Distribution:
|
|
424
|
+
the wheel on PyPI and mirrored on the site; a `pyscript.json` of a few lines is the whole
|
|
425
|
+
install. Reference generated from docstrings.
|
|
426
|
+
|
|
427
|
+
## 15. License and ownership
|
|
428
|
+
|
|
429
|
+
**Apache License 2.0**, copyright Optersoft, S.L. Section 3 grants patents both ways,
|
|
430
|
+
section 5 makes contributions arrive under the same terms with no CLA, section 6 reserves the
|
|
431
|
+
Frontage trademark. Not the MIT OR Apache pair: a user choosing MIT would take none of those
|
|
432
|
+
obligations. PyScript is Apache 2.0; Solid and Leptos are MIT, which is why their ideas may
|
|
433
|
+
be studied freely and their code is not copied regardless. The fork on `puepy-reference`
|
|
434
|
+
keeps its own notice; the rewrite carries Optersoft's from the first commit.
|
|
435
|
+
|
|
436
|
+
## 16. Milestones
|
|
437
|
+
|
|
438
|
+
| | Deliverable | Done when |
|
|
439
|
+
|---|---|---|
|
|
440
|
+
| M0 | `SPEC.md`; `puepy-reference` branch; skeleton; `Renderer` protocol with `HtmlRenderer` and a recording fake; local PyScript fixture | `mk check` green; hello-world renders to a string |
|
|
441
|
+
| M1 | reactive core (signals, memos, two-phase effects, owner, context, batch, `on`, `selector`); `Store` with draft writes; builder → `Template`; `DomRenderer`; delegated events; insert rules; `Show`, `For` (all keying modes); `bind:`; counter, todo and rows examples | unit suite green; browser suite green for those examples on both runtimes; the rows benchmark runs |
|
|
442
|
+
| M2 | `t"…"` templates; `Resource`, `Action`, `Loading`, `Errored`; `NodeRef`; `Dynamic`, `Portal`; fetch and forms examples; the shim decision of 8.6 | same; section 12 decided |
|
|
443
|
+
| M3 | router (nested, params, `preload`, `query`, `action`, `A`, three modes); contacts example; debug error page | full example suite green on Chromium, both runtimes |
|
|
444
|
+
| M4 | docs on academy; landing page with the measured size; wheel on the site; `mk export` | **0.1.0** on PyPI |
|
|
445
|
+
| M5 | `frontage.widgets`; `State` sugar; `interval` / `poll`; `reconcile`; the playground page; Firefox + WebKit in CI; performance pass | **0.2.0** |
|
|
446
|
+
| later | server rendering through `HtmlRenderer`; hydration; async memos, `is_pending`, transactions and optimistic writes (Solid 2.0's model) | 1.0 |
|
|
447
|
+
|
|
448
|
+
## 17. Open decisions
|
|
449
|
+
|
|
450
|
+
1. MicroPython first-class or best-effort. Proposal: first-class, confirmed by section 12.
|
|
451
|
+
2. Template strings in M2 with the builder as fallback. Proposal: yes.
|
|
452
|
+
3. The JavaScript shim of 8.6. Proposal: pure Python first, shim only where measured.
|
|
453
|
+
4. Solid 1.x synchronous propagation versus Solid 2.0 microtask batching. Proposal: 1.x.
|
|
454
|
+
5. Whether `Store` is in 0.1 (this draft says yes, because Python state is dicts) or 0.2.
|
|
455
|
+
6. Accessor spelling: `count()` (Solid) or `count.get()` (Leptos). Proposal: `count()`, so
|
|
456
|
+
the rule "a callable is reactive" has no exceptions; `.value` as a read-only property
|
|
457
|
+
alias for people who find bare calls odd.
|
|
458
|
+
7. Whether `frontage.widgets` (the Streamlit/Shiny input catalogue) belongs in the core
|
|
459
|
+
package or in a second package. Proposal: a subpackage of the core, so `pip download
|
|
460
|
+
frontage` is still the whole install.
|