Liking cljdoc? Tell your friends :D

Spec: Macro Rewrites (defsc-form, defsc-report, defsc-container)

Status: active Priority: P0 Created: 2026-02-20 Owner: AI Depends-on: session-id-convention, form-statechart, report-statechart, container-statechart, routing-conversion

Context

The defsc-form, defsc-report, and defsc-container macros are the primary interface for downstream RAD users. They generate Fulcro component definitions with query, ident, initial-state, and dynamic routing hooks. These macros must be updated to:

  1. Remove all UISM references (::uism/asm-id in queries, UISM-based lifecycle hooks)
  2. Add statechart routing component options (sfro/statechart, sfro/busy?, sfro/initialize)
  3. Remove dynamic routing lifecycle hooks (will-enter, will-leave, allow-route-change?) since statecharts routing uses rstate/istate instead
  4. Do NOT generate :route-segment -- route segments live only on istate in the routing chart

Source of Truth

Current macro implementations:

  • defsc-form* at form.cljc:587 with convert-options at form.cljc:528
  • defsc-report at report.cljc:596
  • defsc-container at container.cljc:136

Routing options from statecharts library:

  • routing_options.cljc defines: sfro/initialize, sfro/initial-props, sfro/busy?, sfro/statechart, sfro/statechart-id, sfro/actors

Requirements

  1. Each macro must generate a component that works as a route target for istate in a statecharts routing chart
  2. The generated query must not include [::uism/asm-id '_]
  3. The generated component must include sfro/statechart (or sfro/statechart-id) as a component option
  4. Custom statecharts must be specifiable via fo/statechart / ro/statechart (renamed from fo/statechart / ro/statechart)
  5. :will-enter, :will-leave, :allow-route-change? must not be generated (statecharts routing does not use them)
  6. All macros remain CLJC-compatible

defsc-form Macro Rewrite

Current Generated Output

The current defsc-form via convert-options (line 528) generates:

;; Query (from form-options->form-query, line 389):
[id-key
 :ui/confirmation-message
 :ui/route-denied?
 ::form/errors
 [::picker-options/options-cache '_]
 [:com.fulcrologic.fulcro.application/active-remotes '_]
 [::uism/asm-id '_]                           ;; <-- REMOVE
 [fs/form-config-join]                        ;; keep (dirty tracking)
 ...attribute-keys...]

;; Ident:
(fn [_ props] [id-key (get props id-key)])

;; Initial State: (generated by pre-merge + form defaults)

;; Route lifecycle (when route-prefix is set):
:route-segment       [route-prefix :action :id]
:allow-route-change? form-allow-route-change   ;; <-- REMOVE
:will-leave          (fn [this props] (form-will-leave this))  ;; <-- REMOVE
:will-enter          (fn [app route-params] (form-will-enter app route-params form-class))  ;; <-- REMOVE

;; Route denied handler:
:route-denied        (fn [this relative-root proposed-route timeouts-and-params]
                       (uism/trigger!! ...))   ;; <-- REMOVE (routing handles this)

New Generated Output

;; Query -- remove ::uism/asm-id, keep everything else:
[id-key
 :ui/confirmation-message
 :ui/route-denied?
 ::form/errors
 [::picker-options/options-cache '_]
 [:com.fulcrologic.fulcro.application/active-remotes '_]
 ;; NO [::uism/asm-id '_]
 [fs/form-config-join]
 ...attribute-keys...]

;; Ident -- UNCHANGED:
(fn [_ props] [id-key (get props id-key)])

;; Initial State -- UNCHANGED (pre-merge + form defaults still apply)

;; NO :route-segment (route segments live in the routing chart on istate)

;; NEW component options for statecharts routing:
sfro/statechart    (or user-chart form/form-statechart)
sfro/busy?         form-busy?
sfro/initialize    :always

;; REMOVED:
;; :will-enter, :will-leave, :allow-route-change?, :route-denied

Changes in Detail

1. Query: Remove [::uism/asm-id '_]

In form-options->form-query (line 389), remove the UISM asm-id join from query-with-scalars:

;; BEFORE:
(into [id-key :ui/confirmation-message :ui/route-denied? ::errors
       [::picker-options/options-cache '_]
       [:com.fulcrologic.fulcro.application/active-remotes '_]
       [::uism/asm-id '_]          ;; DELETE THIS LINE
       fs/form-config-join]
  ...)

;; AFTER:
(into [id-key :ui/confirmation-message :ui/route-denied? ::errors
       [::picker-options/options-cache '_]
       [:com.fulcrologic.fulcro.application/active-remotes '_]
       fs/form-config-join]
  ...)

2. Component Options: Add sfro/statechart

In convert-options (line 528), add statechart routing options to the generated component:

;; The chart to use: user-specified via fo/statechart, or the default form-statechart
(let [chart (or (::statechart options) form-statechart)]
  (assoc base-options
    sfro/statechart  chart
    sfro/busy?       form-busy?
    sfro/initialize  :always))

Where form-busy? is:

(defn form-busy?
  "Returns true if the form has unsaved changes. Used by the routing system
   to guard against navigating away from dirty forms."
  [env data & _]
  (let [{:actor/keys [form]} (scf/resolve-actors env :actor/form)]
    (boolean (and form (fs/dirty? form)))))

The sfro/initialize :always setting means that every time the form is routed to, its state is re-initialized. This matches the current behavior where form-will-enter always starts a fresh UISM.

3. Remove Dynamic Routing Hooks

In convert-options, when route-prefix is set, the current code merges:

;; CURRENT (lines 567-572):
(merge {:route-segment       [route-prefix :action :id]
        :allow-route-change? form-allow-route-change
        :will-leave          (fn [this props] (form-will-leave this))
        :will-enter          (or will-enter
                               (fn [app route-params]
                                 (form-will-enter app route-params (get-class))))})

Remove entirely. No :route-segment, no :will-enter, no :will-leave, no :allow-route-change?:

;; NEW:
;; No route lifecycle options generated at all.
;; Routing lifecycle is handled by istate + sfro/busy? + sfro/initialize.
;; Route segments are defined in the routing chart on istate, not on the component.

4. Remove :route-denied Default

The current default route-denied handler (line 545) triggers the UISM directly. Remove it entirely. In the statecharts routing system, route denial is handled by the sfro/busy? function returning true, which causes the routing chart to set :route/denied? data that the form's statechart can react to.

5. Rename fo/statechart to fo/statechart

The option is renamed from fo/statechart to fo/statechart to reflect the new semantics. It provides a statechart definition (the output of the statechart function):

;; BEFORE (UISM):
(defsc-form MyForm [this props]
  {fo/statechart my-custom-uism-definition  ;; a UISM map
   ...})

;; AFTER (Statechart):
(defsc-form MyForm [this props]
  {fo/statechart my-custom-form-chart  ;; a statechart definition
   ...})

The macro reads fo/statechart and places it as sfro/statechart:

sfro/statechart (or (::statechart options) form-statechart)

If the user specifies a keyword for fo/statechart (a pre-registered chart ID), it is placed as sfro/statechart-id instead:

(if (keyword? user-statechart)
  (assoc base-options sfro/statechart-id user-statechart)
  (assoc base-options sfro/statechart (or user-statechart form-statechart)))

defsc-report Macro Rewrite

Current Generated Output

From defsc-report (line 596):

;; Query (line 631):
[::report/id
 :ui/parameters
 :ui/cache
 :ui/busy?
 :ui/page-count
 :ui/current-page
 [::uism/asm-id [::report/id fqkw]]           ;; <-- REMOVE
 [::picker-options/options-cache '_]
 {:ui/controls (comp/get-query Control)}
 {:ui/current-rows subquery}
 [df/marker-table '(quote _)]
 ...query-inclusions...]

;; Initial State (line 650):
{:ui/parameters   {}
 :ui/cache        {}
 :ui/controls     (mapv ...)
 :ui/busy?        false
 :ui/current-page 1
 :ui/page-count   1
 :ui/current-rows []}

;; Ident:
(fn [] [::report/id (or (::report/id props) fqkw)])

;; Route lifecycle (when route is set):
:will-enter (fn [app route-params] (report-will-enter app route-params report-class))
:route-segment (if (vector? route) route [route])

New Generated Output

;; Query -- remove ::uism/asm-id:
[::report/id
 :ui/parameters
 :ui/cache
 :ui/busy?
 :ui/page-count
 :ui/current-page
 ;; NO [::uism/asm-id ...]
 [::picker-options/options-cache '_]
 {:ui/controls (comp/get-query Control)}
 {:ui/current-rows subquery}
 [df/marker-table '(quote _)]
 ...query-inclusions...]

;; Initial State -- UNCHANGED

;; Ident -- UNCHANGED

;; NO :route-segment (route segments live in the routing chart on istate)

;; NEW component options:
sfro/statechart    (or user-chart report/report-statechart)
sfro/initialize    :once

;; REMOVED:
;; :will-enter (routing handled by istate)

Changes in Detail

1. Query: Remove [::uism/asm-id [::report/id fqkw]]

Line 637 currently includes [::uism/asm-id [::id fqkw]]. Remove it.

2. Component Options: Add sfro/statechart

(let [chart (or (::statechart options) report-statechart)]
  (assoc options
    sfro/statechart chart
    sfro/initialize :once))

Reports use :once initialization because they persist their state across route visits (the data stays loaded). Re-entering a report route should not restart the statechart from scratch.

3. Remove :will-enter

The current default will-enter (line 645) calls report-will-enter which uses dr/route-deferred. Remove this entirely. The routing chart's istate handles initialization.

4. Rename ro/statechart to ro/statechart

Same pattern as forms. ro/statechart now provides a statechart definition or a pre-registered chart ID keyword:

(if (keyword? user-statechart)
  (assoc options sfro/statechart-id user-statechart)
  (assoc options sfro/statechart (or user-statechart report-statechart)))

defsc-container Macro Rewrite

Current Generated Output

From defsc-container (line 136):

;; Query (line 155):
[:ui/parameters
 {:ui/controls (comp/get-query Control)}
 [df/marker-table '(quote _)]
 {child-id-1 (comp/get-query ChildSym1)}
 {child-id-2 (comp/get-query ChildSym2)}
 ...]

;; Initial State (line 164):
{:ui/parameters {}
 :ui/controls   (mapv ...)
 child-id-1     (comp/get-initial-state ChildSym1 {::report/id child-id-1})
 child-id-2     (comp/get-initial-state ChildSym2 {::report/id child-id-2})}

;; Ident:
(fn [] [::container/id fqkw])

;; Route lifecycle (when route is set, line 169):
:route-segment [route]
:will-enter (fn [app route-params] (container-will-enter app route-params container-class))

New Generated Output

;; Query -- UNCHANGED (containers have no ::uism/asm-id in their query)

;; Initial State -- UNCHANGED

;; Ident -- UNCHANGED

;; NO :route-segment (route segments live in the routing chart on istate)

;; NEW component options:
sfro/statechart    (or user-chart container/container-statechart)
sfro/initialize    :once

;; REMOVED:
;; :will-enter (routing handled by istate)

Changes in Detail

1. Query: No Change Needed

The container query does not include ::uism/asm-id. No modification required.

2. Component Options: Add sfro/statechart

(let [chart (or (comp/component-options container-class ::statechart) container-statechart)]
  (assoc options
    sfro/statechart chart
    sfro/initialize :once))

Containers use :once initialization. Like reports, they persist across route visits.

3. Remove :will-enter

Remove container-will-enter usage. The routing chart's istate handles this.


How Statechart Registration Works

The istate element in the routing chart handles chart registration automatically. From routing.cljc:446:

:srcexpr (fn [{:fulcro/keys [app]} data & _]
           (let [Target (rc/registry-key->class target-key)
                 id     (rc/component-options Target sfro/statechart-id)
                 chart  (rc/component-options Target sfro/statechart)]
             (cond
               id    id                               ;; pre-registered, use the ID
               chart (do
                       (scf/register-statechart! app target-key chart)
                       target-key)                     ;; auto-register, use component key
               :else (log/error "no statechart"))))

This means:

  • If the component has sfro/statechart (a chart definition), istate registers it at route time using the component's registry key as the chart key
  • If the component has sfro/statechart-id (a keyword), istate assumes the chart is already registered under that key
  • The macro simply sets the right component option; registration is handled by the routing system

How Custom Charts Are Specified

Pattern 1: Inline Chart via fo/statechart (or ro/statechart)

(def my-custom-form-chart
  (statechart {:initial :state/loading}
    ...))

(defsc-form MyForm [this props]
  {fo/id         account/id
   fo/attributes [...]
   fo/statechart    my-custom-form-chart})
;; Macro sets: sfro/statechart my-custom-form-chart

Pattern 2: Pre-registered Chart via fo/statechart Keyword

;; Somewhere at app init:
(scf/register-statechart! app ::my-special-chart my-chart-def)

(defsc-form MyForm [this props]
  {fo/id         account/id
   fo/attributes [...]
   fo/statechart    ::my-special-chart})
;; Macro sets: sfro/statechart-id ::my-special-chart

Pattern 3: Default Chart (no fo/statechart)

(defsc-form MyForm [this props]
  {fo/id         account/id
   fo/attributes [...]})
;; Macro sets: sfro/statechart form/form-statechart (the library default)

Migration Impact for Downstream Users

No Code Changes Required (common case)

Most users who use the default form/report machines need zero changes. The macros generate the correct component options automatically.

Users with fo/statechart / ro/statechart Overrides

These users must rewrite their custom machines as statecharts. This is a breaking change, but it is expected and documented. The old UISM definition format is incompatible with the new statechart format.

Users with :will-enter Overrides

Some forms specify a custom :will-enter in the macro options. These overrides will be silently ignored since the macro no longer generates :will-enter. Users should be warned at macro expansion time:

(when (contains? options :will-enter)
  (log/warn "defsc-form" sym ":will-enter is ignored. Routing lifecycle is managed by statecharts routing."))

Users Querying ::uism/asm-id

Any code that reads ::uism/asm-id from component props will get nil. This affects:

  • Custom renderers checking machine state
  • Tests asserting on UISM state

These must be updated to use scf/current-configuration instead.

Affected Modules

  • src/main/com/fulcrologic/rad/form.cljc - defsc-form*, convert-options, form-options->form-query
  • src/main/com/fulcrologic/rad/report.cljc - defsc-report macro
  • src/main/com/fulcrologic/rad/container.cljc - defsc-container macro
  • src/main/com/fulcrologic/rad/form_options.cljc - Rename fo/machine to fo/statechart, update docstring
  • src/main/com/fulcrologic/rad/report_options.cljc - Rename ro/machine to ro/statechart, update docstring

Open Questions

  1. DECIDED: fo/statechart renamed to fo/statechart (and ro/statechart to ro/statechart). The rename makes the intent clear. All references to fo/statechart / ro/statechart in the macros and option namespaces become fo/statechart / ro/statechart. This is a breaking change for users who specified custom machines, but they must rewrite their custom machines as statecharts anyway.
  2. DECIDED: No :route-segment in macros. Route segments live only on istate in the routing chart. The macros no longer generate :route-segment. Users define route segments in their routing chart definition.
  3. Should there be a compile-time warning for :will-enter overrides? Yes, at macro expansion time.

Verification

  1. [ ] defsc-form generates query without ::uism/asm-id
  2. [ ] defsc-form generates sfro/statechart component option pointing to form-statechart
  3. [ ] defsc-form generates sfro/busy? component option pointing to form-busy?
  4. [ ] defsc-form generates sfro/initialize :always
  5. [ ] defsc-form does not generate :will-enter, :will-leave, :allow-route-change?
  6. [ ] defsc-form with fo/statechart custom chart sets sfro/statechart to user chart
  7. [ ] defsc-form with fo/statechart keyword sets sfro/statechart-id to that keyword
  8. [ ] defsc-report generates query without ::uism/asm-id
  9. [ ] defsc-report generates sfro/statechart pointing to report-statechart
  10. [ ] defsc-report generates sfro/initialize :once
  11. [ ] defsc-report does not generate :will-enter
  12. [ ] defsc-container generates sfro/statechart pointing to container-statechart
  13. [ ] defsc-container generates sfro/initialize :once
  14. [ ] defsc-container does not generate :will-enter
  15. [ ] Forms using sfro/busy? correctly prevent route changes when dirty
  16. [ ] istate auto-registers charts from sfro/statechart component option
  17. [ ] Compile-time warning emitted when :will-enter override detected

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close