diff --git a/README.md b/README.md index 47e5256..a16902f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,20 @@ [![Package Version](https://img.shields.io/hexpm/v/off_topic)](https://hex.pm/packages/off_topic) [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/off_topic/) -Declarative subscriptions (like Vues `watch` or Reacts `useEffect`) for [Lustre](https://hexdocs.pm/lustre/). Subscribe to browser events — keyboard, pointer, window size, page visibility, WebSockets, and more — without writing any FFI. +This is an experimtal (in design not implementation) [Lustre](https://lustre.build) runtime extension, +adding so-called subscriptions. Subscriptions are at their core effects with a +cleanup function. If you know React, Vue, or Svelte, you can think of them as +`useEffect` or `watch` calls. `off_topic` lets you define your active subscriptions +in a declarative way, using a familiar diffing mechanism to figure out which effects +to start, and which cleanup functions to run. + +It also comes with a comprehensive library of built-in effects and subscriptions and +a vast collection of little demo apps showing how to work with them. Built-in +subscriptions range from simple timers to browser events to SSE and WebSockets. + +Almost all subscriptions and effects in `off_topic` work both with client _and_ server- +components using a small (~3kb min+gzip), extensible runtime custom-element +in the browser. ```sh gleam add off_topic @@ -17,7 +30,7 @@ subscriptions and stopping removed ones automatically. ```gleam import lustre -import off_topic +import off_topic.{type Subscription} pub fn main() { let app = off_topic.application(init:, update:, subscriptions:, view:) @@ -25,14 +38,14 @@ pub fn main() { Nil } -fn subscriptions(model: Model) -> off_topic.Subscription(Msg) { +fn subscriptions(model: Model) -> Subscription(Msg) { off_topic.batch([ // always listen for the page state / visibility off_topic.page_state(PageStateChanged), case model.page_state { // while the page is focused, run a 1 second interval off_topic.Active -> - off_topic.every(every: duration.seconds(1), immediate: False, on_elapsed: Ticked) + off_topic.every(every: duration.seconds(1), immediate: False, on_elapsed: Tick) // when the page is no longer active, returning off_topic.none() // causes the runtime to clean up the interval automatically. _ -> off_topic.none() @@ -41,16 +54,10 @@ fn subscriptions(model: Model) -> off_topic.Subscription(Msg) { } ``` -A subscription is an effect with cleanup — a setup function that returns a -teardown function. off_topic diffs the subscription tree returned by each -`update` call the same way Lustre diffs elements: subscriptions that -disappeared are torn down, new ones are started, and unchanged ones are left -alone. +The module documentation contains many more examples and their source code for you +to play with! -For Lustre server components, off_topic provides a small client-side bridge (3kb min+gzip) -that runs subscriptions in the browser and forwards events to the server. -Almost every built-in subscription works on both sides -without any changes to your code. +There's also some guides on the left that go into more detail. ## Documentation diff --git a/gleam.toml b/gleam.toml index 4012fd3..a142def 100644 --- a/gleam.toml +++ b/gleam.toml @@ -16,12 +16,8 @@ target = "javascript" [documentation] pages = [ { title = "Quickstart", path = "guide/quickstart.html", source = "./guides/quickstart.md" }, - # { title = "Subscriptions from scratch", path = "guide/01-subscriptions-from-scratch.html", source = "./guides/01-subscriptions-from-scratch.md" }, - # { title = "Listening for events", path = "guide/02-listening-for-events.html", source = "./guides/02-listening-for-events.md" }, - # { title = "Commands", path = "guide/03-commands.html", source = "./guides/03-commands.md" }, - # { title = "Extending the ot-client runtime", path = "guide/04-extending-the-runtime.html", source = "./guides/04-extending-the-runtime.md" }, - # { title = "", path = "js/demo.js", source = "demos/dist/component.js" }, - # { title = "", path = "css/demo.css", source = "demos/dist/component.css" } + { title = "Subscriptions from scratch", path = "guide/subscriptions-from-scratch.html", source = "./guides/subscriptions-from-scratch.md" }, + { title = "Server components", path = "guide/server-components.html", source = "./guides/server-components.md" } ] [dependencies] diff --git a/guides/quickstart.md b/guides/quickstart.md index 8609236..6ffda33 100644 --- a/guides/quickstart.md +++ b/guides/quickstart.md @@ -10,7 +10,7 @@ any FFI. Add off_topic to your Gleam project: ```sh -gleam add off_topic +gleam add lustre off_topic ``` off_topic targets JavaScript, so make sure your `gleam.toml` has `target = @@ -22,6 +22,10 @@ off_topic targets JavaScript, so make sure your `gleam.toml` has `target = + target = "javascript" ``` +You should already have this line in your `gleam.toml` file! +If you don't, I recommend you to first go through the [Lustre Quickstart Guide](https://hexdocs.pm/lustre/guide/01-quickstart.html) +before continuing here! + ## Wiring it up The entry point is `off_topic.application` — a drop-in replacement for @@ -64,6 +68,7 @@ state. First, the model and messages: ```gleam +import gleam/int import gleam/time/duration import gleam/time/timestamp.{type Timestamp} import lustre/effect.{type Effect} @@ -96,12 +101,13 @@ Now the subscriptions function: fn subscriptions(model: Model) -> Subscription(Msg) { let timer = case model.page_state { off_topic.Active -> - off_topic.every(every: duration.seconds(1), on_elapsed: Ticked) + off_topic.every(every: duration.seconds(1), immediate: False, on_elapsed: Ticked) _ -> off_topic.none() } off_topic.batch([ off_topic.page_state(PageStateChanged), + off_topic.title("count: " <> int.to_string(model.count)), timer, ]) } @@ -115,58 +121,75 @@ page state immediately when it starts, then again whenever the state changes. `off_topic.every` is an *event* subscription: it fires only on each timer tick, never immediately on start. +`off_topic.title` is a *command* subscription: it watches its parameters for +changes, running a side-effect whenever they do. + Because `subscriptions` is called after every `update`, the timer starts and stops automatically as `model.page_state` changes: when the tab is hidden the next call returns `none()` for the timer and off_topic stops it; when the tab becomes active again, the timer starts afresh. -## Observable values and transient events +## Components -off_topic subscriptions come in two flavours, and their names reflect the -difference. - -**Observable values** have a "current state" the browser holds persistently — the -window size, whether the device prefers dark mode, whether the page is visible. -Their subscriptions dispatch the current value immediately on start, then again on -each change. Their names are the value itself: +If you're building a Lustre custom element, replace `lustre.component` with +`off_topic.component`. The signature is identical except for the added +`subscriptions` callback: ```gleam -off_topic.window_size(GotWindowSize) // dispatches immediately + on resize -off_topic.color_scheme(GotColorScheme) // dispatches immediately + on change -off_topic.online(GotOnlineStatus) // dispatches immediately + on change +import off_topic + +pub fn register() { + off_topic.component(init:, update:, subscriptions:, view:, options: []) +} ``` -**Transient events** happen at a point in time. Their subscriptions only fire when -the event occurs. Their names start with `on_`: +Also swap your import of `lustre/component` for `off_topic/component`. It is a +drop-in replacement — every builder function (`on_attribute_change`, `on_connect`, +`on_disconnect`, and so on) is re-exported unchanged. -```gleam -off_topic.on_click(UserClicked) -off_topic.on_key_down(UserPressedKey) -off_topic.on_pointer_move(MouseMoved) +```diff +- import lustre/component ++ import off_topic/component ``` -## Slowing things down +**Subscriptions follow the client lifecycle.** off_topic starts subscriptions when +the first client connects to the component and stops them when the last one +disconnects. While no clients are connected, no subscriptions are running. This +lifecycle is specific to `off_topic.component` — it does not apply when using +`off_topic.application`. -Some events fire very rapidly. Pointer-move events, for example, can arrive -hundreds of times per second. off_topic lets you rate-limit any subscription with -`throttle` or `delay`: -```gleam -off_topic.on_pointer_move(MouseMoved) -|> off_topic.throttle(wait: duration.milliseconds(50)) -``` +Be aware that `on_disconnect` — and therefore subscription cleanup — is not +guaranteed. If the browser closes the tab abruptly, the disconnect callback may +never fire. + +## How subscriptions are started and stopped + +After every `update`, off_topic calls `subscriptions` with the new model and +compares the result to the previous one. -`throttle` passes the first event through immediately, then drops the rest until -`wait` has elapsed. `delay` holds the last event in a burst and dispatches it only -once the subscription has been quiet for `wait` — useful for search-as-you-type: +Subscriptions are matched **by position** within their batch. The first child is +compared to the first child from last time, the second to the second, and so on. +This means the shape of your batch should stay stable across updates. +When a subscription goes inactive, return `none()` in its slot rather than removing it. The timer in the example above already does this: it is always present in the batch, +either as an active `every(…)` or as `none()`. + +Once matched by position, a subscription is kept running if its **dependencies** +haven't changed. Built-in subscriptions derive their dependencies from their own +parameters — the duration, the message constructor, and so on. Change any of those +and off_topic stops the old one and starts a fresh one. + +Sometimes a subscription's behaviour depends on model state that isn't captured in +its own parameters. `watching` lets you declare those extra dependencies: ```gleam -off_topic.on_key_up(SearchQueryChanged) -|> off_topic.delay(wait: duration.milliseconds(300)) +off_topic.on_pointer_move(PointerMoved) +|> off_topic.watching([off_topic.dep(model.dragging)]) ``` -You can combine both: `throttle` handles the running stream while `delay` catches -the final value after the burst ends. +This restarts the subscription whenever `model.dragging` changes. Without +`watching`, the subscription's own parameters haven't changed, so off_topic has no +reason to restart it. ## Where next @@ -174,16 +197,9 @@ The [API reference](https://hexdocs.pm/off_topic/off_topic.html) lists every function with its type signature and a short description. The guides cover the topics this one skips: -- [Subscriptions from scratch](./01-subscriptions-from-scratch.html) — build - your own subscriptions with `from`, `element`, and `resource`. Understand how - dependencies control restart behaviour and how the server-component path works. - -- [Listening for events](./02-listening-for-events.html) — the full set of built-in - `on_*` event subscriptions and when to reach for `on(target, event, decoder)` - directly to handle events off_topic doesn't cover out of the box. - -- [Commands](./03-commands.html) — the built-in commands for storage, scroll, - and focus, how they work in server-component apps, and how to write your own. +- [Subscriptions from scratch](./subscriptions-from-scratch.html) — build + your own subscriptions with `from`, `element`, and `resource`. -- [Extending the ot-client runtime](./04-extending-the-runtime.html) — how to - register custom subscription and command handlers in `window.OffTopic`. +- [Using off_topic with Server Components](./server-components.html) — how + off_topic can be used with server-components and how it makes + browser events available on the server. diff --git a/guides/server-components.md b/guides/server-components.md new file mode 100644 index 0000000..a2f0db9 --- /dev/null +++ b/guides/server-components.md @@ -0,0 +1,162 @@ +# Server components + +off_topic works with Lustre server components — apps whose Gleam logic runs on +the BEAM but whose UI lives in the browser. + +## How it works + +When your app runs as a server component, Gleam runs on the server and the +browser renders a `` element. off_topic adds a second +custom element — `ot-client-runtime` — alongside it. This element is the bridge: +it receives subscription start and stop instructions from the server, runs the +corresponding JavaScript handlers in the browser, and sends dispatched values +back over the wire. + +Almost all built-in subscriptions work in server components. Each one has two +paths internally: a direct browser path for SPAs, and a delegated path that runs +through the client runtime when the app is on the BEAM. The only exceptions are +the ones marked `JS` in the API reference (e.g. Websockets and Server-sent evnets) +which only have a browser path. + +## Setting up + +Use `off_topic.application` or `off_topic.component` as you normally would. +On the server target, the `ot-client-runtime` element is injected into your +`view` function automatically. You still need to load the client runtime script +in the host page. Serve `priv/static/off-topic.mjs` (or the minified variant) +from your web server and include it as a module script: + +```html + +``` + +Alternatively, `off_topic.script()` returns the runtime as an inline `