Skip to content

makerprism/solid-ml

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

246 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

solid-ml

An OCaml framework for building reactive web applications with server-side rendering (SSR), inspired by SolidJS.

Status: Discontinued. The experiment is winding down — the framework kind of works (tests pass, SSR and hydration operate), but there are likely bugs still lurking, and practical application development proved too high-friction to justify continued investment. The repository remains public as a reference. See the blog posts for the full story.

Background

solid-ml was an experiment in using AI agents to port the architectural philosophy of SolidJS — fine-grained reactivity, no Virtual DOM, high-performance hydration — to OCaml. Development followed a human-AI partnership structure, with a human "Governor" managing architecture while LLMs (Claude and GPT) handled code generation over an 18-day sprint.

What worked well: the MLX template language feels natural if you've used JSX, and the compiler catches mismatched tags. The core reactivity engine is solid.

What didn't: targeting full SSR with hydration introduced substantial complexity (hydration markers, isomorphic module interfaces, template compilation requiring identical output across environments) that proved difficult to maintain and debug in an AI-generated codebase.

Quick Start

open Solid_ml_server

let () =
  Runtime.run (fun () ->
    let dispose =
      Owner.create_root (fun () ->
        (* Create a signal (reactive value) *)
        let count, set_count = Signal.create 0 in

        (* Create a memo (derived value) *)
        let doubled = Memo.create (fun () ->
          Signal.get count * 2
        ) in

        (* Create an effect (side effect that re-runs when dependencies change) *)
        Effect.create (fun () ->
          Printf.printf "Count: %d, Doubled: %d\n"
            (Signal.get count)
            (Memo.get doubled)
        );

        (* Update the signal - effect automatically re-runs *)
        set_count 1;  (* prints: Count:1, Doubled: 2 *)
        set_count 2   (* prints: Count:2, Doubled: 4 *)
      )
    in
    dispose ()
  )

Install (Dune package management)

Add one of the following to your project's dune-project:

; Full stack + MLX (umbrella)
(depends
 (solid-ml (>= 0.1.0)))
; Server-only
(depends
 (solid-ml-server (>= 0.1.0))
 (solid-ml-ssr (>= 0.1.0)))
; Browser-only
(depends
 (solid-ml-browser (>= 0.1.0)))
; Routing (SSR-aware)
(depends
 (solid-ml-router (>= 0.1.0)))

Core API

Runtime Context

Reactive primitives bind to the current owner/runtime if present. On the server, solid-ml-server does not create an implicit runtime: you must call Runtime.run per request (or use SSR helpers that do it for you) to keep state isolated.

(* Create isolated reactive context (recommended for SSR per-request) *)
Runtime.run (fun () ->
  let _count, _set_count = Signal.create 0 in
  ()
)

SSR helpers like Solid_ml_ssr.Render.to_string create and dispose a runtime internally.

Signals

(* Create a signal with initial value (uses structural equality by default) *)
let count, set_count = Signal.create 0

(* Create with physical equality (for mutable values) *)
let buffer, set_buffer = Signal.create_physical (Bytes.create 100)

(* Create with custom equality *)
let items, set_items = Signal.create_eq
  ~equals:(fun a b -> List.length a = List.length b) 
  []
(* Read value (tracks dependency in effects/memos) *)
let value = Signal.get count

(* Read without tracking *)
let value = Signal.peek count

(* Update value - only notifies if value changed *)
Signal.set count 42
Signal.update count (fun n -> n + 1)

Effects

(* Effect re-runs when any signal it reads changes *)
Effect.create (fun () ->
  print_endline (string_of_int (Signal.get count))
)

(* Effect with cleanup *)
Effect.create_with_cleanup (fun () ->
  let subscription = subscribe_something () in
  fun () -> unsubscribe subscription
)

(* Read signal without tracking *)
let value = Effect.untrack (fun () -> Signal.get some_signal)

Memos

(* Memo caches derived value, only recomputes when deps change *)
let doubled = Memo.create (fun () ->
  Signal.get count * 2
)

(* Read memo like a signal *)
let value = Memo.get doubled

Batch

(* Batch multiple updates, effects run once at end *)
Batch.run (fun () ->
  Signal.set first_name "John";
  Signal.set last_name "Doe"
)

Owner (Cleanup/Disposal)

(* Create a root that owns effects - dispose cleans everything up *)
let dispose = Owner.create_root (fun () ->
  Effect.create (fun () -> ...)
) in
dispose ()  (* All effects inside are disposed *)

(* Register cleanup with current owner *)
Owner.on_cleanup (fun () ->
  print_endline "Cleaning up!"
)

Context

(* Create a context with default value *)
let theme_context = Context.create "light"

(* Provide value to descendants *)
Context.provide theme_context "dark" (fun () ->
  (* Code here sees "dark" *)
  let theme = Context.use theme_context in
  ...
)
)

Router (SSR-aware)

Router lives in solid-ml-router with SSR-aware components and loaders. See examples/README.md for runnable demos.

Building

# Build all packages
dune build

# Run tests
dune runtest

Requirements

  • OCaml 5.0+ (uses Domain-local storage for thread safety)
  • dune 3.16+ with Melange support ((using melange 0.1))
  • For browser builds: Node.js (for esbuild bundling)
  • For web server examples: Dream (not included - see examples for reference code)

MLX Interpolation Direction

We currently optimize MLX DX around ergonomic (...) interpolation and avoid adopting {...} syntax in this repository.

SolidJS Notes

Similarities:

  • Signals/effects/memos with automatic dependency tracking
  • Batch updates via Batch.run
  • Suspense boundaries and error boundaries

Differences:

  • Effects run synchronously by default (browser can opt into microtask deferral)
  • Memos use structural equality by default (override with ~equals)
  • No concurrent rendering or transitions
  • solid-ml-browser can create an implicit runtime; solid-ml-server requires Runtime.run per request. SolidJS SSR creates a new root per render/request.

Practical guidance:

  • For server code, always create a runtime per request using Runtime.run, or use SSR helpers like Solid_ml_ssr.Render.to_string/to_document which create and dispose a runtime for you.
  • Avoid creating signals at top level in server code; they now raise because no runtime is active.

Blog Posts

Docs

About

An OCaml framework for building reactive web applications with server-side rendering (SSR), inspired by SolidJS

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages