Skip to content

Commit bbae73b

Browse files
committed
Add mount docs and tighten browser authoring surface
- add docs for mount(...) browser-owned app surfaces - document Module payload access, metadata updates, batching, and store subscription callback shape - compile self.subscribe(store, fn) into lifecycle-managed browser store subscriptions - refresh browser/runtime stubs and pyi signatures for app, page, mount, shell, env, content, sessions, stores, and urls
1 parent 3fdbf2e commit bbae73b

35 files changed

Lines changed: 657 additions & 399 deletions
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
---
2+
title: Mounts
3+
description: Browser-owned client apps — when to use a mount instead of a page route.
4+
order: 11.8
5+
---
6+
7+
# Mounts
8+
9+
A mount is a browser-owned client app mounted at a server URL. Use it when the browser owns the entire surface from boot — a search overlay, an admin console, a rich editor — rather than a server-rendered page that hydrates.
10+
11+
## Page route vs mount
12+
13+
| | `page(...)` | `mount(...)` |
14+
|---|---|---|
15+
| First paint | Server-rendered HTML | Browser renders from boot |
16+
| SSR | Yes | No |
17+
| Interactive | After hydration | From boot |
18+
| Use for | Most routes | Full client apps |
19+
20+
If you need a fast first paint or SSR for SEO, use a `page(...)` route. Use a mount when the browser owns everything from the start.
21+
22+
## Scaffold
23+
24+
```bash
25+
sprag add mount search
26+
```
27+
28+
This creates:
29+
30+
```
31+
app/mounts/search/
32+
├── mount.py # Manifest
33+
├── web.py # Root Component
34+
├── modules.py # Root Module (optional)
35+
└── server.py # Boot controller (optional)
36+
```
37+
38+
## Manifest
39+
40+
```python
41+
# app/mounts/search/mount.py
42+
from sprag import mount
43+
from .web import SearchApp
44+
from .modules import SearchModule
45+
from .server import SearchBoot
46+
47+
search = mount(
48+
path="/search",
49+
component=SearchApp,
50+
module=SearchModule,
51+
boot=SearchBoot,
52+
metadata={"title": "Search"},
53+
)
54+
```
55+
56+
### Parameters
57+
58+
| Parameter | Required | Description |
59+
|---|---|---|
60+
| `path` | Yes | URL path for this mount |
61+
| `component` | Yes | Root Component class |
62+
| `module` | No | Root Module class for lifecycle logic |
63+
| `boot` | No | Controller providing initial data via `load()` |
64+
| `metadata` | No | Dict of metadata (title, description, etc.) |
65+
| `shell` | No | Override the app-level shell |
66+
| `css` | No | Mount-specific CSS files |
67+
| `js` | No | Extra scripts to include |
68+
| `modules` | No | JS import aliases: `{"alias": "path/to/module.js"}` |
69+
| `providers` | No | Browser provider Modules |
70+
71+
## Boot controller
72+
73+
The `boot` controller's `load()` runs on the server and seeds initial state into the mount, just like a page controller. Use it to supply data the mount needs on first load.
74+
75+
```python
76+
# app/mounts/search/server.py
77+
from sprag import Controller
78+
79+
class SearchBoot(Controller):
80+
route = "/search"
81+
82+
def load(self):
83+
return {"title": "Search the docs"}
84+
```
85+
86+
The return dict becomes the Module's initial `self.state`. If you don't need server data, omit `boot` — the mount still works with an empty initial state.
87+
88+
## Root Component and Module
89+
90+
The root Component renders the mount's DOM. The root Module owns lifecycle: event delegation, server calls, state.
91+
92+
```python
93+
# app/mounts/search/web.py
94+
from sprag import Component, ui
95+
96+
class SearchApp(Component):
97+
def render(self, props=None):
98+
return ui.div(
99+
ui.input(
100+
type="text",
101+
placeholder="Search…",
102+
data_role="search-input",
103+
class_="search-input",
104+
),
105+
ui.div(data_role="search-status", class_="search-status"),
106+
ui.ul(data_role="search-results", class_="search-results"),
107+
class_="search-app",
108+
)
109+
```
110+
111+
```python
112+
# app/mounts/search/modules.py
113+
from sprag import Module, debounce
114+
115+
class SearchModule(Module):
116+
def on_start(self):
117+
self.delegate(self.element, "input", "[data-role='search-input']", self.on_input)
118+
119+
@debounce(0.15)
120+
def on_input(self, event, target):
121+
self.call_action("search", {"query": target.value}).then(self._on_results)
122+
123+
def _on_results(self, result):
124+
self.set_state(result.value or {})
125+
```
126+
127+
## Providers
128+
129+
Pass browser-side provider Modules the same way as with `page(...)`:
130+
131+
```python
132+
search = mount(
133+
path="/search",
134+
component=SearchApp,
135+
module=SearchModule,
136+
providers={"toast": ToastProvider},
137+
)
138+
```
139+
140+
Resolve them inside any Module with `self.provider("toast")`.

docs/app/content/docs/framework/stores.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class CounterService(Service):
7070

7171
## Browser-side usage
7272

73-
In a `Module`, use `self.subscribe()` to bind store changes to your lifecycle:
73+
In a `Module`, use `self.subscribe(store, fn)` to bind store changes to your lifecycle:
7474

7575
```python
7676
from sprag import Module, store

docs/app/content/docs/guides/payload-design.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ order: 35
88

99
Everything returned from `load()` becomes `window.__SPRAG_PAYLOAD__` — a JSON blob inlined into every page's HTML. Keeping it lean directly reduces page weight, time-to-first-byte, and the size of your build output.
1010

11+
In browser `Module` code, read payload fields with dict-style access:
12+
13+
```python
14+
payload = browser.window.__SPRAG_PAYLOAD__ or {}
15+
doc = payload.get("doc")
16+
```
17+
18+
SPRAG compiles `.get("key", default)` to JavaScript property access, and the spelling stays friendly to Python type checkers.
19+
1120
## What gets serialized
1221

1322
SPRAG serializes the return value of `load()` using Python's standard JSON encoder. **Dataclasses, Pydantic models, and ORM objects serialize all their fields** — including ones you never intended to send to the browser.

docs/app/content/docs/ragot/modules.md

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,18 @@ def on_save(self, event, target):
8888
result.then(self._on_saved)
8989
```
9090

91+
## Reading JSON payloads
92+
93+
Browser payloads are plain JSON objects. Use dict-style access in Module code:
94+
95+
```python
96+
payload = browser.window.__SPRAG_PAYLOAD__ or {}
97+
auth = payload.get("auth")
98+
count = payload.get("count", 0)
99+
```
100+
101+
That spelling type-checks cleanly and compiles to JavaScript property access.
102+
91103
## DOM access
92104

93105
- **`self.element`** — the DOM node this Module is attached to (passed from `hydrate()`)
@@ -168,6 +180,46 @@ def on_click(self, event, target):
168180
self.navigate("/other-page")
169181
```
170182

183+
## Page metadata
184+
185+
Update the page title, description, or canonical URL dynamically from the browser:
186+
187+
```python
188+
def on_start(self):
189+
self.set_metadata({"title": "My Page — App"})
190+
191+
def _on_article_loaded(self, result):
192+
article = (result.value or {}).get("article") or {}
193+
self.set_metadata({
194+
"title": article.get("title", ""),
195+
"description": article.get("summary", ""),
196+
})
197+
```
198+
199+
`set_metadata(metadata, options={})` merges the dict onto the active page head. The same keys supported in the static `page(metadata={...})` manifest work here: `title`, `description`, `canonical`, `og:*`. Use it for routes where the document title depends on data fetched after hydration.
200+
201+
## Batching state updates
202+
203+
`batch_state(fn)` calls `fn(state)` with the current mutable state and fires exactly one re-render when the mutator returns. Use it when you need multiple fields updated atomically, reading from the current state:
204+
205+
```python
206+
class CounterModule(Module):
207+
def on_start(self):
208+
self.delegate(self.element, "click", "[data-role='reset']", self.on_reset)
209+
210+
def on_reset(self, event, target):
211+
self.batch_state(self._reset)
212+
213+
def _reset(self, state):
214+
self.set_state({
215+
"count": 0,
216+
"total": state.get("total", 0) + 1,
217+
"last_reset": True,
218+
})
219+
```
220+
221+
Pass a method reference — nested `def` inside a method is not supported in browser codegen.
222+
171223
## Timers
172224

173225
```python
@@ -189,11 +241,11 @@ class MyModule(Module):
189241
def on_start(self):
190242
self.subscribe(counter_store, self._on_store)
191243

192-
def _on_store(self, state, meta, store):
244+
def _on_store(self, state, meta, s):
193245
self.set_state({"count": state["count"]})
194246
```
195247

196-
Auto-cleaned on `on_stop()`.
248+
Auto-cleaned on `on_stop()`. The callback receives `(state, meta, store)` — trailing args can be omitted if unused.
197249

198250
## Page and Mount Providers
199251

docs/app/routes/blog/components.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
from sprag import Component, ui
2-
31
from app.site import blog_card
2+
from sprag import Component, ui
43

54

65
class BlogIndexPage(Component):
@@ -16,6 +15,7 @@ def render(self, props=None):
1615
)
1716
for post in posts
1817
]
18+
1919
return ui.div(
2020
ui.header(
2121
ui.h1("Blog"),

sprag/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Public SPRAG framework surface."""
22

3-
__version__ = "0.1.18"
3+
__version__ = "0.1.19"
44

55
from .runtime import dom
66
from .runtime.app import App

sprag/dev/codegen/expressions.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,28 @@ def _compile_expr(node, env, method_names=None):
204204
ms_js = f"({_compile_expr(sec_arg, env, method_names=method_names)} * 1000)"
205205
js_method = node.func.attr # timeout / interval map identity
206206
return f"this.{js_method}({fn_js}, {ms_js})"
207+
# self.subscribe(store, fn) — lifecycle-managed store subscription.
208+
# The Python API mirrors Specter's store.subscribe(fn, owner=self).
209+
# In the browser this must compile to store.subscribe(fn) +
210+
# this.addCleanup(unsub) so the subscription tears down with the Module.
211+
# Ragot's Module.subscribe(fn, options) is for Module-local state only
212+
# and silently no-ops when its first arg is not a function.
213+
if (
214+
isinstance(node.func, ast.Attribute)
215+
and node.func.attr == "subscribe"
216+
and isinstance(node.func.value, ast.Name)
217+
and node.func.value.id == "self"
218+
and len(node.args) == 2
219+
and isinstance(node.args[0], ast.Name)
220+
and (env.get("__sprag_stores__") or {}).get(node.args[0].id)
221+
):
222+
store_refs = env["__sprag_stores__"]
223+
store_js_name = store_refs[node.args[0].id]
224+
fn_arg = node.args[1]
225+
fn_js = _compile_expr(fn_arg, env, method_names=method_names)
226+
if _is_bound_method_reference(fn_arg, method_names):
227+
fn_js = f"{fn_js}.bind(this)"
228+
return f"this.addCleanup({store_js_name}.subscribe({fn_js}))"
207229
# Store method translation. The Python local name is mapped to the
208230
# JS bridge name via env, and the SPRAG method name is translated
209231
# to its JS counterpart via the STORE_METHOD_JS table (nearly

sprag/dev/templates/labs/app/routes/auth_demo/protected/modules.py.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class ProtectedAuthModule(Module):
3636
self.on_switch_profile,
3737
)
3838
payload = browser.window.__SPRAG_PAYLOAD__ or {}
39-
client_auth = payload.auth or None
39+
client_auth = payload.get("auth") or None
4040
self.set_state(
4141
{
4242
"client_auth": client_auth,

sprag/dev/templates/labs/app/routes/login/modules.py.tmpl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class LoginModule(Module):
2222
def on_start(self):
2323
self.delegate(self.element, "submit", "[data-role='login-form']", self.on_submit)
2424
payload = browser.window.__SPRAG_PAYLOAD__ or {}
25-
client_auth = payload.auth or None
25+
client_auth = payload.get("auth") or None
2626
self.set_state(
2727
{
2828
"client_auth": client_auth,

sprag/runtime/app.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,13 @@ def __init__(self, name: str):
2424

2525
@dataclass
2626
class App:
27-
"""Configured SPRAG application: providers, surfaces, build, and server runtime."""
27+
"""Application container for a SPRAG project.
28+
29+
Create this once in ``app/__init__.py``. Configure providers, route and
30+
mount packages, the app shell, global ESM modules, metadata, sessions,
31+
uploads, and server mode. Use ``build(...)`` for static output and
32+
``serve(...)`` for local/runtime serving.
33+
"""
2834

2935
providers: dict = field(default_factory=dict)
3036
routes: str = "app.routes"

0 commit comments

Comments
 (0)