xote 7.0.0 → 7.1.0-beta.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/ppx/README.md ADDED
@@ -0,0 +1,307 @@
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
+ | Bare child, control flow (`if`/`switch` selecting different nodes) | yes | branches decomposed fine-grained, then wrapped in `View.tracked` — see below |
93
+ | Bare child, otherwise (`{Signal.get(x)}`, `{"lit"}`, `{someNode}`) | — | wrapped in `View.child` — see [Bare value children](#bare-value-children) |
94
+
95
+ The result: reactivity lives at the leaves; `View.tracked` is emitted
96
+ **surgically**, only around a child region whose node *structure* actually
97
+ varies, and never around the stable elements that enclose it.
98
+
99
+ ### Bare value children
100
+
101
+ You don't need an explicit `<View.Int>`/`<View.Text>` value primitive under the
102
+ annotation — a **bare `{…}` child** in element position works directly:
103
+
104
+ ```rescript
105
+ @xote.component
106
+ let make = () =>
107
+ <div>
108
+ {"Count: "} // static text
109
+ {Signal.get(count)} // reactive text leaf
110
+ </div>
111
+ ```
112
+
113
+ Every bare non-control-flow child is wrapped in the runtime helper
114
+ [`View.child`](../src/View.res), which coerces at runtime:
115
+
116
+ - an eager signal read is thunked first, so it becomes a **reactive text** leaf;
117
+ - a static scalar (`{"lit"}`, `{42}`) becomes a **static text** node — previously
118
+ a *compile error* (a scalar in node position), now it just works;
119
+ - a value that is **already a node** (`{View.text(x)}`, a component call, a list)
120
+ passes through untouched (detected by its runtime tag);
121
+ - `null`/`undefined` render nothing.
122
+
123
+ This also covers control flow whose **branches are scalars** — the `switch` is
124
+ still tracked for the structural swap, but each scalar branch is coerced by
125
+ `View.child`, so `| Loading => "…"` no longer needs a value primitive either.
126
+
127
+ The explicit `View.Text/Int/Float/Bool` primitives remain available (they are
128
+ what non-PPX code uses, and give stronger `int`/`float` typing on the child);
129
+ `View.child` is just the zero-ceremony default under `@xote.component`.
130
+
131
+ ### Control flow tracks only the condition
132
+
133
+ When a branch body is decomposed *before* the `View.tracked` wrapper is applied,
134
+ its leaves become thunks. So when the tracked scope runs the chosen branch to
135
+ build its nodes, those thunks are not invoked — the scope ends up subscribed to
136
+ only the **condition/scrutinee**, not to signals read by leaves inside the
137
+ branches. Given:
138
+
139
+ ```rescript
140
+ @xote.component
141
+ let make = () =>
142
+ <div>
143
+ {switch Signal.get(status) {
144
+ | Loading => <span> {View.text("Loading...")} </span>
145
+ | Ready(msg) => <strong class={Signal.get(theme)}> {View.text(msg)} </strong>
146
+ }}
147
+ </div>
148
+ ```
149
+
150
+ the `class={Signal.get(theme)}` becomes a `computedAttr` **inside** the
151
+ `View.tracked`. Changing `theme` updates just that class and leaves the
152
+ `<strong>` in place; only a change to `status` (the scrutinee) re-runs the
153
+ switch and rebuilds the branch. `example/verify.mjs` asserts both:
154
+ the `<strong>` keeps its identity across a `theme` change, and a `status`
155
+ change still swaps the branch.
156
+
157
+ ## What counts as "reads a signal"
158
+
159
+ Detection is more than a literal `Signal.get`. An alias environment threaded
160
+ through the traversal (scoped: an alias is visible only *after* its binding, and
161
+ shadowing it with a non-alias removes it) recognises all of these:
162
+
163
+ | Form | Example | Detected via |
164
+ |---|---|---|
165
+ | Direct | `Signal.get(sig)` / `X.Signal.get(sig)` | literal match |
166
+ | Pipe | `sig->Signal.get` | desugars to `Signal.get(sig)` before the PPX |
167
+ | Value alias | `let g = Signal.get` … `g(sig)` | binding tracked in scope |
168
+ | Module alias | `module S = Signal` … `S.get(sig)` | binding tracked in scope |
169
+ | Open | `open Signal` … `get(sig)` | bare `get` under an open |
170
+ | Local reactive helper | `let cls = () => Signal.get(x) ? …` … `cls()` | function binding whose body eagerly reads a signal; its *call* counts |
171
+
172
+ `Signal.peek` is intentionally **not** a read — it is an untracked read, so a
173
+ value that only peeks stays static (verified by the shadowing case, where an
174
+ alias rebound to a `peek`-based function is dropped and its attribute is left
175
+ as a plain string).
176
+
177
+ Only *eager* reads trigger a thunk. A read deferred inside a nested lambda — a
178
+ `() => …` you wrote yourself, a `Computed`, a `Prop.reactive(Computed.make(…))`,
179
+ or a helper that merely *returns* a thunk — is already reactive and left as-is.
180
+ Because of that, when detection can't see a read (below), the safe fix is always
181
+ to wrap the value in `() =>` yourself: it will not be double-wrapped.
182
+
183
+ ## How it works
184
+
185
+ Same mechanism as `rescript-tracked-ppx` in PR #34: ReScript 12 hands an
186
+ external PPX an OCaml **4.06** parsetree (marshal magic `Caml1999M022`).
187
+ `ppx.ml` vendors those exact AST types (so `Marshal` round-trips), implements
188
+ the `ppx <infile> <outfile>` protocol, and rewrites bindings carrying the
189
+ `xote.component` attribute (swapping in `jsx.component` and decomposing the
190
+ returned JSX).
191
+
192
+ The PPX runs **before** ReScript's JSX transform, so it sees JSX as
193
+ `Apply @[JSX]` nodes with attributes as labelled arguments — the ideal layer to
194
+ redistribute reactivity across attributes and children before they are lowered
195
+ into `XoteJSX` calls. Thunks are emitted as `Function$(fun () -> …)` with a
196
+ `res.arity` attribute (the uncurried-function encoding); the same encoding is
197
+ unwrapped to reach the component body inside `Function$(fun ~props -> …)`.
198
+
199
+ ## License
200
+
201
+ The vendored AST modules at the top of `ppx.ml` (`Location`, `Longident`,
202
+ `Asttypes`, `Parsetree`) are copied verbatim from the OCaml 4.06 compiler,
203
+ © 1996–2019 INRIA, distributed under **LGPL-2.1 with the OCaml linking
204
+ exception**; they keep their original copyright headers. The
205
+ `@xote.component` rewriter below them is the project's own code. The full third-party notice
206
+ and license text is in [`LICENSE.OCaml`](./LICENSE.OCaml), which ships in the
207
+ npm tarball alongside `ppx.ml`.
208
+
209
+ ## Build
210
+
211
+ ```sh
212
+ sh build.sh # produces ./ppx (needs ocamlopt; any recent OCaml, tested 4.14)
213
+ ```
214
+
215
+ Wire it into a project's `rescript.json`:
216
+
217
+ ```json
218
+ { "ppx-flags": ["xote-tracked-ppx/ppx"] }
219
+ ```
220
+
221
+ ## Opt-in for consumers
222
+
223
+ The compiled binary is platform-specific and is **not** shipped in the npm
224
+ package, and — deliberately — neither is a `ppx-flags` entry in Xote's own
225
+ published `rescript.json`. That last point matters: a ReScript consumer
226
+ recompiles a dependency's sources during its own build and applies that
227
+ dependency's `ppx-flags`, so a PPX listed in Xote's published config would
228
+ force `ocamlopt` on **every** Xote user. Instead the PPX is strictly opt-in.
229
+
230
+ The `ppx.ml` source *is* published, so a consumer who wants `@xote.component`
231
+ can build it from the installed package and reference it from **their own**
232
+ `rescript.json`:
233
+
234
+ ```sh
235
+ npm install xote
236
+ sh node_modules/xote/ppx/build.sh # needs ocamlopt
237
+ ```
238
+
239
+ ```json
240
+ { "ppx-flags": ["xote/ppx/ppx"] }
241
+ ```
242
+
243
+ Consumers who don't do this are entirely unaffected.
244
+
245
+ ## In this repo
246
+
247
+ The PPX is developed and exercised in-repo without touching the published
248
+ library config:
249
+
250
+ - **`npm run ppx:build`** / **`npm run ppx:test`** (repo root) build the binary
251
+ and run the end-to-end example verification.
252
+ - **CI** (`.github/workflows/ci.yml`) installs `ocaml-nox`, builds the PPX, and
253
+ runs `ppx:test` on every push/PR, so it can't silently rot.
254
+ - The **docs site** (`docs-website/`) is a real consumer: its own
255
+ `rescript.json` carries the `ppx-flags`, its `res:build` builds the PPX first,
256
+ and the Counter demo is authored with `@xote.component`. The published Xote
257
+ library (`src/`, root `rescript.json`) stays PPX-free.
258
+
259
+ ## Run the example
260
+
261
+ The example is a standalone mini-project. The whole flow is wrapped in a single
262
+ script — from the repo root:
263
+
264
+ ```sh
265
+ npm run ppx:test # setup + build ppx + compile Demo.res + jsdom verify
266
+ ```
267
+
268
+ Or step by step from `example/`:
269
+
270
+ ```sh
271
+ sh setup.sh # link toolchain + Xote from the repo root (idempotent)
272
+ sh ../build.sh # build the ppx
273
+ npm run build # compile Demo.res through the ppx
274
+ npm run verify # jsdom runtime check (52 assertions)
275
+ ```
276
+
277
+ ## Known limitations (it's a prototype)
278
+
279
+ - **Signal detection is syntactic** (though alias- and helper-aware — see the
280
+ table above). It follows `let`/`module`/`open` aliases and *local* reactive
281
+ helpers, but not indirection it cannot see the definition of: a signal read
282
+ behind an **imported / cross-module** helper, or reached through a data
283
+ structure, is not detected. Such a value compiles to a **static, once-evaluated
284
+ attribute/text with no error** — the one genuinely silent failure. The escape
285
+ hatch is reliable: wrap it in `() =>` yourself and it becomes reactive (the
286
+ eager check leaves your thunk alone, so it is never double-wrapped). An
287
+ over-eager match only produces a harmless extra `computedAttr`.
288
+ - **Value-component detection is hard-coded** to the qualified
289
+ `View.Text/Int/Float/Bool`. An aliased or opened `View` (`module V = View` →
290
+ `<V.Text>`, `open View` → bare `<Text>`) is not recognized as a value
291
+ component. This is **not silent** — the value child lands in node position and
292
+ is a **compile error** (`string` where `View.node` is expected), so it fails
293
+ loudly at build time. Simplest fix: drop the wrapper and use a **bare `{…}`
294
+ child** (`View.child` coerces it), or use the qualified `View.Text` form.
295
+ - **Coupled to ReScript's ppx ABI.** The vendored OCaml 4.06 parsetree, the
296
+ `Caml1999M022` marshal magic, and the uncurried `Function$` construct name are
297
+ compiler internals. A ReScript release that bumps the ppx AST version fails
298
+ loudly (magic mismatch); one that renamed `Function$` could fail *quietly*.
299
+ Validated against ReScript 12; CI building the docs site through the PPX is the
300
+ canary on upgrade.
301
+ - **A branch swap still rebuilds that branch's subtree.** Control flow tracks
302
+ only the condition (leaves inside branches stay fine-grained, see above), but
303
+ when the condition *does* change, the selected branch is built fresh — there
304
+ is no keyed diffing between the old and new branch. This matches Xote's own
305
+ `View.Show`/`View.tracked`; use `View.For` with `by` for lists.
306
+ - **Not wired into the main build.** This lives outside `src/` and is not part
307
+ 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"