Files
Synapse/docs/adr/0021-rfx-managed-frontend-state.md
Dasemu 40631362b3 Initial import of LogDarioSeq baseline
Establishes version control for the personal Logseq DB fork before
starting the server-client architecture migration and domain features
described in docs/agent-guide and the approved implementation plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 15:32:15 +02:00

261 lines
12 KiB
Markdown

# ADR 0021: RFX-managed Frontend State
Date: 2026-06-03
Status: Accepted
## Context
ADR 0019 replaced Rum with HSX and moved the UI runtime onto React 19. The
frontend now has a React-compatible component runtime, but application state is
still managed by `frontend.state` through a large global atom plus many nested
atoms.
The current state layer has several responsibilities mixed together:
- `frontend.state/state` stores the main application map.
- Many values inside that map are themselves atoms, for example editor,
selection, RTC, PDF, command palette, scroll, and other high-churn UI refs.
- `frontend.state/use-sub` provides React subscriptions by wrapping the global
atom or a nested atom with `logseq.shui.hooks/use-atom`.
- `frontend.state/get-state`, `set-state!`, and `update-state!` are used across
handlers, components, workers, plugins, mobile code, and Electron integration.
- `frontend.state/pub-event!` publishes vectors to a `core.async` channel stored
at `:system/events`.
- `frontend.handler.events` consumes that channel and routes events through a
`defmulti`.
- Worker DB queries and component subscriptions use a separate reactive layer in
`frontend.db.react` and `frontend.db.hooks`.
- Several subsystems use Missionary flows or direct atom watches for runtime
state that is not just component state.
This makes UI reactivity explicit enough for React hooks, but the ownership is
still split between path cursors, nested atoms, a global event channel, and a
large set of imperative helpers. A code scan showed the size of the migration
surface:
- about 178 `state/use-sub` call sites under `src/main/frontend`;
- about 310 `set-state!` or `update-state!` call sites under
`src/main/frontend`;
- about 190 `pub-event!` call sites under `src/main/frontend`;
- about 102 `frontend.handler.events/handle` methods.
RFX is a better fit for the next step because it keeps the re-frame style
architecture while using React Context and hooks instead of Reagent. It provides
scoped app instances, `use-sub`, `dispatch`, `reg-sub`, `reg-event-db`,
`reg-event-fx`, effects, coeffects, snapshots, and a queue. Logseq has already
forked RFX and added root `deps.edn` support on `logseq/rfx` branch
`logseq/fork`, commit `d37aaceb37fcaf969c5cd04646dfdb985d65d3c2`.
The migration must not destabilize editor input, block trees, DB worker
transactions, RTC, plugin APIs, Electron IPC, mobile native flows, or
DataScript query subscriptions.
## Decision
Adopt RFX as the frontend application state and event runtime.
Add RFX to the main frontend dependency root from the Logseq fork:
```clojure
io.github.logseq/rfx
{:git/url "https://github.com/logseq/rfx.git"
:git/sha "d37aaceb37fcaf969c5cd04646dfdb985d65d3c2"}
```
Create a Logseq-owned RFX context, store, and registry. The context should be
provided near the root render in `frontend.core`, around `page/current-page`.
The RFX instance must be initialized during frontend startup before event
handling starts.
Replace `frontend.state` as the state owner in one coordinated migration.
Existing non-hook public entry points may remain as thin call-through APIs only
when they preserve caller compatibility without keeping state outside RFX:
- `get-state`
- `set-state!`
- `update-state!`
- `pub-event!`
- focused helpers such as `get-current-repo`, `set-current-repo!`,
`get-config`, editor helpers, selection helpers, and RTC helpers
Replace all `frontend.state/use-sub` and focused hook call sites with
`rfx/use-sub` or Logseq-owned domain hooks backed by RFX subscriptions. Do not
keep `frontend.state/use-sub` as a compatibility wrapper. `get-state`,
`set-state!`, `update-state!`, and focused non-hook helpers should read
snapshots or dispatch RFX events instead of mutating the old global atom.
Use one source of truth:
- Frontend application state is plain data in the RFX app db and is updated
through RFX events.
- Nested atoms must not remain as the representation for application state.
Convert nested atom state to plain values and path updates during the
migration.
- Missionary flows and direct atom watches must not remain as state propagation
paths for frontend application state. Replace them with RFX subscriptions,
events, effects, and coeffects.
- Imperative resources such as DOM nodes, editor handles, timers, external
worker objects, Electron handles, and plugin runtime handles stay outside the
RFX app db. They may be held in refs only when they are not renderable
application state and do not drive UI subscriptions directly.
Migrate every frontend state domain and its event handlers in the same change.
Do not land partial domain migrations. State-only migrations that still rely on
`frontend.handler.events/handle` for writes are rejected, and event-only
migrations whose render state still lives behind legacy path cursors are also
rejected.
`pub-event!` may remain as the public promise-returning compatibility API for
callers, but it should dispatch into RFX instead of writing to `:system/events`.
It must route to explicit `rfx/reg-event-db` or `rfx/reg-event-fx`
registrations and resolve or reject the deferred promise from the RFX event
result. Do not keep a bridge that invokes the existing
`frontend.handler.events/handle` multimethod.
Use RFX to replace flow-based frontend state propagation. DataScript execution
and worker-owned invalidation can remain worker responsibilities, but
UI-facing query result state and refresh orchestration should be exposed through
RFX subscriptions and events instead of Missionary flows or atom watches.
## Migration Shape
The migration should be one coordinated replacement of the frontend state and
event runtime.
1. Add the RFX dependency and create a small `frontend.rfx` namespace that owns
the app context, registry, initialization, dispatch helpers, and root
provider.
2. Move the initial plain app state map construction out of the `defonce state`
expression into a reusable function.
3. Move all frontend application state domains to plain RFX app-db values,
including route state, graph/current repo state, search, modals, sidebars,
theme flags, auth tokens, import/loading state, editor state, selection,
command palette, PDF, RTC UI state, plugin UI state, Electron UI state, and
mobile UI state.
4. Replace all `frontend.handler.events/handle` methods with explicit
`rfx/reg-event-db` or `rfx/reg-event-fx` registrations. Keep event ids
domain-scoped and register effects/coeffects for DB worker calls, RTC,
plugins, Electron IPC, mobile native calls, and async error capture.
5. Replace `:system/events` with RFX dispatch behind `pub-event!` and remove the
`core.async` event channel.
6. Replace Missionary flows and direct atom watches that propagate frontend
application state with RFX subscriptions, events, effects, and coeffects.
7. Replace all `frontend.state/use-sub` and focused state hook call sites with
`rfx/use-sub` or domain hooks backed by `rfx/reg-sub`.
8. Remove nested atoms, path cursors, and legacy hook paths for application
state. Keep refs only for imperative resources that do not represent
renderable state.
9. Update tests in the same change so every moved domain has coverage for state
reads, event-driven writes, async effects, and promise resolution.
Each domain should have a clear owner namespace, subscription ids, event ids,
effects, coeffects, and test coverage.
## Consequences
State ownership becomes explicit. Components subscribe through React-safe RFX
hooks, while non-view code can use RFX snapshots and dispatch against the same
instance.
Tests and isolated UI surfaces can create scoped RFX contexts instead of
mutating the global `frontend.state/state` atom. This matches the context model
introduced by the HSX migration.
The event system gains a uniform event/effect/coeffect shape and immediately
replaces the `core.async` channel plus `defmulti` dispatch path.
The migration cannot be a mechanical atom swap. Nested atom mutation does not
automatically notify RFX subscriptions. State must move to plain values, and
writes must move to RFX events.
Compatibility wrappers may remain only as public API aliases that call into RFX.
They must not retain separate storage, separate event dispatch, Missionary flow
state propagation, or direct atom-watch state propagation.
RFX effects that run async work must preserve existing error reporting behavior,
including `:capture-error` and promise rejection paths used by callers of
`pub-event!`.
## Rejected Alternatives
### Keep `frontend.state` As-is
This avoids migration cost but leaves Logseq with a custom subscription system
after the React 19 and HSX migration. It also keeps event routing separate from
state updates and does not give isolated RFX contexts for tests or component
development.
### Replace Only `pub-event!`
Moving events to RFX while leaving render state on path cursors would improve
event structure, but components would still subscribe through the custom atom
layer. It would not solve the state ownership problem.
### Migrate Domain-by-Domain
An incremental domain migration would leave two state and event runtimes active
for too long. That would preserve the current split ownership across RFX,
legacy path cursors, Missionary flows, atom watches, the `core.async` event
channel, and `defmulti` handlers. The migration should replace all frontend
application state domains and handlers together.
### Migrate State Without Event Handlers
Moving state keys to RFX while keeping their writes in
`frontend.handler.events/handle` would preserve the current split ownership in a
new form. RFX-managed state must be written through RFX events, so state and
event handlers should move together.
### Keep `frontend.state/use-sub` As A Wrapper
Keeping `frontend.state/use-sub` as a compatibility hook would preserve the old
subscription boundary and make it harder to see which code is using RFX
subscriptions directly. All hook call sites should move to `rfx/use-sub` or
domain hooks backed by RFX.
### Keep Missionary Flows For Frontend State
Keeping Missionary flows or atom watches as frontend state propagation would
leave RFX as only a partial store. Worker internals can still use their own
runtime mechanisms, but UI-facing state should be expressed through RFX
subscriptions and events.
### Keep DataScript Query Reactivity Outside RFX
Keeping `frontend.db.react` and `frontend.db.hooks/use-query` as a separate
UI-facing subscription layer would leave another flow-like state runtime beside
RFX. Query execution and worker invalidation may stay in the DB worker, but the
frontend-facing subscription and refresh contract should be mediated through
RFX.
## Verification
Before accepting the migration, verify:
- root rendering is wrapped in the Logseq RFX context;
- no `frontend.state/use-sub` call sites remain;
- direct `rfx/use-sub` and domain hook call sites obey React hook ordering;
- RFX-managed keys re-render components after `set-state!`, `update-state!`,
and event handlers update them;
- all frontend application state domains update through RFX events, with no
remaining legacy `defmethod handle` write path;
- no Missionary flow or direct atom watch remains as a frontend application
state propagation path;
- non-managed refs are limited to imperative resources and do not drive UI
subscriptions directly;
- `pub-event!` still returns a promise that resolves or rejects with the event
result;
- event errors are logged and reported through the existing capture path;
- graph switching, route changes, theme toggles, sidebars, modals, settings,
search, import progress, login state, plugin UI, Electron IPC, mobile tabs,
RTC indicator, editor editing, and block selection keep prior behavior;
- UI-facing DB query result subscriptions and refresh orchestration go through
RFX, while worker-owned query execution and invalidation keep prior behavior;
- no React hook-order warnings, invalid element warnings, or missed subscription
updates appear in Chrome;
- focused unit tests cover every state domain, event registration, effect,
coeffect, non-hook compatibility wrapper, and replaced flow path;
- `bb dev:lint-and-test` passes;
- relevant app or CLI E2E tests pass for graph switch, editor input, selection,
sidebar, settings, plugin, and sync surfaces touched by the migrated keys.