My aggregated monorepo of OCaml code, automaintained
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add implementation plan for widget/FRP bridge experiments

Six tasks covering: dependency install, shared view types, renderer,
Lwd counter (Exp A), Note counter (Exp B), and evaluation/results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+664
+664
docs/plans/2026-02-24-widget-frp-experiments-plan.md
··· 1 + # Widget/FRP Bridge Experiments — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Build two self-contained experiments (Lwd counter, Note counter) that evaluate FRP libraries for driving reactive widgets from an OCaml Web Worker, producing a comparison document. 6 + 7 + **Architecture:** Standalone dune project in `experiments/widget-bridge/`. Worker-side OCaml code uses an FRP library to derive a serializable view description from state. The view is sent to the main thread via `postMessage` (using `Marshal`). The main thread renders the view to DOM using Brr and sends user events back. 8 + 9 + **Tech Stack:** OCaml, js_of_ocaml 6.2, Brr 0.0.8, Lwd 0.4, Note 0.0.3, dune 3.21 10 + 11 + --- 12 + 13 + ## Task 1: Install dependencies and scaffold project 14 + 15 + **Files:** 16 + - Create: `experiments/widget-bridge/dune-project` 17 + 18 + **Step 1: Install lwd and note** 19 + 20 + Run: `opam install lwd note` 21 + 22 + Expected: Both install successfully. lwd 0.4 depends on seq. 23 + note 0.0.3 has minimal deps (ocaml >= 4.08). 24 + 25 + **Step 2: Verify installation** 26 + 27 + Run: `opam list lwd note` 28 + 29 + Expected: Both show as installed. 30 + 31 + **Step 3: Create dune-project** 32 + 33 + Create `experiments/widget-bridge/dune-project`: 34 + 35 + ```ocaml 36 + (lang dune 3.21) 37 + (name widget-bridge-experiments) 38 + ``` 39 + 40 + This is a standalone project — no opam file, no monorepo integration. 41 + 42 + **Step 4: Build to verify the project is recognised** 43 + 44 + Run: `opam exec -- dune build --root experiments/widget-bridge` 45 + 46 + Expected: Succeeds (nothing to build yet). 47 + 48 + **Step 5: Commit** 49 + 50 + ```bash 51 + git add experiments/widget-bridge/dune-project 52 + git commit -m "feat: scaffold widget-bridge experiments project" 53 + ``` 54 + 55 + --- 56 + 57 + ## Task 2: Implement shared view type library 58 + 59 + **Files:** 60 + - Create: `experiments/widget-bridge/lib/dune` 61 + - Create: `experiments/widget-bridge/lib/view.ml` 62 + - Create: `experiments/widget-bridge/lib/view.mli` 63 + 64 + **Step 1: Create lib/dune** 65 + 66 + ```ocaml 67 + (library 68 + (name view) 69 + (libraries)) 70 + ``` 71 + 72 + No dependencies — the view type is pure OCaml. 73 + 74 + **Step 2: Create lib/view.mli** 75 + 76 + ```ocaml 77 + (** Serializable view descriptions for the widget bridge. 78 + 79 + No closures, no JS references. Event handlers are symbolic 80 + identifiers resolved by the worker. *) 81 + 82 + type event_id = string 83 + 84 + type attr = 85 + | Property of string * string 86 + | Style of string * string 87 + | Class of string 88 + | Handler of string * event_id (** event name, handler id *) 89 + 90 + type node = 91 + | Text of string 92 + | Element of { tag : string; attrs : attr list; children : node list } 93 + 94 + type event_msg = { 95 + handler_id : event_id; 96 + event_type : string; 97 + value : string option; 98 + } 99 + 100 + (** Pretty-print a node tree (for evaluation/documentation). *) 101 + val pp_node : Format.formatter -> node -> unit 102 + ``` 103 + 104 + **Step 3: Create lib/view.ml** 105 + 106 + ```ocaml 107 + type event_id = string 108 + 109 + type attr = 110 + | Property of string * string 111 + | Style of string * string 112 + | Class of string 113 + | Handler of string * event_id 114 + 115 + type node = 116 + | Text of string 117 + | Element of { tag : string; attrs : attr list; children : node list } 118 + 119 + type event_msg = { 120 + handler_id : event_id; 121 + event_type : string; 122 + value : string option; 123 + } 124 + 125 + let pp_attr fmt = function 126 + | Property (k, v) -> Format.fprintf fmt "%s=%S" k v 127 + | Style (k, v) -> Format.fprintf fmt "style:%s=%S" k v 128 + | Class c -> Format.fprintf fmt ".%s" c 129 + | Handler (ev, id) -> Format.fprintf fmt "on:%s->%s" ev id 130 + 131 + let rec pp_node fmt = function 132 + | Text s -> Format.fprintf fmt "%S" s 133 + | Element { tag; attrs; children } -> 134 + Format.fprintf fmt "@[<v 2><%s" tag; 135 + List.iter (fun a -> Format.fprintf fmt " %a" pp_attr a) attrs; 136 + Format.fprintf fmt ">"; 137 + List.iter (fun c -> Format.fprintf fmt "@,%a" pp_node c) children; 138 + Format.fprintf fmt "@]@,</%s>" tag 139 + ``` 140 + 141 + **Step 4: Build** 142 + 143 + Run: `opam exec -- dune build --root experiments/widget-bridge` 144 + 145 + Expected: Compiles without errors. 146 + 147 + **Step 5: Commit** 148 + 149 + ```bash 150 + git add experiments/widget-bridge/lib/ 151 + git commit -m "feat: add shared view type library for widget experiments" 152 + ``` 153 + 154 + --- 155 + 156 + ## Task 3: Implement shared renderer 157 + 158 + The renderer runs on the main thread. It takes a `View.node`, builds 159 + DOM using Brr, and wires event handlers to call back to the worker. 160 + 161 + **Files:** 162 + - Create: `experiments/widget-bridge/renderer/dune` 163 + - Create: `experiments/widget-bridge/renderer/renderer.ml` 164 + - Create: `experiments/widget-bridge/renderer/renderer.mli` 165 + 166 + **Step 1: Create renderer/dune** 167 + 168 + ```ocaml 169 + (library 170 + (name renderer) 171 + (libraries view brr)) 172 + ``` 173 + 174 + **Step 2: Create renderer/renderer.mli** 175 + 176 + ```ocaml 177 + (** Render a {!View.node} tree to DOM and wire event handlers. 178 + 179 + Each call to {!render} produces a fresh DOM subtree. The caller is 180 + responsible for replacing the container's children. *) 181 + 182 + val render : on_event:(View.event_msg -> unit) -> View.node -> Brr.El.t 183 + ``` 184 + 185 + **Step 3: Create renderer/renderer.ml** 186 + 187 + ```ocaml 188 + open Brr 189 + 190 + let rec render ~on_event (node : View.node) : El.t = 191 + match node with 192 + | Text s -> 193 + El.txt (Jstr.v s) 194 + | Element { tag; attrs; children } -> 195 + let brr_attrs = List.filter_map (fun (a : View.attr) -> 196 + match a with 197 + | Property (k, v) -> Some (At.v (Jstr.v k) (Jstr.v v)) 198 + | Style (k, v) -> Some (At.v (Jstr.v "style") (Jstr.v (k ^ ":" ^ v))) 199 + | Class c -> Some (At.v At.Name.class' (Jstr.v c)) 200 + | Handler _ -> None 201 + ) attrs in 202 + let el = 203 + El.v (Jstr.v tag) ~at:brr_attrs 204 + (List.map (render ~on_event) children) 205 + in 206 + List.iter (fun (a : View.attr) -> 207 + match a with 208 + | Handler (event_name, handler_id) -> 209 + let target_el : Ev.target = El.as_target el in 210 + ignore @@ Ev.listen 211 + (Ev.Type.void (Jstr.v event_name)) 212 + (fun _ev -> 213 + on_event { View.handler_id; event_type = event_name; value = None }) 214 + target_el 215 + | _ -> () 216 + ) attrs; 217 + el 218 + ``` 219 + 220 + **Step 4: Build** 221 + 222 + Run: `opam exec -- dune build --root experiments/widget-bridge` 223 + 224 + Expected: Compiles. If `Ev.Type.void` doesn't exist, check Brr API 225 + and adjust — may need `Ev.Type.create (Jstr.v event_name)` instead. 226 + 227 + **Step 5: Commit** 228 + 229 + ```bash 230 + git add experiments/widget-bridge/renderer/ 231 + git commit -m "feat: add shared DOM renderer for widget experiments" 232 + ``` 233 + 234 + --- 235 + 236 + ## Task 4: Implement Lwd counter (Experiment A) 237 + 238 + **Files:** 239 + - Create: `experiments/widget-bridge/lwd_counter/dune` 240 + - Create: `experiments/widget-bridge/lwd_counter/worker.ml` 241 + - Create: `experiments/widget-bridge/lwd_counter/main.ml` 242 + - Create: `experiments/widget-bridge/html/lwd_counter.html` 243 + 244 + **Step 1: Create lwd_counter/dune** 245 + 246 + Two separate executables sharing the directory, each with explicit 247 + module lists: 248 + 249 + ```ocaml 250 + (executable 251 + (name worker) 252 + (modules worker) 253 + (modes js) 254 + (libraries view lwd js_of_ocaml)) 255 + 256 + (executable 257 + (name main) 258 + (modules main) 259 + (modes js) 260 + (libraries view renderer brr)) 261 + ``` 262 + 263 + **Step 2: Create lwd_counter/worker.ml** 264 + 265 + ```ocaml 266 + open View 267 + 268 + (* --- State --- *) 269 + 270 + let count = Lwd.var 0 271 + 272 + (* --- View derivation --- *) 273 + 274 + let view = 275 + Lwd.map (fun n -> 276 + Element { tag = "div"; attrs = [Class "counter"]; children = [ 277 + Element { tag = "button"; 278 + attrs = [Handler ("click", "dec")]; 279 + children = [Text "-"] }; 280 + Element { tag = "span"; 281 + attrs = [Style ("margin", "0 1em")]; 282 + children = [Text (string_of_int n)] }; 283 + Element { tag = "button"; 284 + attrs = [Handler ("click", "inc")]; 285 + children = [Text "+"] }; 286 + ] }) 287 + (Lwd.get count) 288 + 289 + let root = Lwd.observe view 290 + 291 + (* --- Worker loop --- *) 292 + 293 + let send_view () = 294 + let v = Lwd.sample root in 295 + Js_of_ocaml.Worker.post_message (Marshal.to_bytes (v : node) []) 296 + 297 + let handle_event (ev : event_msg) = 298 + (match ev.handler_id with 299 + | "inc" -> Lwd.set count (Lwd.peek count + 1) 300 + | "dec" -> Lwd.set count (Lwd.peek count - 1) 301 + | _ -> ()); 302 + send_view () 303 + 304 + let () = 305 + send_view (); 306 + Js_of_ocaml.Worker.set_onmessage (fun data -> 307 + let ev : event_msg = Marshal.from_bytes data 0 in 308 + handle_event ev) 309 + ``` 310 + 311 + **Step 3: Create lwd_counter/main.ml** 312 + 313 + ```ocaml 314 + open Brr 315 + 316 + let () = 317 + let container = 318 + match Document.find_el_by_id G.document (Jstr.v "app") with 319 + | Some el -> el 320 + | None -> failwith "No #app element found" 321 + in 322 + let worker = Brr_webworkers.Worker.create (Jstr.v "worker.js") in 323 + let on_event (ev : View.event_msg) = 324 + Brr_webworkers.Worker.post worker 325 + (Marshal.to_bytes ev []) 326 + in 327 + ignore @@ Ev.listen Brr_io.Message.Ev.message 328 + (fun msg -> 329 + let data : bytes = Brr_io.Message.Ev.data msg in 330 + let view : View.node = Marshal.from_bytes data 0 in 331 + let t0 = Performance.now_ms G.performance in 332 + let el = Renderer.render ~on_event view in 333 + El.set_children container [el]; 334 + let t1 = Performance.now_ms G.performance in 335 + Brr.Console.(log [str "Render:"; float (t1 -. t0); str "ms"])) 336 + (Brr_webworkers.Worker.as_target worker) 337 + ``` 338 + 339 + **Step 4: Create html/lwd_counter.html** 340 + 341 + ```html 342 + <!DOCTYPE html> 343 + <html> 344 + <head> 345 + <meta charset="utf-8"> 346 + <title>Experiment A: Lwd Counter</title> 347 + <style> 348 + body { font-family: system-ui, sans-serif; padding: 2rem; } 349 + .counter { display: flex; align-items: center; font-size: 1.5rem; } 350 + .counter button { font-size: 1.5rem; padding: 0.25em 0.75em; cursor: pointer; } 351 + </style> 352 + </head> 353 + <body> 354 + <h1>Experiment A: Lwd Counter</h1> 355 + <div id="app">Loading…</div> 356 + <script src="../_build/default/experiments/widget-bridge/lwd_counter/main.js"></script> 357 + </body> 358 + </html> 359 + ``` 360 + 361 + Note: The `<script>` path assumes serving from the repo root. The 362 + worker URL `worker.js` in main.ml is relative to the HTML file — this 363 + will need adjustment. We may need to serve from the `_build` directory 364 + or copy artifacts. 365 + 366 + **Step 5: Build** 367 + 368 + Run: `opam exec -- dune build --root experiments/widget-bridge` 369 + 370 + Expected: Produces `_build/default/experiments/widget-bridge/lwd_counter/worker.js` 371 + and `_build/default/experiments/widget-bridge/lwd_counter/main.js`. 372 + 373 + If build fails, likely causes: 374 + - `Ev.Type.void` may not exist — try `Ev.Type.create` or check 375 + `Brr.Ev.Type` API 376 + - `Marshal` may need `[]` flags or `[Marshal.No_sharing]` 377 + - `Brr_io.Message.Ev.data` may need type annotation or `Obj.magic` 378 + coercion if the bytes type doesn't match 379 + 380 + Fix any compilation errors before proceeding. 381 + 382 + **Step 6: Create a serve script and test manually** 383 + 384 + Create a simple HTML file that works with dune's output layout. The 385 + worker URL needs to be correct relative to where main.js loads. 386 + 387 + Approach: serve from `_build/default/experiments/widget-bridge/` and 388 + adjust paths. 389 + 390 + Update `html/lwd_counter.html` to use correct relative paths, then: 391 + 392 + Run: `cd _build/default/experiments/widget-bridge && python3 -m http.server 8000` 393 + 394 + Open `http://localhost:8000/html/lwd_counter.html` in a browser. 395 + 396 + Expected: Counter displays with + and - buttons. Clicking + increments, 397 + clicking - decrements. Console shows render timing. 398 + 399 + **Step 7: Commit** 400 + 401 + ```bash 402 + git add experiments/widget-bridge/lwd_counter/ experiments/widget-bridge/html/lwd_counter.html 403 + git commit -m "feat: Experiment A — Lwd counter with worker bridge" 404 + ``` 405 + 406 + --- 407 + 408 + ## Task 5: Implement Note counter (Experiment B) 409 + 410 + **Files:** 411 + - Create: `experiments/widget-bridge/note_counter/dune` 412 + - Create: `experiments/widget-bridge/note_counter/worker.ml` 413 + - Create: `experiments/widget-bridge/note_counter/main.ml` 414 + - Create: `experiments/widget-bridge/html/note_counter.html` 415 + 416 + **Step 1: Create note_counter/dune** 417 + 418 + ```ocaml 419 + (executable 420 + (name worker) 421 + (modules worker) 422 + (modes js) 423 + (libraries view note js_of_ocaml)) 424 + 425 + (executable 426 + (name main) 427 + (modules main) 428 + (modes js) 429 + (libraries view renderer brr)) 430 + ``` 431 + 432 + **Step 2: Create note_counter/worker.ml** 433 + 434 + ```ocaml 435 + open View 436 + 437 + (* --- Event sources --- *) 438 + 439 + let inc_e, send_inc = Note.E.create () 440 + let dec_e, send_dec = Note.E.create () 441 + 442 + (* --- Reactive state --- *) 443 + 444 + let count = 445 + let delta = Note.E.select [ 446 + Note.E.map (fun () n -> n + 1) inc_e; 447 + Note.E.map (fun () n -> n - 1) dec_e; 448 + ] in 449 + Note.S.accum 0 delta 450 + 451 + (* --- View derivation --- *) 452 + 453 + let view = 454 + Note.S.map (fun n -> 455 + Element { tag = "div"; attrs = [Class "counter"]; children = [ 456 + Element { tag = "button"; 457 + attrs = [Handler ("click", "dec")]; 458 + children = [Text "-"] }; 459 + Element { tag = "span"; 460 + attrs = [Style ("margin", "0 1em")]; 461 + children = [Text (string_of_int n)] }; 462 + Element { tag = "button"; 463 + attrs = [Handler ("click", "inc")]; 464 + children = [Text "+"] }; 465 + ] }) 466 + count 467 + 468 + (* --- Worker loop --- *) 469 + 470 + let send_view (v : node) = 471 + Js_of_ocaml.Worker.post_message (Marshal.to_bytes v []) 472 + 473 + let () = 474 + (* Observe view signal and send updates *) 475 + let logr = Note.S.log view send_view in 476 + Note.Logr.hold logr; 477 + (* Send initial view *) 478 + send_view (Note.S.value view); 479 + (* Handle incoming events *) 480 + Js_of_ocaml.Worker.set_onmessage (fun data -> 481 + let ev : event_msg = Marshal.from_bytes data 0 in 482 + match ev.handler_id with 483 + | "inc" -> send_inc () 484 + | "dec" -> send_dec () 485 + | _ -> ()) 486 + ``` 487 + 488 + **Step 3: Create note_counter/main.ml** 489 + 490 + This is identical to the Lwd version — same renderer, same protocol. 491 + Copy `lwd_counter/main.ml` and change only the worker URL if needed. 492 + 493 + ```ocaml 494 + open Brr 495 + 496 + let () = 497 + let container = 498 + match Document.find_el_by_id G.document (Jstr.v "app") with 499 + | Some el -> el 500 + | None -> failwith "No #app element found" 501 + in 502 + let worker = Brr_webworkers.Worker.create (Jstr.v "worker.js") in 503 + let on_event (ev : View.event_msg) = 504 + Brr_webworkers.Worker.post worker 505 + (Marshal.to_bytes ev []) 506 + in 507 + ignore @@ Ev.listen Brr_io.Message.Ev.message 508 + (fun msg -> 509 + let data : bytes = Brr_io.Message.Ev.data msg in 510 + let view : View.node = Marshal.from_bytes data 0 in 511 + let t0 = Performance.now_ms G.performance in 512 + let el = Renderer.render ~on_event view in 513 + El.set_children container [el]; 514 + let t1 = Performance.now_ms G.performance in 515 + Brr.Console.(log [str "Render:"; float (t1 -. t0); str "ms"])) 516 + (Brr_webworkers.Worker.as_target worker) 517 + ``` 518 + 519 + **Step 4: Create html/note_counter.html** 520 + 521 + ```html 522 + <!DOCTYPE html> 523 + <html> 524 + <head> 525 + <meta charset="utf-8"> 526 + <title>Experiment B: Note Counter</title> 527 + <style> 528 + body { font-family: system-ui, sans-serif; padding: 2rem; } 529 + .counter { display: flex; align-items: center; font-size: 1.5rem; } 530 + .counter button { font-size: 1.5rem; padding: 0.25em 0.75em; cursor: pointer; } 531 + </style> 532 + </head> 533 + <body> 534 + <h1>Experiment B: Note Counter</h1> 535 + <div id="app">Loading…</div> 536 + <script src="../note_counter/main.js"></script> 537 + </body> 538 + </html> 539 + ``` 540 + 541 + **Step 5: Build** 542 + 543 + Run: `opam exec -- dune build --root experiments/widget-bridge` 544 + 545 + Expected: Produces `note_counter/worker.js` and `note_counter/main.js` 546 + in `_build/default/experiments/widget-bridge/`. 547 + 548 + **Step 6: Test manually** 549 + 550 + Serve and test in browser as with Experiment A. Verify same counter 551 + behaviour. 552 + 553 + **Step 7: Commit** 554 + 555 + ```bash 556 + git add experiments/widget-bridge/note_counter/ experiments/widget-bridge/html/note_counter.html 557 + git commit -m "feat: Experiment B — Note counter with worker bridge" 558 + ``` 559 + 560 + --- 561 + 562 + ## Task 6: Evaluate and document results 563 + 564 + **Files:** 565 + - Create: `experiments/widget-bridge/RESULTS.md` 566 + 567 + **Step 1: Measure bundle sizes** 568 + 569 + Run: 570 + ```bash 571 + ls -lh _build/default/experiments/widget-bridge/lwd_counter/worker.js 572 + ls -lh _build/default/experiments/widget-bridge/note_counter/worker.js 573 + ls -lh _build/default/experiments/widget-bridge/lwd_counter/main.js 574 + ls -lh _build/default/experiments/widget-bridge/note_counter/main.js 575 + ``` 576 + 577 + **Step 2: Count lines of code** 578 + 579 + Run: 580 + ```bash 581 + wc -l experiments/widget-bridge/lwd_counter/worker.ml 582 + wc -l experiments/widget-bridge/note_counter/worker.ml 583 + ``` 584 + 585 + **Step 3: Capture sample view output** 586 + 587 + Add a temporary `Printf.eprintf "%s\n" (Format.asprintf "%a" View.pp_node v)` 588 + to each worker, or use the pretty-printer in a small test script, to 589 + capture the view tree for one state. Include in RESULTS.md. 590 + 591 + **Step 4: Write RESULTS.md** 592 + 593 + Document all measurements and subjective ergonomics comparison in a 594 + table following this structure: 595 + 596 + ```markdown 597 + # Widget/FRP Bridge — Experiment Results 598 + 599 + ## Measurements 600 + 601 + | Metric | Lwd (Exp A) | Note (Exp B) | 602 + |--------|-------------|--------------| 603 + | Worker lines of OCaml | N | N | 604 + | Worker JS bundle size | N KB | N KB | 605 + | Main JS bundle size | N KB | N KB | 606 + | Render latency (median) | N ms | N ms | 607 + 608 + ## Ergonomics 609 + 610 + ### Lwd 611 + - ... 612 + 613 + ### Note 614 + - ... 615 + 616 + ## View Description (sample JSON) 617 + ... 618 + 619 + ## Recommendation 620 + ... 621 + ``` 622 + 623 + **Step 5: Commit** 624 + 625 + ```bash 626 + git add experiments/widget-bridge/RESULTS.md 627 + git commit -m "docs: add widget/FRP experiment results" 628 + ``` 629 + 630 + --- 631 + 632 + ## Troubleshooting 633 + 634 + ### Marshal fails across postMessage 635 + 636 + If `Marshal.from_bytes` raises on the receiving side, the structured 637 + clone algorithm may not preserve the bytes correctly. Fix: switch to 638 + manual JSON encoding using `Jv.obj` (Brr) on the sending side and 639 + `Jv.get` on the receiving side. This means replacing `Marshal` with 640 + custom `view_to_jv` / `view_of_jv` functions. 641 + 642 + ### Brr API differences 643 + 644 + If `Ev.Type.void`, `Performance.now_ms`, or similar don't match, 645 + check `$(opam var lib)/brr/brr.mli` for exact signatures. The Brr 646 + API may use `Jstr.t` where we used `string` — wrap with `Jstr.v`. 647 + 648 + ### Worker URL resolution 649 + 650 + The worker script URL in `Worker.create (Jstr.v "worker.js")` is 651 + resolved relative to the HTML page. If serving from a different 652 + directory, adjust the path or use an absolute URL. 653 + 654 + ### Lwd.observe memory 655 + 656 + Always call `Lwd.release root` when done. For the experiment this 657 + doesn't matter (the worker runs for the page lifetime), but note it 658 + for production use. 659 + 660 + ### Note.Logr.hold 661 + 662 + Note loggers are garbage-collected unless held. Always call 663 + `Note.Logr.hold` on loggers you want to keep alive. Forgetting this 664 + causes silent failures where signal updates stop propagating.