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/README.md +19 -0
- package/dist/{RuntimeJsxProp.res-A6N-qWoK.js → RuntimeJsxProp.res-gr8AK5hj.js} +1 -1
- package/dist/{RuntimeJsxProp.res-D3W5GXqT.mjs → RuntimeJsxProp.res-k7-PNxKK.mjs} +1 -1
- package/dist/{View.res-SuqJrQPC.mjs → View.res-3LSynUOy.mjs} +260 -207
- package/dist/View.res-DhZkE_sC.js +1 -0
- package/dist/client.cjs +1 -1
- package/dist/client.mjs +2 -2
- package/dist/hydration.cjs +1 -1
- package/dist/hydration.mjs +1 -1
- package/dist/mdx.cjs +1 -1
- package/dist/mdx.mjs +1 -1
- package/dist/router.cjs +1 -1
- package/dist/router.mjs +2 -2
- package/dist/xote.cjs +1 -1
- package/dist/xote.mjs +397 -344
- package/dist/xote.umd.js +1 -1
- package/package.json +9 -3
- package/ppx/LICENSE.OCaml +548 -0
- package/ppx/README.md +308 -0
- package/ppx/build.sh +10 -0
- package/ppx/ppx.ml +1363 -0
- package/src/View.res +69 -0
- package/src/View.res.mjs +68 -0
- package/dist/View.res-C36--SJD.js +0 -1
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"
|