xote 7.0.0 → 7.1.0-beta.2

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/ppx/README.md ADDED
@@ -0,0 +1,308 @@
1
+ # xote-tracked-ppx
2
+
3
+ A native ReScript PPX that expands an `@xote.component` annotation into a
4
+ props-deriving component whose returned JSX is decomposed into **fine-grained
5
+ reactive leaves** — the compile-time counterpart to the runtime
6
+ [`View.tracked`](../src/View.res) helper.
7
+
8
+ > **Status:** experimental, opt-in. The PPX is exercised in CI and used by the
9
+ > [docs site](../docs-website) itself (the Counter demo), but it is **not part
10
+ > of the published npm package** — consumers who want it build it themselves
11
+ > (see [Opt-in for consumers](#opt-in-for-consumers)). It demonstrates that the
12
+ > [`rescript-signals` #34](https://github.com/brnrdog/rescript-signals/pull/34)
13
+ > auto-tracking idea can target Xote's view layer *and* compile away the
14
+ > wholesale-replacement tradeoff of `View.tracked`. See
15
+ > [`docs/proposals/tracked-blocks.md`](../docs/proposals/tracked-blocks.md) for
16
+ > the full design context (this is "Phase 2, fine-grained variant").
17
+
18
+ ## What problem it solves
19
+
20
+ `View.tracked(() => <block>)` is convenient — every signal read inside the
21
+ block subscribes it automatically — but it lowers to a `SignalFragment` over a
22
+ `Computed`, so **any** dependency change re-evaluates the whole block and
23
+ replaces its children wholesale (no diffing; local DOM state like input focus
24
+ is lost). Good for small blocks, a footgun on large ones.
25
+
26
+ This PPX takes the same ergonomic surface — write plain JSX, read signals
27
+ inline — but instead of one coarse computed it **decomposes the block at
28
+ compile time**, pushing reactivity down to exactly the leaves that read
29
+ signals. The element structure is emitted once and never rebuilt.
30
+
31
+ ## Example
32
+
33
+ Input — one annotation replaces `@jsx.component` and makes the return
34
+ fine-grained:
35
+
36
+ ```rescript
37
+ @xote.component
38
+ let make = (~label: string) => {
39
+ let count = Signal.make(0)
40
+ <div class={Signal.get(count) > 0 ? "on" : "off"} id="card">
41
+ <span class="static-label"> {label} </span>
42
+ {Signal.get(count)}
43
+ </div>
44
+ }
45
+ ```
46
+
47
+ Compiles to (abbreviated):
48
+
49
+ ```js
50
+ function make(props) {
51
+ let label = props.label; // props derived (@jsx.component)
52
+ let count = Signal.make(0);
53
+ return Elements.jsxs("div", {
54
+ id: "card", // static — untouched
55
+ class: () => Signal.get(count) > 0 ? "on" : "off", // → View.computedAttr (reactive leaf)
56
+ children: [
57
+ Elements.jsx("span", { // static subtree — untouched
58
+ class: "static-label",
59
+ children: View.child(label), // static text (passthrough/coerce)
60
+ }),
61
+ View.child(() => Signal.get(count)), // → reactive text node (leaf)
62
+ ],
63
+ });
64
+ }
65
+ ```
66
+
67
+ Props derive from the labeled args (`label` stays static); no `View.tracked`,
68
+ no `SignalFragment`, no `<View.Int>` wrapper, no wholesale rebuild. The bare
69
+ `{Signal.get(count)}` child is coerced by `View.child` into a reactive text
70
+ leaf; the `<div>` and `<span>` keep their DOM identity across updates, and only
71
+ the `class` attribute and the number re-run. (`example/verify.mjs` asserts
72
+ exactly this by tagging the elements and checking the tags survive a signal
73
+ change.)
74
+
75
+ `@xote.component` is the single annotation. The PPX rewrites it to
76
+ `@jsx.component` (so the JSX transform still derives the props record) and
77
+ fine-grains the returned JSX — one attribute, no `@jsx.component` +
78
+ tracking-annotation stacking. Because it emits `@jsx.component`, it inherits its
79
+ rules: **one component per module** (each in its own file, or a submodule).
80
+
81
+ ## Decomposition rules
82
+
83
+ Applied recursively to the component's returned JSX:
84
+
85
+ | Position | Reads a signal? | Result |
86
+ |---|---|---|
87
+ | Attribute value (`class={…}`) | yes | thunked → `View.computedAttr` (reactive attribute leaf) |
88
+ | Attribute value | no | left as-is (static attribute) |
89
+ | `<View.Text/Int/Float/Bool>` child | yes | thunked → reactive text node (leaf) |
90
+ | `<View.Text/…>` child | no | left as-is (static text) |
91
+ | Element / nested JSX | — | recurse into attributes and children |
92
+ | Fragment (`<>…</>`) | — | recurse into each child independently (so nested reactive regions stay separate — not collapsed into one thunk) |
93
+ | Bare child, control flow (`if`/`switch` selecting different nodes) | yes | branches decomposed fine-grained, then wrapped in `View.tracked` — see below |
94
+ | Bare child, otherwise (`{Signal.get(x)}`, `{"lit"}`, `{someNode}`) | — | wrapped in `View.child` — see [Bare value children](#bare-value-children) |
95
+
96
+ The result: reactivity lives at the leaves; `View.tracked` is emitted
97
+ **surgically**, only around a child region whose node *structure* actually
98
+ varies, and never around the stable elements that enclose it.
99
+
100
+ ### Bare value children
101
+
102
+ You don't need an explicit `<View.Int>`/`<View.Text>` value primitive under the
103
+ annotation — a **bare `{…}` child** in element position works directly:
104
+
105
+ ```rescript
106
+ @xote.component
107
+ let make = () =>
108
+ <div>
109
+ {"Count: "} // static text
110
+ {Signal.get(count)} // reactive text leaf
111
+ </div>
112
+ ```
113
+
114
+ Every bare non-control-flow child is wrapped in the runtime helper
115
+ [`View.child`](../src/View.res), which coerces at runtime:
116
+
117
+ - an eager signal read is thunked first, so it becomes a **reactive text** leaf;
118
+ - a static scalar (`{"lit"}`, `{42}`) becomes a **static text** node — previously
119
+ a *compile error* (a scalar in node position), now it just works;
120
+ - a value that is **already a node** (`{View.text(x)}`, a component call, a list)
121
+ passes through untouched (detected by its runtime tag);
122
+ - `null`/`undefined` render nothing.
123
+
124
+ This also covers control flow whose **branches are scalars** — the `switch` is
125
+ still tracked for the structural swap, but each scalar branch is coerced by
126
+ `View.child`, so `| Loading => "…"` no longer needs a value primitive either.
127
+
128
+ The explicit `View.Text/Int/Float/Bool` primitives remain available (they are
129
+ what non-PPX code uses, and give stronger `int`/`float` typing on the child);
130
+ `View.child` is just the zero-ceremony default under `@xote.component`.
131
+
132
+ ### Control flow tracks only the condition
133
+
134
+ When a branch body is decomposed *before* the `View.tracked` wrapper is applied,
135
+ its leaves become thunks. So when the tracked scope runs the chosen branch to
136
+ build its nodes, those thunks are not invoked — the scope ends up subscribed to
137
+ only the **condition/scrutinee**, not to signals read by leaves inside the
138
+ branches. Given:
139
+
140
+ ```rescript
141
+ @xote.component
142
+ let make = () =>
143
+ <div>
144
+ {switch Signal.get(status) {
145
+ | Loading => <span> {View.text("Loading...")} </span>
146
+ | Ready(msg) => <strong class={Signal.get(theme)}> {View.text(msg)} </strong>
147
+ }}
148
+ </div>
149
+ ```
150
+
151
+ the `class={Signal.get(theme)}` becomes a `computedAttr` **inside** the
152
+ `View.tracked`. Changing `theme` updates just that class and leaves the
153
+ `<strong>` in place; only a change to `status` (the scrutinee) re-runs the
154
+ switch and rebuilds the branch. `example/verify.mjs` asserts both:
155
+ the `<strong>` keeps its identity across a `theme` change, and a `status`
156
+ change still swaps the branch.
157
+
158
+ ## What counts as "reads a signal"
159
+
160
+ Detection is more than a literal `Signal.get`. An alias environment threaded
161
+ through the traversal (scoped: an alias is visible only *after* its binding, and
162
+ shadowing it with a non-alias removes it) recognises all of these:
163
+
164
+ | Form | Example | Detected via |
165
+ |---|---|---|
166
+ | Direct | `Signal.get(sig)` / `X.Signal.get(sig)` | literal match |
167
+ | Pipe | `sig->Signal.get` | desugars to `Signal.get(sig)` before the PPX |
168
+ | Value alias | `let g = Signal.get` … `g(sig)` | binding tracked in scope |
169
+ | Module alias | `module S = Signal` … `S.get(sig)` | binding tracked in scope |
170
+ | Open | `open Signal` … `get(sig)` | bare `get` under an open |
171
+ | Local reactive helper | `let cls = () => Signal.get(x) ? …` … `cls()` | function binding whose body eagerly reads a signal; its *call* counts |
172
+
173
+ `Signal.peek` is intentionally **not** a read — it is an untracked read, so a
174
+ value that only peeks stays static (verified by the shadowing case, where an
175
+ alias rebound to a `peek`-based function is dropped and its attribute is left
176
+ as a plain string).
177
+
178
+ Only *eager* reads trigger a thunk. A read deferred inside a nested lambda — a
179
+ `() => …` you wrote yourself, a `Computed`, a `Prop.reactive(Computed.make(…))`,
180
+ or a helper that merely *returns* a thunk — is already reactive and left as-is.
181
+ Because of that, when detection can't see a read (below), the safe fix is always
182
+ to wrap the value in `() =>` yourself: it will not be double-wrapped.
183
+
184
+ ## How it works
185
+
186
+ Same mechanism as `rescript-tracked-ppx` in PR #34: ReScript 12 hands an
187
+ external PPX an OCaml **4.06** parsetree (marshal magic `Caml1999M022`).
188
+ `ppx.ml` vendors those exact AST types (so `Marshal` round-trips), implements
189
+ the `ppx <infile> <outfile>` protocol, and rewrites bindings carrying the
190
+ `xote.component` attribute (swapping in `jsx.component` and decomposing the
191
+ returned JSX).
192
+
193
+ The PPX runs **before** ReScript's JSX transform, so it sees JSX as
194
+ `Apply @[JSX]` nodes with attributes as labelled arguments — the ideal layer to
195
+ redistribute reactivity across attributes and children before they are lowered
196
+ into `XoteJSX` calls. Thunks are emitted as `Function$(fun () -> …)` with a
197
+ `res.arity` attribute (the uncurried-function encoding); the same encoding is
198
+ unwrapped to reach the component body inside `Function$(fun ~props -> …)`.
199
+
200
+ ## License
201
+
202
+ The vendored AST modules at the top of `ppx.ml` (`Location`, `Longident`,
203
+ `Asttypes`, `Parsetree`) are copied verbatim from the OCaml 4.06 compiler,
204
+ © 1996–2019 INRIA, distributed under **LGPL-2.1 with the OCaml linking
205
+ exception**; they keep their original copyright headers. The
206
+ `@xote.component` rewriter below them is the project's own code. The full third-party notice
207
+ and license text is in [`LICENSE.OCaml`](./LICENSE.OCaml), which ships in the
208
+ npm tarball alongside `ppx.ml`.
209
+
210
+ ## Build
211
+
212
+ ```sh
213
+ sh build.sh # produces ./ppx (needs ocamlopt; any recent OCaml, tested 4.14)
214
+ ```
215
+
216
+ Wire it into a project's `rescript.json`:
217
+
218
+ ```json
219
+ { "ppx-flags": ["xote-tracked-ppx/ppx"] }
220
+ ```
221
+
222
+ ## Opt-in for consumers
223
+
224
+ The compiled binary is platform-specific and is **not** shipped in the npm
225
+ package, and — deliberately — neither is a `ppx-flags` entry in Xote's own
226
+ published `rescript.json`. That last point matters: a ReScript consumer
227
+ recompiles a dependency's sources during its own build and applies that
228
+ dependency's `ppx-flags`, so a PPX listed in Xote's published config would
229
+ force `ocamlopt` on **every** Xote user. Instead the PPX is strictly opt-in.
230
+
231
+ The `ppx.ml` source *is* published, so a consumer who wants `@xote.component`
232
+ can build it from the installed package and reference it from **their own**
233
+ `rescript.json`:
234
+
235
+ ```sh
236
+ npm install xote
237
+ sh node_modules/xote/ppx/build.sh # needs ocamlopt
238
+ ```
239
+
240
+ ```json
241
+ { "ppx-flags": ["xote/ppx/ppx"] }
242
+ ```
243
+
244
+ Consumers who don't do this are entirely unaffected.
245
+
246
+ ## In this repo
247
+
248
+ The PPX is developed and exercised in-repo without touching the published
249
+ library config:
250
+
251
+ - **`npm run ppx:build`** / **`npm run ppx:test`** (repo root) build the binary
252
+ and run the end-to-end example verification.
253
+ - **CI** (`.github/workflows/ci.yml`) installs `ocaml-nox`, builds the PPX, and
254
+ runs `ppx:test` on every push/PR, so it can't silently rot.
255
+ - The **docs site** (`docs-website/`) is a real consumer: its own
256
+ `rescript.json` carries the `ppx-flags`, its `res:build` builds the PPX first,
257
+ and the Counter demo is authored with `@xote.component`. The published Xote
258
+ library (`src/`, root `rescript.json`) stays PPX-free.
259
+
260
+ ## Run the example
261
+
262
+ The example is a standalone mini-project. The whole flow is wrapped in a single
263
+ script — from the repo root:
264
+
265
+ ```sh
266
+ npm run ppx:test # setup + build ppx + compile Demo.res + jsdom verify
267
+ ```
268
+
269
+ Or step by step from `example/`:
270
+
271
+ ```sh
272
+ sh setup.sh # link toolchain + Xote from the repo root (idempotent)
273
+ sh ../build.sh # build the ppx
274
+ npm run build # compile Demo.res through the ppx
275
+ npm run verify # jsdom runtime check (60 assertions)
276
+ ```
277
+
278
+ ## Known limitations (it's a prototype)
279
+
280
+ - **Signal detection is syntactic** (though alias- and helper-aware — see the
281
+ table above). It follows `let`/`module`/`open` aliases and *local* reactive
282
+ helpers, but not indirection it cannot see the definition of: a signal read
283
+ behind an **imported / cross-module** helper, or reached through a data
284
+ structure, is not detected. Such a value compiles to a **static, once-evaluated
285
+ attribute/text with no error** — the one genuinely silent failure. The escape
286
+ hatch is reliable: wrap it in `() =>` yourself and it becomes reactive (the
287
+ eager check leaves your thunk alone, so it is never double-wrapped). An
288
+ over-eager match only produces a harmless extra `computedAttr`.
289
+ - **Value-component detection is hard-coded** to the qualified
290
+ `View.Text/Int/Float/Bool`. An aliased or opened `View` (`module V = View` →
291
+ `<V.Text>`, `open View` → bare `<Text>`) is not recognized as a value
292
+ component. This is **not silent** — the value child lands in node position and
293
+ is a **compile error** (`string` where `View.node` is expected), so it fails
294
+ loudly at build time. Simplest fix: drop the wrapper and use a **bare `{…}`
295
+ child** (`View.child` coerces it), or use the qualified `View.Text` form.
296
+ - **Coupled to ReScript's ppx ABI.** The vendored OCaml 4.06 parsetree, the
297
+ `Caml1999M022` marshal magic, and the uncurried `Function$` construct name are
298
+ compiler internals. A ReScript release that bumps the ppx AST version fails
299
+ loudly (magic mismatch); one that renamed `Function$` could fail *quietly*.
300
+ Validated against ReScript 12; CI building the docs site through the PPX is the
301
+ canary on upgrade.
302
+ - **A branch swap still rebuilds that branch's subtree.** Control flow tracks
303
+ only the condition (leaves inside branches stay fine-grained, see above), but
304
+ when the condition *does* change, the selected branch is built fresh — there
305
+ is no keyed diffing between the old and new branch. This matches Xote's own
306
+ `View.Show`/`View.tracked`; use `View.For` with `by` for lists.
307
+ - **Not wired into the main build.** This lives outside `src/` and is not part
308
+ of `npm run build`/`test`; it is a standalone proof of concept.
package/ppx/build.sh ADDED
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+ # Compiles the fine-grained @tracked ppx to a native binary using the system
3
+ # OCaml compiler. ReScript 12 hands a ppx an OCaml 4.06 parsetree; ppx.ml
4
+ # vendors those exact AST types, so the only build dependency is ocamlopt
5
+ # (any recent OCaml works — tested with 4.14).
6
+ set -e
7
+ cd "$(dirname "$0")"
8
+ ocamlopt -w -a-31 ppx.ml -o ppx
9
+ rm -f ppx.cmi ppx.cmx ppx.o
10
+ echo "built $(pwd)/ppx"