rip-lang 3.15.4 → 3.16.1
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.
- package/README.md +6 -4
- package/bin/rip +167 -12
- package/docs/AGENTS.md +1 -1
- package/docs/RIP-APP.md +808 -0
- package/docs/RIP-DUCKDB.md +477 -0
- package/docs/RIP-INTRO.md +396 -0
- package/docs/RIP-LANG.md +59 -5
- package/docs/RIP-SCHEMA.md +191 -8
- package/docs/RIP-TYPES.md +74 -103
- package/docs/demo/README.md +4 -3
- package/docs/dist/rip.js +3627 -1470
- package/docs/dist/rip.min.js +671 -244
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/example/index.json +7 -7
- package/docs/example/index.json.br +0 -0
- package/docs/extensions/duckdb/manifest.json +1 -1
- package/docs/extensions/duckdb/v1.5.2/linux_amd64/ripdb.duckdb_extension.gz +0 -0
- package/docs/extensions/duckdb/v1.5.2/osx_arm64/ripdb.duckdb_extension.gz +0 -0
- package/docs/extensions/vscode/print/index.html +2 -1
- package/docs/extensions/vscode/print/print-1.0.13.vsix +0 -0
- package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
- package/docs/extensions/vscode/print/print-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
- package/docs/ui/bundle.json +61 -0
- package/docs/ui/bundle.json.br +0 -0
- package/docs/ui/hljs-rip.js +0 -7
- package/docs/ui/index.css +66 -23
- package/docs/ui/index.html +6 -6
- package/package.json +9 -3
- package/rip-loader.js +64 -2
- package/src/AGENTS.md +63 -36
- package/src/browser.js +96 -14
- package/src/compiler.js +960 -143
- package/src/components.js +794 -88
- package/src/{types-emit.js → dts.js} +181 -71
- package/src/grammar/README.md +1 -1
- package/src/grammar/grammar.rip +111 -97
- package/src/lexer.js +132 -18
- package/src/parser.js +203 -205
- package/src/repl.js +74 -6
- package/src/schema/runtime-orm.js +168 -4
- package/src/schema/runtime-validate.js +146 -2
- package/src/schema/runtime.generated.js +314 -6
- package/src/schema/schema.js +5 -5
- package/src/sourcemaps.js +277 -1
- package/src/stdlib.js +253 -0
- package/src/typecheck.js +2023 -106
- package/src/types.js +127 -7
- package/docs/ui/accordion.rip +0 -103
- package/docs/ui/alert-dialog.rip +0 -53
- package/docs/ui/autocomplete.rip +0 -115
- package/docs/ui/avatar.rip +0 -37
- package/docs/ui/badge.rip +0 -15
- package/docs/ui/breadcrumb.rip +0 -47
- package/docs/ui/button-group.rip +0 -26
- package/docs/ui/button.rip +0 -23
- package/docs/ui/card.rip +0 -25
- package/docs/ui/carousel.rip +0 -110
- package/docs/ui/checkbox-group.rip +0 -61
- package/docs/ui/checkbox.rip +0 -33
- package/docs/ui/collapsible.rip +0 -50
- package/docs/ui/combobox.rip +0 -130
- package/docs/ui/context-menu.rip +0 -88
- package/docs/ui/date-picker.rip +0 -206
- package/docs/ui/dialog.rip +0 -60
- package/docs/ui/drawer.rip +0 -58
- package/docs/ui/editable-value.rip +0 -82
- package/docs/ui/field.rip +0 -53
- package/docs/ui/fieldset.rip +0 -22
- package/docs/ui/form.rip +0 -39
- package/docs/ui/grid.rip +0 -901
- package/docs/ui/input-group.rip +0 -28
- package/docs/ui/input.rip +0 -36
- package/docs/ui/label.rip +0 -16
- package/docs/ui/menu.rip +0 -134
- package/docs/ui/menubar.rip +0 -151
- package/docs/ui/meter.rip +0 -36
- package/docs/ui/multi-select.rip +0 -203
- package/docs/ui/native-select.rip +0 -33
- package/docs/ui/nav-menu.rip +0 -126
- package/docs/ui/number-field.rip +0 -162
- package/docs/ui/otp-field.rip +0 -89
- package/docs/ui/pagination.rip +0 -123
- package/docs/ui/popover.rip +0 -93
- package/docs/ui/preview-card.rip +0 -75
- package/docs/ui/progress.rip +0 -25
- package/docs/ui/radio-group.rip +0 -57
- package/docs/ui/resizable.rip +0 -123
- package/docs/ui/scroll-area.rip +0 -145
- package/docs/ui/select.rip +0 -151
- package/docs/ui/separator.rip +0 -17
- package/docs/ui/skeleton.rip +0 -22
- package/docs/ui/slider.rip +0 -165
- package/docs/ui/spinner.rip +0 -17
- package/docs/ui/table.rip +0 -27
- package/docs/ui/tabs.rip +0 -113
- package/docs/ui/textarea.rip +0 -48
- package/docs/ui/toast.rip +0 -87
- package/docs/ui/toggle-group.rip +0 -71
- package/docs/ui/toggle.rip +0 -24
- package/docs/ui/toolbar.rip +0 -38
- package/docs/ui/tooltip.rip +0 -85
- package/src/app.rip +0 -1571
- package/src/sourcemap-merge.js +0 -287
- /package/docs/demo/{components → routes}/_layout.rip +0 -0
- /package/docs/demo/{components → routes}/about.rip +0 -0
- /package/docs/demo/{components → routes}/card.rip +0 -0
- /package/docs/demo/{components → routes}/counter.rip +0 -0
- /package/docs/demo/{components → routes}/index.rip +0 -0
- /package/docs/demo/{components → routes}/todos.rip +0 -0
- /package/src/schema/{dts-emit.js → dts.js} +0 -0
package/docs/RIP-APP.md
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/assets/rip-schema-social.png" alt="Rip App" width="640">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Rip App — Application Framework
|
|
6
|
+
|
|
7
|
+
> **The browser-side framework that ships with rip-lang. Stash, resource,
|
|
8
|
+
> timing, components store, file-based router, fine-grained renderer,
|
|
9
|
+
> orchestrated launch, and shared ARIA primitives — all in one bundle,
|
|
10
|
+
> no build step required.**
|
|
11
|
+
|
|
12
|
+
Rip App is to the browser what `@rip-lang/server` is to HTTP: a
|
|
13
|
+
batteries-included, opinionated application framework written in Rip
|
|
14
|
+
itself. It uses the language's compiler and reactive primitives the
|
|
15
|
+
same way any user's app would — Rip App doesn't extend the language,
|
|
16
|
+
it just exposes a coherent set of pieces (state, async data, routing,
|
|
17
|
+
rendering, lifecycle) on top of them.
|
|
18
|
+
|
|
19
|
+
If you've loaded `<script src="rip.min.js">` and called `app.launch(...)`,
|
|
20
|
+
you've used Rip App. This doc explains what's inside it, why it's
|
|
21
|
+
shaped the way it is, and how to use it well.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# Contents
|
|
26
|
+
|
|
27
|
+
- [Rip App — Application Framework](#rip-app--application-framework)
|
|
28
|
+
- [Contents](#contents)
|
|
29
|
+
- [1. The four-layer architecture](#1-the-four-layer-architecture)
|
|
30
|
+
- [2. Quick start](#2-quick-start)
|
|
31
|
+
- [Real apps: bundles + file-based routing](#real-apps-bundles--file-based-routing)
|
|
32
|
+
- [3. The subsystems](#3-the-subsystems)
|
|
33
|
+
- [Stash](#stash)
|
|
34
|
+
- [createResource](#createresource)
|
|
35
|
+
- [Timing helpers](#timing-helpers)
|
|
36
|
+
- [Components store](#components-store)
|
|
37
|
+
- [createRouter](#createrouter)
|
|
38
|
+
- [createRenderer](#createrenderer)
|
|
39
|
+
- [launch](#launch)
|
|
40
|
+
- [ARIA helpers](#aria-helpers)
|
|
41
|
+
- [4. Lifecycle invariants](#4-lifecycle-invariants)
|
|
42
|
+
- [Component lifecycle order](#component-lifecycle-order)
|
|
43
|
+
- [User hooks](#user-hooks)
|
|
44
|
+
- [Effect ownership](#effect-ownership)
|
|
45
|
+
- [Effect cleanup-on-rerun](#effect-cleanup-on-rerun)
|
|
46
|
+
- [Parent chain (for context)](#parent-chain-for-context)
|
|
47
|
+
- [Layout and page parentage](#layout-and-page-parentage)
|
|
48
|
+
- [Factory blocks (for/if in render)](#factory-blocks-forif-in-render)
|
|
49
|
+
- [Keyed list reconciliation](#keyed-list-reconciliation)
|
|
50
|
+
- [5. Async effects](#5-async-effects)
|
|
51
|
+
- [6. Gotchas](#6-gotchas)
|
|
52
|
+
- [The bundle boundary matters](#the-bundle-boundary-matters)
|
|
53
|
+
- [Render-template name shadowing (fixed)](#render-template-name-shadowing-fixed)
|
|
54
|
+
- [Nested `for` loops can both name `i` (fixed)](#nested-for-loops-can-both-name-i-fixed)
|
|
55
|
+
- [Snapshot tests are brittle](#snapshot-tests-are-brittle)
|
|
56
|
+
- [No browser e2e tests](#no-browser-e2e-tests)
|
|
57
|
+
- [7. When NOT to use Rip App](#7-when-not-to-use-rip-app)
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## 1. The four-layer architecture
|
|
62
|
+
|
|
63
|
+
Rip's framework code splits into four layers. Knowing which layer a
|
|
64
|
+
piece of code lives in tells you what it can assume about its
|
|
65
|
+
dependencies and what it owes its callers.
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
Layer 0 Language core src/compiler.js, src/lexer.js, src/parser.js,
|
|
69
|
+
(compiler + reactive src/types.js, src/dts.js, src/typecheck.js,
|
|
70
|
+
runtime + types + src/schema/, src/grammar/grammar.rip
|
|
71
|
+
schema runtime)
|
|
72
|
+
|
|
73
|
+
Layer 1 Component runtime src/components.js
|
|
74
|
+
(__Component class, - __pushComponent / __popComponent /
|
|
75
|
+
render-template codegen, __getCurrentComponent (the bridge)
|
|
76
|
+
__reconcile, __lis, - __reconcile (LIS-based keyed list update)
|
|
77
|
+
__transition, context) - render-template → DOM codegen
|
|
78
|
+
|
|
79
|
+
Layer 2 Rip App framework packages/app/index.rip
|
|
80
|
+
(this doc's subject) - Stash, Resource, Timing, Components store,
|
|
81
|
+
Router, Renderer, Launch, ARIA helpers
|
|
82
|
+
|
|
83
|
+
Layer 3 Headless widgets packages/ui/browser/components/*.rip
|
|
84
|
+
(ARIA-driven UI library) - 54+ widgets: Dialog, MultiSelect, DatePicker,
|
|
85
|
+
Combobox, Slider, ScrollArea, etc.
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Layer 2 (Rip App) depends on Layers 0 + 1 via two `globalThis`
|
|
89
|
+
bridges:
|
|
90
|
+
|
|
91
|
+
- `globalThis.__rip` — reactive primitives (`__state`, `__computed`,
|
|
92
|
+
`__effect`, `__batch`, `__getEffectSignal`).
|
|
93
|
+
- `globalThis.__ripComponent` — component machinery
|
|
94
|
+
(`__pushComponent`, `__popComponent`, `__getCurrentComponent`,
|
|
95
|
+
`setContext`, `getContext`, `hasContext`, `__Component`,
|
|
96
|
+
`__reconcile`, `__transition`, `__handleComponentError`, `__clsx`,
|
|
97
|
+
`__lis`).
|
|
98
|
+
|
|
99
|
+
The bridges are populated when the runtime is loaded (either via the
|
|
100
|
+
embedded preamble in standalone-compiled output, or via the framework
|
|
101
|
+
bundle's evaluation). Rip App reads them lazily so module load order
|
|
102
|
+
is irrelevant.
|
|
103
|
+
|
|
104
|
+
Rip App **does not extend the compiler**. Anything that compiles
|
|
105
|
+
without Rip App also compiles with Rip App; Rip App's value is purely
|
|
106
|
+
the user-land API it exposes.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## 2. Quick start
|
|
111
|
+
|
|
112
|
+
The smallest possible Rip App is one HTML file you can drop into any
|
|
113
|
+
static server (or open straight from disk):
|
|
114
|
+
|
|
115
|
+
```html
|
|
116
|
+
<!doctype html>
|
|
117
|
+
<html>
|
|
118
|
+
<body>
|
|
119
|
+
<script defer
|
|
120
|
+
src="https://shreeve.github.io/rip-lang/dist/rip.min.js"
|
|
121
|
+
data-src=""
|
|
122
|
+
data-mount="App"></script>
|
|
123
|
+
<script type="text/rip">
|
|
124
|
+
App = component
|
|
125
|
+
@name := "world"
|
|
126
|
+
render
|
|
127
|
+
h1 "Hello, #{@name}!"
|
|
128
|
+
button @click: (=> @name = "Rip"), "Click me"
|
|
129
|
+
</script>
|
|
130
|
+
</body>
|
|
131
|
+
</html>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Save as `index.html`, run `python3 -m http.server` (or any other
|
|
135
|
+
static server, or just double-click the file), and you're live.
|
|
136
|
+
|
|
137
|
+
What each attribute does:
|
|
138
|
+
|
|
139
|
+
- `defer` — wait for the document to parse so the inline
|
|
140
|
+
`<script type="text/rip">` block is in the DOM when the framework
|
|
141
|
+
runs.
|
|
142
|
+
- `src=...` — the framework bundle (compiler + reactive runtime +
|
|
143
|
+
Rip App). Use the GitHub Pages CDN URL above, or self-host a copy
|
|
144
|
+
of `dist/rip.min.js`.
|
|
145
|
+
- `data-src=""` — **explicitly empty**. Without this, the runtime
|
|
146
|
+
defaults to `GET /app` (the auto-bundle endpoint provided by
|
|
147
|
+
`@rip-lang/server`'s `serve` middleware). When you're not running
|
|
148
|
+
a Rip server, the empty string suppresses that fetch.
|
|
149
|
+
- `data-mount="App"` — name of the top-level component to mount.
|
|
150
|
+
Mounted onto `<body>` by default; pass `data-target="#app"` (or
|
|
151
|
+
any selector) to mount somewhere else.
|
|
152
|
+
|
|
153
|
+
The browser loads `rip.min.js`, the compiled framework + compiler
|
|
154
|
+
evaluate, all inline `<script type="text/rip">` blocks share scope
|
|
155
|
+
and compile in-browser, and the runtime calls `App.mount('body')`
|
|
156
|
+
for you. Clicking the button mutates `@name`; reactivity updates
|
|
157
|
+
only the `<h1>`'s text node.
|
|
158
|
+
|
|
159
|
+
A note on the `@` prefix: `@name := "world"` declares `name` as a
|
|
160
|
+
**prop** — the parent can pass `App name: "Rip"` to override the
|
|
161
|
+
default, and the component's own code reads/writes it via `@name`.
|
|
162
|
+
A bare `name := "world"` (no `@`) declares an internal-only state
|
|
163
|
+
that isn't exposed as a prop. The two are not interchangeable; if
|
|
164
|
+
you write `name := "world"` in the declaration but then `@name = ...`
|
|
165
|
+
in a handler, you'll be writing to a different binding than the one
|
|
166
|
+
your render reads. **Use `@` consistently for anything you want
|
|
167
|
+
both reactive and externally settable.**
|
|
168
|
+
|
|
169
|
+
### Real apps: bundles + file-based routing
|
|
170
|
+
|
|
171
|
+
For multi-page apps you serve a bundle (a JSON map of paths → Rip
|
|
172
|
+
source) and let the runtime route between them. The
|
|
173
|
+
`@rip-lang/server` middleware emits the bundle for you; the browser
|
|
174
|
+
runtime auto-detects and calls `app.launch(...)` internally:
|
|
175
|
+
|
|
176
|
+
```html
|
|
177
|
+
<script defer
|
|
178
|
+
src="/dist/rip.min.js"
|
|
179
|
+
data-src="/bundle.json"
|
|
180
|
+
data-router
|
|
181
|
+
data-persist="local"></script>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The `data-router` attribute switches the runtime from "mount one
|
|
185
|
+
component" mode to "file-based router with renderer" mode. With a
|
|
186
|
+
bundle in hand, the runtime calls `launch({ bundle, hash, persist })`
|
|
187
|
+
and you get the full Rip App stack — stash, router, renderer, hot
|
|
188
|
+
reload — wired up automatically.
|
|
189
|
+
|
|
190
|
+
Direct `app.launch(opts)` is still public for advanced use (custom
|
|
191
|
+
target, custom error handling, multiple launches over the lifetime
|
|
192
|
+
of the page) but you rarely need it.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## 3. The subsystems
|
|
197
|
+
|
|
198
|
+
### Stash
|
|
199
|
+
|
|
200
|
+
A deep reactive proxy with path navigation. Single-app state,
|
|
201
|
+
JSON-persistable, fine-grained signal subscription per key.
|
|
202
|
+
|
|
203
|
+
```rip
|
|
204
|
+
app = createStash
|
|
205
|
+
user:
|
|
206
|
+
name: "Alice"
|
|
207
|
+
prefs:
|
|
208
|
+
theme: "dark"
|
|
209
|
+
todos: []
|
|
210
|
+
|
|
211
|
+
# Path-based access
|
|
212
|
+
app['user/prefs/theme'] # → 'dark'
|
|
213
|
+
app['user/prefs/theme'] = 'light'
|
|
214
|
+
|
|
215
|
+
# Reserved methods (not properties — atomic ops)
|
|
216
|
+
app.inc 'todos/length' # increment counter
|
|
217
|
+
app.flip 'user/prefs/expanded' # toggle boolean
|
|
218
|
+
app.has 'user/email' # → false
|
|
219
|
+
app.del 'user/prefs/theme' # remove
|
|
220
|
+
app.keys 'user' # → ['name', 'prefs']
|
|
221
|
+
app.join 'user', email: "..." # shallow merge
|
|
222
|
+
|
|
223
|
+
# Use raw object underneath
|
|
224
|
+
plain = unwrapStash(app) # back to a plain JS object
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Single-stash policy**: Rip App assumes one stash per page (the one
|
|
228
|
+
`launch()` creates as `app.data`). `persistStash` and the
|
|
229
|
+
beforeunload-flush mechanism both rely on this. Apps needing
|
|
230
|
+
isolated state silos should use plain `__state(...)` signals or
|
|
231
|
+
namespace under different keys on `app.data`.
|
|
232
|
+
|
|
233
|
+
### createResource
|
|
234
|
+
|
|
235
|
+
Async data with race protection, abort support, and reactive loading
|
|
236
|
+
state.
|
|
237
|
+
|
|
238
|
+
```rip
|
|
239
|
+
user = createResource ->
|
|
240
|
+
signal = getEffectSignal() # capture BEFORE await; aborts on dispose
|
|
241
|
+
res = fetch! "/api/users/#{userId}", { signal }
|
|
242
|
+
res.json!
|
|
243
|
+
|
|
244
|
+
# Reactive consumers see loading/data/error transitions
|
|
245
|
+
~> if user.loading
|
|
246
|
+
"Loading..."
|
|
247
|
+
else if user.error
|
|
248
|
+
"Error: #{user.error.message}"
|
|
249
|
+
else
|
|
250
|
+
user.data?.name
|
|
251
|
+
|
|
252
|
+
user.refetch! # rejected promises rethrow; awaiters see them
|
|
253
|
+
user.dispose() # cancels in-flight, clears state
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Race protection: each refetch increments a generation counter. Old
|
|
257
|
+
responses that resolve after a newer fetch is in flight are dropped.
|
|
258
|
+
AbortController signal is passed to `fn` and aborted on
|
|
259
|
+
dispose/refetch.
|
|
260
|
+
|
|
261
|
+
### Timing helpers
|
|
262
|
+
|
|
263
|
+
`delay`, `debounce`, `throttle`, `hold` — all reactive, all return a
|
|
264
|
+
disposable signal-like object.
|
|
265
|
+
|
|
266
|
+
```rip
|
|
267
|
+
showLoading := delay 200 -> loading # truthy waits 200ms, falsy immediate
|
|
268
|
+
debouncedQuery := debounce 300 -> query # propagates 300ms after last change
|
|
269
|
+
smoothScroll := throttle 100 -> scrollY # at most once per 100ms
|
|
270
|
+
showSaved := hold 2000 -> saved # once truthy, stays true ≥ 2000ms
|
|
271
|
+
|
|
272
|
+
# All return objects with .value, .read(), .dispose()
|
|
273
|
+
debouncedQuery.dispose()
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
The `.dispose()` is auto-called when the enclosing component unmounts
|
|
277
|
+
(via the `__getCurrentComponent` bridge). Manual disposal is only
|
|
278
|
+
needed when used outside a component.
|
|
279
|
+
|
|
280
|
+
### Components store
|
|
281
|
+
|
|
282
|
+
In-memory virtual filesystem of `.rip` source files with hot-reload
|
|
283
|
+
watchers. The renderer uses it; you rarely touch it directly.
|
|
284
|
+
|
|
285
|
+
```rip
|
|
286
|
+
store = createComponents()
|
|
287
|
+
store.write 'components/Card.rip', source
|
|
288
|
+
store.read 'components/Card.rip'
|
|
289
|
+
store.list 'components'
|
|
290
|
+
store.watch (event, path) -> ... # 'create' | 'change' | 'delete'
|
|
291
|
+
unwatch = store.watch (...) -> ...
|
|
292
|
+
unwatch() # idempotent disposer
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### createRouter
|
|
296
|
+
|
|
297
|
+
File-system routing with base prefix, query/hash preservation, hash
|
|
298
|
+
mode, error callback, and nav-callback hooks.
|
|
299
|
+
|
|
300
|
+
```rip
|
|
301
|
+
router = createRouter components,
|
|
302
|
+
root: 'components' # root directory in the store
|
|
303
|
+
base: '/admin' # path prefix (e.g. for sub-app deployment)
|
|
304
|
+
hash: false # hash-mode routing
|
|
305
|
+
onError: (err) ->
|
|
306
|
+
console.error "Routing error: #{err.status} #{err.path}"
|
|
307
|
+
|
|
308
|
+
router.push '/users/42?tab=settings'
|
|
309
|
+
router.replace '/login'
|
|
310
|
+
router.push '/cart', noScroll: true # don't reset scroll on this nav
|
|
311
|
+
router.back()
|
|
312
|
+
router.forward()
|
|
313
|
+
|
|
314
|
+
# Reactive properties (each is its own signal)
|
|
315
|
+
router.path # '/users/42'
|
|
316
|
+
router.params # { id: '42' }
|
|
317
|
+
router.route # { file, regex, pattern }
|
|
318
|
+
router.layouts # ['_layout.rip', 'users/_layout.rip']
|
|
319
|
+
router.query # { tab: 'settings' }
|
|
320
|
+
router.hash # ''
|
|
321
|
+
router.navigating # true while resolve() in flight (200ms delay)
|
|
322
|
+
|
|
323
|
+
# router.current is a single __computed (one subscription, not 6)
|
|
324
|
+
~> info = router.current
|
|
325
|
+
mountThePage(info) if info.route
|
|
326
|
+
|
|
327
|
+
# Subscribe to nav events explicitly
|
|
328
|
+
unwatch = router.onNavigate (current) -> log current.path
|
|
329
|
+
unwatch()
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The renderer uses `router.current` to drive its mount effect. Each
|
|
333
|
+
field is also a separate signal so subscribers can track only what
|
|
334
|
+
they care about.
|
|
335
|
+
|
|
336
|
+
#### Anchor opt-outs and active-link styling
|
|
337
|
+
|
|
338
|
+
The router intercepts plain `<a>` clicks at the document level. Two
|
|
339
|
+
per-anchor attributes adjust that behavior:
|
|
340
|
+
|
|
341
|
+
| Attribute | Effect |
|
|
342
|
+
| ----------------------- | ------------------------------------------------------------------------------------- |
|
|
343
|
+
| `data-router-ignore` | Skip SPA interception entirely. The browser performs a full navigation. |
|
|
344
|
+
| `data-router-noscroll` | Take the SPA navigation, but don't reset scroll to `(0, 0)`. |
|
|
345
|
+
|
|
346
|
+
Anchors with `target="_blank"`, `[download]`, cross-origin hrefs, or
|
|
347
|
+
hrefs outside `base` are also skipped automatically.
|
|
348
|
+
|
|
349
|
+
**Active-link highlighting.** On every navigation the router walks
|
|
350
|
+
in-document anchors and sets `aria-current` on those that match the
|
|
351
|
+
current path:
|
|
352
|
+
|
|
353
|
+
- exact match → `aria-current="page"`
|
|
354
|
+
- prefix match (`/blog` on `/blog/123`) → `aria-current="true"`
|
|
355
|
+
- otherwise → attribute removed (only if the router set it)
|
|
356
|
+
|
|
357
|
+
Style it with attribute selectors — no per-link boilerplate needed:
|
|
358
|
+
|
|
359
|
+
```css
|
|
360
|
+
nav a[aria-current="page"] { color: red; font-weight: bold; }
|
|
361
|
+
nav a[aria-current="true"] { color: red; }
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Setting `aria-current` manually on an anchor wins — the router only
|
|
365
|
+
touches values it set itself.
|
|
366
|
+
|
|
367
|
+
**Scroll restoration.** New navigations (`push` or a link click)
|
|
368
|
+
reset scroll to `(0, 0)`. Back/forward (`popstate`) restores the
|
|
369
|
+
scroll position the page had when you left it. Same-document fragment
|
|
370
|
+
links (`#section`) defer to the browser.
|
|
371
|
+
|
|
372
|
+
**Typed routes (compile-time).** In a typed project (one with
|
|
373
|
+
`rip.strict: true` or `::` annotations), `rip check` synthesizes a
|
|
374
|
+
`__RipRoutes` union from the file tree under `app/routes/` and
|
|
375
|
+
threads it through three places:
|
|
376
|
+
|
|
377
|
+
| Place | Type | Catches |
|
|
378
|
+
| ---------------------------------- | --------------------------------------------------------------------- | ---------------------------------- |
|
|
379
|
+
| `<a href: "...">` in render blocks | `__RipRoutes` for `/`-prefixed literals; any string otherwise | Typos in known routes |
|
|
380
|
+
| `router.push url, opts?` | `__RipRoutes` (replaces base `string`) | Typos in programmatic navigation |
|
|
381
|
+
| `@params` in `routes/[id].rip` | `{ id: string }` (replaces `Record<string, string>`) | Typos like `@params.bogus` |
|
|
382
|
+
|
|
383
|
+
Anchor `href` uses a `const`-generic conditional: a literal starting
|
|
384
|
+
with `/` must satisfy `__RipRoutes`, while external schemes
|
|
385
|
+
(`https://`, `mailto:`, `tel:`), fragments (`#anchor`), and dynamic
|
|
386
|
+
`string` values fall through unchecked. Typos like `<a href: "/crat">`
|
|
387
|
+
produce a single-line error naming the valid routes.
|
|
388
|
+
`router.replace` is deliberately left at `string` — it's commonly
|
|
389
|
+
used to mutate the current URL with query strings, where the built
|
|
390
|
+
value can't satisfy a literal-route union. Catch-all routes
|
|
391
|
+
(`[...rest].rip`) are excluded from `__RipRoutes` — they're runtime
|
|
392
|
+
404 fallbacks, not navigation targets, so including them as
|
|
393
|
+
`/${string}` would defeat typo-catching for every other route.
|
|
394
|
+
|
|
395
|
+
### createRenderer
|
|
396
|
+
|
|
397
|
+
The render loop. Subscribes to `router.current`, mounts/unmounts
|
|
398
|
+
page + layout components, manages the lifecycle. You rarely call this
|
|
399
|
+
directly — `launch()` does it for you — but the API is public.
|
|
400
|
+
|
|
401
|
+
```rip
|
|
402
|
+
renderer = createRenderer
|
|
403
|
+
router: router
|
|
404
|
+
app: app
|
|
405
|
+
components: components
|
|
406
|
+
resolver: resolver
|
|
407
|
+
compile: compile # the rip-lang compileToJS function
|
|
408
|
+
target: '#app'
|
|
409
|
+
onError: (err) -> console.error err
|
|
410
|
+
|
|
411
|
+
renderer.start() # idempotent — second call is a no-op
|
|
412
|
+
renderer.remount() # re-mount the current route
|
|
413
|
+
renderer.remount(true) # force a full unmount + remount (hot reload)
|
|
414
|
+
renderer.stop() # tears down lifecycle, revokes blob URLs
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### launch
|
|
418
|
+
|
|
419
|
+
The orchestrator. Single entry point that wires bundle → stash →
|
|
420
|
+
components → resolver → router → renderer → lifecycle.
|
|
421
|
+
|
|
422
|
+
```rip
|
|
423
|
+
result = await app.launch
|
|
424
|
+
bundle: { components: {...}, routes: {...}, data: {...} }
|
|
425
|
+
base: '/admin' # optional URL prefix
|
|
426
|
+
target: '#app' # optional mount target
|
|
427
|
+
persist: 'local' # optional: 'local' | 'session' | true | false
|
|
428
|
+
hash: false # optional: hash-mode routing
|
|
429
|
+
|
|
430
|
+
# Returns
|
|
431
|
+
result.app # the stash proxy
|
|
432
|
+
result.components # the components store
|
|
433
|
+
result.router # the router
|
|
434
|
+
result.renderer # the renderer
|
|
435
|
+
result.destroy() # teardown — symmetric to launch
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**`launch` is single-arg**: just `launch(opts)`. Earlier polymorphic
|
|
439
|
+
forms like `launch(base, opts)` are gone.
|
|
440
|
+
|
|
441
|
+
`destroy()` is idempotent and cleans up everything: closes the SSE
|
|
442
|
+
hot-reload watch, stops the renderer, destroys the router, disposes
|
|
443
|
+
the persist stash, deletes the resolver classes key from globalThis,
|
|
444
|
+
clears `globalThis.__ripApp` / `__ripLaunched` / `window.app` /
|
|
445
|
+
`window.__RIP__`. After `destroy()`, you can `launch(...)` again with
|
|
446
|
+
a different config.
|
|
447
|
+
|
|
448
|
+
### ARIA helpers
|
|
449
|
+
|
|
450
|
+
Shared keyboard/popup/focus primitives used by Rip UI widgets.
|
|
451
|
+
Registered on `globalThis.__aria` and `globalThis.ARIA` when the
|
|
452
|
+
framework bundle evaluates. Available globally in any component
|
|
453
|
+
without imports.
|
|
454
|
+
|
|
455
|
+
```rip
|
|
456
|
+
ARIA.listNav e, h # popup list nav (ArrowDown/Up, Enter, Escape, Home/End, typeahead)
|
|
457
|
+
ARIA.rovingNav e, h, orient # inline composite nav (radiogroup, tabs, toolbar)
|
|
458
|
+
ARIA.popupDismiss open, popup, close, els, repos
|
|
459
|
+
ARIA.popupGuard delay # per-component reopen suppression after pointer-driven closes
|
|
460
|
+
ARIA.bindPopover open, popover, setOpen, source
|
|
461
|
+
ARIA.bindDialog open, dialog, setOpen, dismissable
|
|
462
|
+
ARIA.position trigger, floating, opts # CSS anchor positioning with fallback
|
|
463
|
+
ARIA.positionBelow trigger, popup, gap, setVisible
|
|
464
|
+
ARIA.trapFocus panel # Tab wraps first↔last
|
|
465
|
+
ARIA.wireAria panel, id # auto-label panel from heading + paragraph
|
|
466
|
+
ARIA.lockScroll inst # body scroll lock with stack management
|
|
467
|
+
ARIA.unlockScroll inst
|
|
468
|
+
ARIA.combine ...disposers # fold N disposers into one
|
|
469
|
+
ARIA.hasAnchor() # feature-detect CSS anchor positioning
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
The `bindPopover`, `bindDialog`, and `popupDismiss` helpers are
|
|
473
|
+
**idempotent at the element level** — calling them repeatedly (as
|
|
474
|
+
happens when an enclosing `~>` effect re-runs) doesn't accumulate
|
|
475
|
+
listeners. Each call removes any prior listener it had attached
|
|
476
|
+
before adding a new one. This was earned the hard way; see
|
|
477
|
+
[Gotchas](#6-gotchas) below.
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## 4. Lifecycle invariants
|
|
482
|
+
|
|
483
|
+
These are the contracts the framework upholds. Reading these is
|
|
484
|
+
faster than tracing through 13 commits' worth of fixes.
|
|
485
|
+
|
|
486
|
+
### Component lifecycle order
|
|
487
|
+
|
|
488
|
+
```text
|
|
489
|
+
constructor (props) ← user code receives initial props
|
|
490
|
+
└─ _init(props) ← @state, @computed, top-level ~> effects all wire here
|
|
491
|
+
__pushComponent(this) wraps this call
|
|
492
|
+
→ _parent established (set-once)
|
|
493
|
+
→ effects auto-register on this._disposers
|
|
494
|
+
|
|
495
|
+
mount(target) ← only the renderer calls this directly
|
|
496
|
+
└─ __pushComponent(this)
|
|
497
|
+
_create() ← DOM tree construction; reactive bindings + child components
|
|
498
|
+
Per-child push wrap: each child's _create runs with
|
|
499
|
+
child as current, so the child's reactive bindings
|
|
500
|
+
register on child._disposers, not parent's.
|
|
501
|
+
beforeMount() ← user hook; signals/state ready, DOM not yet in tree
|
|
502
|
+
effects created here auto-register on this component
|
|
503
|
+
_setup() ← post-creation effects (rare; most go in _init)
|
|
504
|
+
mounted() ← user hook; DOM is in the tree now
|
|
505
|
+
__popComponent
|
|
506
|
+
|
|
507
|
+
[ ... reactive updates happen here, ad infinitum ... ]
|
|
508
|
+
|
|
509
|
+
unmount({ removeDOM = true }) ← idempotent (_unmounted flag short-circuits second calls)
|
|
510
|
+
└─ beforeUnmount() ← user hook; signals/effects still live
|
|
511
|
+
children.forEach unmount ← cascade BEFORE this instance's disposers
|
|
512
|
+
_disposers.forEach run ← effect cleanup fires
|
|
513
|
+
unmounted() ← user hook; final notification
|
|
514
|
+
DOM removal (if requested)
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
### User hooks
|
|
518
|
+
|
|
519
|
+
The framework recognizes these hook names on any component. All are
|
|
520
|
+
optional; the runtime calls each only if defined.
|
|
521
|
+
|
|
522
|
+
| Hook | When it fires | Notes |
|
|
523
|
+
| --------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
|
524
|
+
| `beforeMount` | After `_create`, before DOM is attached | Effects created here auto-register on the component |
|
|
525
|
+
| `mounted` | After DOM attached | Runs once per visit |
|
|
526
|
+
| `beforeUnmount` | Before children unmount and disposers fire | Signals/effects still live |
|
|
527
|
+
| `unmounted` | After disposers fire and DOM is removed | Final notification; runs once per visit |
|
|
528
|
+
| `onError` | A throw escapes any component method (render, hook, event) | Receives `{ status?, message?, error?, path? }`; the renderer walks the layout chain to find the nearest defining component |
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
### Effect ownership
|
|
532
|
+
|
|
533
|
+
Every `__effect(fn)` call automatically registers its disposer with
|
|
534
|
+
the **currently-pushed component** at the time of the call (via
|
|
535
|
+
`globalThis.__ripComponent.__getCurrentComponent`). On
|
|
536
|
+
`component.unmount()`, all registered disposers fire. This is the
|
|
537
|
+
mechanism that makes `~> ARIA.bindPopover(...)` work without a
|
|
538
|
+
per-widget `beforeUnmount` hook.
|
|
539
|
+
|
|
540
|
+
Two exceptions:
|
|
541
|
+
|
|
542
|
+
1. Effects created by **factory blocks** (`for`/`if` in render
|
|
543
|
+
templates) opt out of auto-registration via
|
|
544
|
+
`__effect(fn, {skipRegister: true})`. Their disposers live in the
|
|
545
|
+
factory's local `disposers` array and are called by `d(detaching)`
|
|
546
|
+
when the block is removed.
|
|
547
|
+
2. Top-level effects created outside any component context have no
|
|
548
|
+
parent to register with; the disposer must be called manually.
|
|
549
|
+
|
|
550
|
+
### Effect cleanup-on-rerun
|
|
551
|
+
|
|
552
|
+
If a `~>` body returns a function, that function becomes the effect's
|
|
553
|
+
cleanup. It fires:
|
|
554
|
+
|
|
555
|
+
1. Before each re-run, just after the new run is about to start.
|
|
556
|
+
2. On `effect.dispose()` (which fires when the owning component
|
|
557
|
+
unmounts).
|
|
558
|
+
|
|
559
|
+
For sync bodies, this is straightforward. For async bodies (the body
|
|
560
|
+
returns a Promise), the cleanup function still works, but with two
|
|
561
|
+
extra guards in place:
|
|
562
|
+
|
|
563
|
+
- A per-run `runId` counter discards async resolutions from a
|
|
564
|
+
superseded run. If the effect re-ran while the prior body was
|
|
565
|
+
awaiting, the prior body's eventual resolution is run-and-discarded
|
|
566
|
+
immediately, never installed on the now-current run's `_cleanup`.
|
|
567
|
+
- The effect's `AbortSignal` is aborted on every re-run / dispose.
|
|
568
|
+
User code that captured the signal via `getEffectSignal()` and
|
|
569
|
+
passed it to `fetch` / `setTimeout` etc. sees `AbortError` and
|
|
570
|
+
unwinds cleanly.
|
|
571
|
+
|
|
572
|
+
### Parent chain (for context)
|
|
573
|
+
|
|
574
|
+
`component._parent` is **set-once** during the first
|
|
575
|
+
`__pushComponent` that has a non-self predecessor. Subsequent pushes
|
|
576
|
+
preserve the construction-time chain. This keeps `getContext` /
|
|
577
|
+
`hasContext` / `__handleComponentError` walking up the tree
|
|
578
|
+
correctly even after the same component is re-pushed for its own
|
|
579
|
+
mount or factory re-entry.
|
|
580
|
+
|
|
581
|
+
The graph-traversal sites (`getContext`, `hasContext`,
|
|
582
|
+
`__handleComponentError`) all carry a `visited` Set as
|
|
583
|
+
defense-in-depth against any future bug that corrupts the chain. A
|
|
584
|
+
self-cycle in `_parent` no longer hangs the runtime; it's just an
|
|
585
|
+
early termination.
|
|
586
|
+
|
|
587
|
+
### Layout and page parentage
|
|
588
|
+
|
|
589
|
+
The renderer instantiates layouts in order, threading each as the
|
|
590
|
+
parent for the next, so an outer layout's `setContext` is visible to
|
|
591
|
+
inner layouts and to the page via `getContext`. The page is parented
|
|
592
|
+
to the innermost layout. Construction-time parent chain survives the
|
|
593
|
+
mount-time re-pushes.
|
|
594
|
+
|
|
595
|
+
### Factory blocks (for/if in render)
|
|
596
|
+
|
|
597
|
+
Factory blocks own their child component instances exclusively in a
|
|
598
|
+
local `_factoryChildren` array. When the block is detached
|
|
599
|
+
(`d(detaching)`), each child has `unmount({removeDOM: false})`
|
|
600
|
+
called. Child instances are NOT pushed onto the parent's `_children`
|
|
601
|
+
— that array would otherwise grow unboundedly under loop churn
|
|
602
|
+
(every removed iteration would leave a stale ref).
|
|
603
|
+
|
|
604
|
+
Conditional / loop reactive effects in **class mode** (top-level
|
|
605
|
+
component) push a manual disposer onto the parent's `_disposers`
|
|
606
|
+
that calls `currentBlock.d(true)` on unmount. Without this, parent
|
|
607
|
+
unmount would dispose the effect (preventing future re-runs) but
|
|
608
|
+
leave the current block alive — its DOM, signal subscriptions, and
|
|
609
|
+
child components all leaked.
|
|
610
|
+
|
|
611
|
+
### Keyed list reconciliation
|
|
612
|
+
|
|
613
|
+
`__reconcile` reuses blocks across renders when their keys match.
|
|
614
|
+
Phase-1 prefix scan calls `p()` (the per-render update) on the reused
|
|
615
|
+
block ONLY if the underlying item reference changed (even if the key
|
|
616
|
+
is the same). With a custom `keyFn` like `(item) -> item.id`, this
|
|
617
|
+
catches the case where stable keys map to mutated item content (an
|
|
618
|
+
item was replaced wholesale but the id stayed the same).
|
|
619
|
+
|
|
620
|
+
---
|
|
621
|
+
|
|
622
|
+
## 5. Async effects
|
|
623
|
+
|
|
624
|
+
For `~>` bodies that use `await`, capture the effect's
|
|
625
|
+
`AbortSignal` BEFORE any await, then pass it through to anything
|
|
626
|
+
that supports it (fetch, setTimeout via `AbortSignal.timeout`, etc.):
|
|
627
|
+
|
|
628
|
+
```rip
|
|
629
|
+
~>
|
|
630
|
+
signal = getEffectSignal() # capture before await
|
|
631
|
+
return unless @userId
|
|
632
|
+
res = fetch! "/api/users/#{@userId}", {signal}
|
|
633
|
+
return if signal.aborted # bail if disposed mid-flight
|
|
634
|
+
data = res.json!
|
|
635
|
+
return if signal.aborted
|
|
636
|
+
@user = data
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
`getEffectSignal()` returns the AbortSignal of the currently-running
|
|
640
|
+
effect, or `null` if called outside an effect or before
|
|
641
|
+
`AbortController` is available. The signal is aborted whenever the
|
|
642
|
+
effect re-runs (a dependency changed) or is disposed (the owning
|
|
643
|
+
component is unmounting).
|
|
644
|
+
|
|
645
|
+
If you don't capture the signal, the await still runs to completion,
|
|
646
|
+
but the effect's cleanup-via-return-function still works — and any
|
|
647
|
+
post-await mutation might write to a destroyed component. The signal
|
|
648
|
+
is the cleanest way to bail early.
|
|
649
|
+
|
|
650
|
+
---
|
|
651
|
+
|
|
652
|
+
## 6. Gotchas
|
|
653
|
+
|
|
654
|
+
Things that have bitten us before. Each is documented at its
|
|
655
|
+
in-source enforcement site too, but here's the unified list.
|
|
656
|
+
|
|
657
|
+
### The bundle boundary matters
|
|
658
|
+
|
|
659
|
+
The framework bundle (`docs/dist/rip.min.js`) loads ONCE at app
|
|
660
|
+
startup. Anything inside it evaluates exactly once and registers its
|
|
661
|
+
globals (`__rip`, `__ripComponent`, `__aria`, etc.) for the page
|
|
662
|
+
lifetime. The widget bundle (`docs/ui/bundle.json`) loads on-demand
|
|
663
|
+
when a widget is referenced; each widget compiles separately.
|
|
664
|
+
|
|
665
|
+
**Do not move framework-level helpers into the widget bundle.** ARIA
|
|
666
|
+
helpers were once moved to `packages/ui/browser/components/_aria.rip`
|
|
667
|
+
to "decouple" them. Two things broke immediately:
|
|
668
|
+
|
|
669
|
+
1. `globalThis.ARIA` was undefined when widgets evaluated — the
|
|
670
|
+
widget bundle is module-graph-driven; nothing imported `_aria.rip`
|
|
671
|
+
so it never loaded.
|
|
672
|
+
2. We added `import './_aria.rip'` to 21 widgets to compensate. The
|
|
673
|
+
per-widget import was fragile (a new widget contributor could
|
|
674
|
+
forget) and didn't actually save bytes (it just shifted them
|
|
675
|
+
between bundles).
|
|
676
|
+
|
|
677
|
+
We reverted. ARIA stays in `packages/app/index.rip` because *that
|
|
678
|
+
file is part of the framework bundle* — guaranteed-once evaluation,
|
|
679
|
+
guaranteed-globally-available. If you want to refactor a primitive
|
|
680
|
+
out of `index.rip`, it can move to a sibling file in `packages/app/`
|
|
681
|
+
**only if `scripts/build.js` is updated to include it in the
|
|
682
|
+
framework bundle**.
|
|
683
|
+
|
|
684
|
+
### Render-template name shadowing (fixed)
|
|
685
|
+
|
|
686
|
+
Earlier versions of the compiler treated a lowercase identifier as
|
|
687
|
+
an HTML tag *even when a local of the same name was in lexical
|
|
688
|
+
scope*. Writing `code = ex.body` then `span code` either silently
|
|
689
|
+
mis-routed the reference or emitted a stray `<code>` element. The
|
|
690
|
+
rule now matches every other lexically-scoped language: a render-
|
|
691
|
+
scope local shadows the HTML tag with the same name.
|
|
692
|
+
|
|
693
|
+
```rip
|
|
694
|
+
# Both of these now do what they look like — `code` is read as a value,
|
|
695
|
+
# not interpreted as the <code> HTML tag.
|
|
696
|
+
for ex in examples
|
|
697
|
+
code = if ex.curl? then buildCurl(ex) else ex.code
|
|
698
|
+
CodeBlock label: ex.label, code: code
|
|
699
|
+
|
|
700
|
+
for code in examples
|
|
701
|
+
span code # → <span>{code}</span>
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
Bindings introduced by `name = expr` and loop variables introduced
|
|
705
|
+
by `for x in ...` / `for x, i in ...` are both treated as lexical
|
|
706
|
+
locals. The shadowing only resolves within the same block factory
|
|
707
|
+
(loop body, conditional branch) — render locals do not currently
|
|
708
|
+
thread across factory boundaries the way loop variables do.
|
|
709
|
+
|
|
710
|
+
Render bindings are creation-time captures, not reactive computeds.
|
|
711
|
+
`code = ex.body` evaluates once when the block is built and never
|
|
712
|
+
re-runs, mirroring the existing semantics of `span ex.body` (also
|
|
713
|
+
one-shot). If you need the value to track changes, lift the
|
|
714
|
+
expression to a class-level `:=` / `~=` member, or read the reactive
|
|
715
|
+
source directly inside the DOM expression.
|
|
716
|
+
|
|
717
|
+
### Nested `for` loops can both name `i` (fixed)
|
|
718
|
+
|
|
719
|
+
The outer `for item in items` no longer auto-allocates `i` and then
|
|
720
|
+
collides with an inner `for v, i in ...`. The compiler now pre-scans
|
|
721
|
+
the loop body for explicit descendant index names and skips any name
|
|
722
|
+
that would clash, falling back to a mangled internal name only if
|
|
723
|
+
every conventional letter is taken. The patch function's parameter
|
|
724
|
+
list stays unique at any nesting depth.
|
|
725
|
+
|
|
726
|
+
```rip
|
|
727
|
+
# All of these now compile cleanly:
|
|
728
|
+
for item in items
|
|
729
|
+
for v, i in item.options
|
|
730
|
+
span "#{v}@#{i}"
|
|
731
|
+
|
|
732
|
+
for item in items
|
|
733
|
+
for group in item.groups
|
|
734
|
+
for v, i in group.values
|
|
735
|
+
span "#{v}@#{i}"
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
(User-explicit duplicates — e.g. `for x, i in xs / for y, i in ys`
|
|
739
|
+
where the same `i` is bound at two nesting levels — are still a
|
|
740
|
+
strict-mode error. That's a real name conflict in the user's source,
|
|
741
|
+
not something the compiler should silently rewrite.)
|
|
742
|
+
|
|
743
|
+
### Snapshot tests are brittle
|
|
744
|
+
|
|
745
|
+
The compiler's codegen is exercised by ~50 `code "..."` snapshot
|
|
746
|
+
tests in `test/rip/components.rip`. Any codegen change (we did 19
|
|
747
|
+
snapshot updates in Wave 8a, 11 in Wave 11) requires updating those
|
|
748
|
+
expected outputs. The pattern of "compile this Rip → expect this
|
|
749
|
+
exact JS" is fragile but catches accidental codegen regressions.
|
|
750
|
+
|
|
751
|
+
When you change codegen, expect snapshot churn. Use the snapshot
|
|
752
|
+
auto-update script (`/tmp/update-snapshots.mjs` from the wave 8a-12
|
|
753
|
+
sessions) only after **manually verifying the diff is mechanical**
|
|
754
|
+
(your intentional change, not a behavior regression).
|
|
755
|
+
|
|
756
|
+
### No browser e2e tests
|
|
757
|
+
|
|
758
|
+
The unit test suite (`bun run test`) covers compiler codegen,
|
|
759
|
+
runtime semantics, schema, and server behaviors — but does NOT load
|
|
760
|
+
`rip.min.js` in a browser-like environment and verify the framework
|
|
761
|
+
runs end-to-end. We rely on:
|
|
762
|
+
|
|
763
|
+
- Snapshot tests catching codegen regressions.
|
|
764
|
+
- Runtime unit tests catching reactive/lifecycle regressions.
|
|
765
|
+
- `scripts/check-bundle-graph.js` catching bundle-composition
|
|
766
|
+
regressions.
|
|
767
|
+
- Manual verification (loading a real app) for end-to-end behavior.
|
|
768
|
+
|
|
769
|
+
This is a real coverage gap. Refactors that touch the bundle
|
|
770
|
+
composition or the browser entry point should be smoke-tested by
|
|
771
|
+
loading an actual app.
|
|
772
|
+
|
|
773
|
+
---
|
|
774
|
+
|
|
775
|
+
## 7. When NOT to use Rip App
|
|
776
|
+
|
|
777
|
+
Honest list of where Rip App is the wrong tool:
|
|
778
|
+
|
|
779
|
+
- **Server-side rendering (SSR) or streaming.** Rip App is
|
|
780
|
+
browser-first by design. There's no `renderToString`, no hydration
|
|
781
|
+
protocol, no resumability. If your project's primary requirement
|
|
782
|
+
is SEO-friendly server rendering, use a framework that has SSR as
|
|
783
|
+
a core concern (Next.js, SvelteKit, Nuxt, SolidStart).
|
|
784
|
+
- **Multi-team scale.** The render DSL is unconventional enough that
|
|
785
|
+
a large team will keep tripping on the conceptual model (block
|
|
786
|
+
factories, fine-grained reactivity, the `@`/`:=`/`~=` keyword
|
|
787
|
+
family) even after the historical tag-name and loop-index
|
|
788
|
+
footguns are gone. Rip App is happiest with a small focused team
|
|
789
|
+
that fits the framework's mental model in one head.
|
|
790
|
+
- **Plugin-ecosystem-dependent apps.** If your roadmap depends on
|
|
791
|
+
"there's a library for that" — auth, charts, maps, file uploads,
|
|
792
|
+
rich-text editing — the npm ecosystem around React/Vue is
|
|
793
|
+
dramatically larger. Rip's first-party packages cover the basics;
|
|
794
|
+
they don't cover everything.
|
|
795
|
+
- **TypeScript-strict shops.** Rip has its own type system with
|
|
796
|
+
growing capability, but it's not yet at the maturity of TypeScript
|
|
797
|
+
+ a major framework's `@types/*`. If your team's correctness story
|
|
798
|
+
is "TypeScript catches it," Rip is a step laterally, not forward.
|
|
799
|
+
- **Multiple isolated app instances on one page.** The single-stash
|
|
800
|
+
/ single-launch / single-`globalThis.__ripApp` model assumes one
|
|
801
|
+
app per page. Multi-app embedding is possible but you'd be
|
|
802
|
+
fighting the design.
|
|
803
|
+
|
|
804
|
+
For everything else — single-team browser apps, internal tools,
|
|
805
|
+
dashboards, documentation sites, demos, weekend projects, hobbyist
|
|
806
|
+
work — Rip App is genuinely competitive. The "no build step,
|
|
807
|
+
batteries included, drop in a script tag" pitch is real, and the
|
|
808
|
+
framework's lifecycle + reactive model is honestly solid.
|