Files
php-qml/docs/reactive-models.md

164 lines
7.2 KiB
Markdown
Raw Normal View History

# Reactive models
The QML side has two reactive primitives that wrap a Mercure-fed REST endpoint into a Qt model: `ReactiveListModel` for collections, `ReactiveObject` for single entities. They are what makes the headline `make:bridge:resource` workflow turn into a live UI without any handwritten cross-side glue.
For the wire-level details — dual-publish topics, version counter, `correlationKey` — see [Update semantics](update-semantics.md).
## `ReactiveListModel`
`QAbstractListModel` subclass that:
1. Issues a `GET <source>` once `BackendConnection.connectionState === Online`.
2. Subscribes to `<topic>` over Mercure SSE.
3. Applies events to the local rows.
4. Exposes a `pending` role for optimistic mutations.
```qml
import PhpQml.Bridge
ReactiveListModel {
id: todoModel
baseUrl: BackendConnection.url
token: BackendConnection.token
source: "/api/todos" // initial GET + mutation endpoint
topic: "app://model/todo" // collection Mercure topic
}
ListView {
model: todoModel
delegate: ItemDelegate {
required property string id
required property string title
required property bool done
required property bool pending // ← provided by the model
opacity: pending ? 0.5 : 1.0
// …
}
}
```
### Properties
| Property | Type | Notes |
| --- | --- | --- |
| `baseUrl` | string | Usually `BackendConnection.url`. |
| `token` | string | Bearer token. Reads `BackendConnection.token`. |
| `source` | string | REST path, e.g. `/api/todos`. Initial GET hits this; mutations append `/<id>`. |
| `topic` | string | Mercure topic. Convention: `app://model/<resource-name>`. |
| `idField` | string | Defaults to `id`. Override if your resource keys on `slug`, `uuid`, etc. |
| `ready` | bool | `true` once the initial GET has populated the model. Useful for empty-state UI. |
### Roles
Every entity in the response becomes a row. Each entity field becomes a role of the same name. On top of that the model adds:
- **`pending`** — `true` while an optimistic mutation against this row is in flight.
### Methods
```qml
todoModel.invoke(method, urlSuffix, body, optimistic)
```
| Arg | Description |
| --- | --- |
| `method` | `"POST"`, `"PATCH"`, `"DELETE"`, … |
| `urlSuffix` | Appended to `source`. Pass `""` for collection-level mutations (e.g. POST), `"/" + id` for entity-level. |
| `body` | JS object — serialised as JSON. Pass `null` for verbs without a body. |
| `optimistic` | `{ op: "upsert"|"delete", id, data? }` — the local patch applied before the HTTP call returns. |
`invoke()` mints a uuidv7 `Idempotency-Key` and returns it. The header round-trips into Mercure as `correlationKey` so the model can reconcile its in-flight optimistic patch against the server echo.
### Signals
| Signal | Fired on |
| --- | --- |
| `commandFailed(key, status, problem)` | HTTP failure. Local optimistic patch already rolled back; `problem` is the parsed Symfony Problem-Details JSON if available. |
| `commandTimedOut(key)` | HTTP succeeded but Mercure echo never arrived within 5 s. Model has re-fetched. |
### Event handling
When a Mercure event lands on `<topic>`:
```json
{ "op": "upsert", "id": "01HX…", "data": { "title": "buy milk", "done": false }, "correlationKey": "…", "version": 42 }
```
- `upsert` — find by `idField`, update existing row or append a new one.
- `delete` — find by `idField`, remove the row.
- `correlationKey` matches an in-flight `Idempotency-Key` → reconcile + clear `pending`.
- `version` skips by more than 1 → drop the model and re-fetch.
`op` and `data` shape are intentionally identical between the events you receive and the optimistic patches you supply, so adding new mutation types is mostly a backend concern.
## `ReactiveObject`
Single-entity twin. Wraps a `QQmlPropertyMap` so QML accesses fields as plain properties.
```qml
ReactiveObject {
id: currentTodo
baseUrl: BackendConnection.url
token: BackendConnection.token
source: "/api/todos/" + selectedId // initial GET
topic: "app://model/todo/" + selectedId
}
Label { text: currentTodo.fields.title || "(loading)" }
CheckBox { checked: currentTodo.fields.done || false }
```
### Properties
| Property | Type | Notes |
| --- | --- | --- |
| `baseUrl` / `token` / `source` / `topic` | as above | But `source` is the entity URL (`/api/todos/<id>`) and `topic` is the entity topic (`app://model/todo/<id>`). |
| `fields` | `QQmlPropertyMap` | Dynamic — fields appear under their JSON names. |
| `ready` | bool | Initial GET completed. |
| `pending` | bool | Optimistic mutation in flight. |
### Methods
```qml
currentTodo.patch({ done: true })
currentTodo.remove()
```
Both behave like `ReactiveListModel.invoke()` — optimistic local mutation, HTTP request, Mercure reconciliation.
## Choosing between them
- **List view, table, grid** — `ReactiveListModel`.
- **Detail view of one entity** — `ReactiveObject`. Doesn't pull the entire collection over the wire.
- **Both at once** (master/detail) — instantiate one of each. They use *different* Mercure topics, so a list update doesn't churn the detail subscription and vice-versa. The dual-publish is exactly to avoid this trade-off.
If your detail view shows fields that aren't in the list payload (e.g. a heavy `description` field), `ReactiveObject`'s entity-topic subscription gets the full record on every change without affecting list-view subscribers.
## Mercure dual-publish
Every `#[BridgeResource]` mutation publishes to two topics. From the PHP side this is invisible — `ModelPublisher::publishUpdate($entity)` dispatches both events with the same payload, the same `correlationKey`, and a shared incremented `version`.
```
postPersist | postUpdate | postRemove
ModelPublisher::publishUpdate
├──▶ Mercure: app://model/<name> (collection event)
└──▶ Mercure: app://model/<name>/<id> (entity event)
```
Why both? A `ReactiveListModel` shouldn't process events about other resources, and a `ReactiveObject` shouldn't process events about *other instances* of its resource. Filtering at subscribe time is what Mercure topics are for; the cost is one extra publish per mutation.
## Anti-patterns
- **Driving the model from a custom mutation path that bypasses `invoke()`.** You lose `pending`, `correlationKey` reconciliation, and rollback. If your HTTP call doesn't fit the `invoke()` shape, do the call manually but don't mutate the model directly — let the Mercure echo do it.
- **Reusing one `ReactiveListModel` across windows.** Each instance owns its Mercure subscription; sharing means a closed window's subscription stays alive until the host exits. Spawn one per window; they auto-cohere via the server.
- **Sub-classing the model in QML to add fields.** Don't — the model is dynamic. Add the field to the entity, regenerate the migration, and the field appears as a role automatically.
## See also
- [Update semantics](update-semantics.md) — the wire-level rules these models implement.
- [Makers](makers.md) — how `make:bridge:resource` produces a starter `<Name>List.qml` already wired up.
- [QML API reference](qml-api.md#reactivelistmodel) — exhaustive property/method list.