Skip to content

Instantly share code, notes, and snippets.

@holyjak
Last active July 26, 2023 17:55
Show Gist options
  • Save holyjak/da15f613376bf7015385a7cd899ee3e8 to your computer and use it in GitHub Desktop.
Save holyjak/da15f613376bf7015385a7cd899ee3e8 to your computer and use it in GitHub Desktop.
Assorted notes from learning and experimenting with Fulcro [WIP]

Fulcro Field Notes

Assorted notes from learning and experimenting with Fulcro.

Learning tips

Tony Kay, 3/2022:

One other thing: The various namespaces of Fulcro, while taking advantage of Fulcro’s features, are by no means the only way of doing things. This applies to nses for routing, forms, state machines in particular. Those are there to show one way of handing those tasks, and I personally use them all to good effect; however, do not approach forms as “I MUST use form-state or I’m doing it wrong”. I’d recommend starting out by ignoring these nses and trying to do it on your own. It isn’t hard to write a form without form-state. What happens, though, is you start to see repetitive patterns (like wanting to undo a change), validation, and save/load. Ultimately, RAD forms are much more powerful, because they do almost all of it for you, but the existence of those should not keep you from trying very simple things “solo”, without the help of those libraries…when learning it is very useful to understand the primitives before using the add-ons.

General

From Tony Kay:

[..] a guiding principle that always helps me: Rendering in Fulcro is a pure function of state. Get the state right, and rendering will follow. The query/ident/composition has a shape that exactly matches the expected structure in app state. Y ou do have to reason over that in time, but you need to learn to adapt to look at the data patterns.

Anytime I have a “bug” I first look at query/ident/initial state and see if they make sense, THEN I look at app state and see if it matches what makes sense, THEN I look at my logic and see if it is also working with that same data shape. It’s all about the data.

Tony 1/2021:

So, from a Fulcro perspective, when you want data in some particular place, just ask for it…add the prop where you want it. But Fulcro cannot magically write your server-side query or data initialization to actually make it appear there. Fulcro is very (intentionally) literal. There is no magic on the front end in Fulcro or RAD as far as this question goes. If the data is in your graph db on the front-end, it will be in the rendered component (data + query + ui tree all literally match). Fulcro is about making it easy to initialize (initial state), normalize (via query/ident), denormalize (query + normalized state), and “patch” (via load, data targeting, merge-component) that model…but the normalize/denormalize via UI query is quite literally a marshall/unmarshall kind of thing…like base64 encode/decode.

Tony 2/2022:

[..] to me Fulcro is about a pattern of data management, reasoning, and to some extent scalable code bases (though that is very much tied to the people doing it).

I would claim that Fulcro’s central core (for UI) is actually simpler (not necessarily easier, at first) than Reframe. There is a normalized db, a query, and a UI tree. There are no side-effects mixed into the render, etc. Every modification goes through a single concept: the mutation. The fact that this handles full-stack equally as well as client-only means there is even less to deal with conceptually. BUT, you do have to learn the nuances (query/ident/initial state are the three big concepts). Fulcro is partially hard because I’ve provided so many parts. But 80% of them are completely optional, [..] Fulcro is opinionated where it is useful, and completely configurable where it should be flexible.

[..] Another fair point: If it’s a small project with little or no I/O, I’d agree with Reframe. It’s very tractable and easy for that kind of project. That said, I’ve had more than one consulting client come to me for help porting from Reframe to Fulcro when their project got bigger.

Tony, 2023-05-16

That is the main hurdle to understanding I/O in Fulcro. Loads (and mutation return values) have to be targeted unless all you care about is the normalized data.

i.e. they need :target to create an edge from some data to the normalized data being inserted into the client DB, so that it is reachable.

How does query + DB → props work?

The way Fulcro combines the :query of your componenets and the normalized client DB (fed by :initial-state and whatever load! you have issued) to produce a data tree and passes it down the component tree via props is simple in theory but gave me quite a few headaches in practice. This text should help my past self to avoid them.

Beware that there is no magic involved in turning a component’s query into its props - it is your duty to ensure that parents include children’s queries and initial state all the way up to the root and then pass the correct props down to the children.

A look at a Fulcro component

An example from the Book, using the verbose lambda form of for initial-state:

(defsc Root [this {:keys [friends ; (2)
                          enemies] :as props}]
  {:query         [{:friends ; (1)
                    (comp/get-query PersonList)}
                   {:enemies
                    (comp/get-query PersonList)}]
   ;; :ident (fn [] [:component/id :singleton1]) ; (6)
   :initial-state (fn [params]
                    {:friends ; (3)
                     (comp/get-initial-state
                       PersonList {:label "Friends"}) ; (4)
                     :enemies
                     (comp/get-initial-state
                       PersonList {:label "Enemies"})})}
  (dom/div
    (ui-person-list friends) ; (5)
    (ui-person-list enemies)))
  1. We include the query of the child component using an "EQL join". The key must* correspond to an attribute of the current entity/node. In the case of the Root, the entity is the state DB so there must be a top-level key :friends there.

    • *) An exception to the rule are computed properties (???) *TODO*

  2. The props, supplied by Fulcro in the case of the root component, will then contain a :friends key, which we destructure here (and Fulcro checks that the props we destructure match what we asked for in the query)

  3. Here we want to set the initial state so that we show the two lists and their labels, even if empty. We could also decide to show nothing and leave the state as the default nil

  4. We pass in the params that the child’s initial-state function needs, in this case the label

  5. We pass the relevant part of the data tree as props to the child

  6. The root must not have an ident(ificator) but child components normally have it. It is especially important for non-singleton components such as Person to tell Fulcro which of the props is the ID of the entity and thus how to normalize it: :ident (fn [] [:person/id (:person/id props)]) (or just the short "template form" :ident :person/id if the two keywords are the same, as in this case).

High-level overview of the process

  1. Fulcro initializes the client DB from the root component’s initial state, which you made to explicitly compose the initial state of its children by including …​ :some-child (comp/get-initial-state SomeChild <params>), …​ (or using the shorthand template notion). TODO Code: Get the initial state tree

  2. Fulcro gets the root component’s query which composes the queries of its children (which again compose the queries of their children, all the way to the leaves) thanks to you having explicitly included …​{:some-child (comp/get-query SomeChild)}, …​ for each of the children. The EQL query is just data, going from some root entity and describing what properties we want included. (PersonList wants a label and a list of people, Person wants a name and an age, …​ .)

  3. Fulcro walks the query and the client DB in parallel to construct the data tree corresponding to the query (esentially filling in the values).

    • Gotcha: If it hits a nil node, it won’t continue down the branch - even if a downstream components asks for root data that is there. So you want to set the parent’s initial state to {} instead of the defaul nil if you need it to go down no matter what.

  4. Fulcro sends the data tree to the root component as its props - the component is responsible for extracting the parts its children need and sending them on via their props (typically as the first argument of the defined ui-* factory function).

Gotcha’s

  • A query does not fetch anything, contrary to e.g. re-frame’s subscriptions, it is just (meta)data; you must make sure that it gets included in the root component’s query (via all intermediate ancestors). The data is then passed down by you from the root, via props.

Terms

  • ident of a component/data

  • initial state

  • query

What about Routers?

Routers automatically include the query of each child (:altN (comp/get-query MyTargetN)) and pass the data of the corresponding prop to the target component as props. The router also includes any computed properties so what is happening is something like ((factory MyTargetN) (comp/computed (:alt0 props) (comp/get-computed this))) (where computed is similar to merge).

It has a dynamic query, keeping the current target under ::dr/current-route.

Fixing a Router inside a dynamically-loaded, non-singleton component

What if you have a router inside a component whose data isn’t loaded at start and that has a dynamic ident (such as :person/id[:person/id "something"])? When the component’s data is loaded, you must add the router’s data so that Fulcro can find the router. Breaking this connection to the router leads to rendering issues with the content. A way to fix it is to ensure that you fill any "holes" in the data with the component’s initial data (which should include the router’s) using :pre-merge:

(defsc Person [_ {:person/keys [name details-router]}]
  {:query [:person/name {:person/details-router (comp/get-query PersonDetailsRouter)}]
   :ident :person/name
   :initial/state {:person/details-router {}}
   :will-enter (fn [app {name :person/name}]
                 (let [ident [:person/name name]]
                   (dr/route-deferred ident
                      #(df/load! app ident Person
                                 {:post-mutation        `dr/target-ready
                                  :post-mutation-params {:target ident}}))))
   :pre-merge (fn [{:keys [data-tree]}]
                ;; Merge the data *onto* the initial state:
                (merge (comp/get-initial-state Person)
                       data-tree))}
  (dom/div
    (str "Hello, I am " name)
    (ui-details-router details-router)))
Gotchas

When you load! the data for a component that has a router - the server has no idea about the router and the data loaded will thus remove (or, if loading for a new ident, not add) the router’s initial state - and thus, when Fulcro creates the data tree from the DB and query, it will run into a nil at the router’s join and not fill in its data even though it has them in the DB.

Solution: Use :pre-merge to add the router’s initial state to the data loaded from the server - see [17.4. Initial State](http://book.fulcrologic.com/#_initial_state_2) in the Book.

Common problems and solutions

If you get this warning:

Attempt to get an ASM path [..] for a state machine that is not in Fulcro state. ASM ID: <some router component>

it is mostly harmless, indicating that you are rendering routers before you’ve started their associated UISMs (repeated on every render until the SM starts). Tony suggests:

You can use app/set-root! with initialize state, then dr/initialize! or dr/change-route!, then app/mount! with NO initialize state to get rid of most or all of those. Basically: Make sure you’ve explicitly routed to a leaf (target) before mounting.

I would also recommend to start Root with :ui/ready? false and switch it to true in a transaction that you issue after a call to dr/initialize! (which itself issues transactions to start all the routers in the query). (Remember, transactions are executed in-order.) In its body you would render "Loading.." while not ready?. (You might also want to wait for loading some initial date before switching it on.)

Corner cases & riddles

  1. Display :friends list (if you only have :list/id → :friends → data)

  2. Inside Person, have sub-components Age and Name showing parts of the entity (normally, component = entity)

  3. Accessing a root entity from a nested component (a Link Query) and the importance if init. state

  4. What about non-data components (a Router or a Grouping) in the middle of a component tree w.r.t. query composition and props propagation (see routers - make up kwds, include queries…​?)

Quiz

Given the Person - PersonList - Root components, how will the DB differ if we remove :idents from the components?

Think about it first…​

With idents:

{...
 :enemies [:list/id 1],
 :friends [:list/id 0],
 :list/id
 {0
  {:list/label "Friends",
   :list/id 0,
   :list/people [[:person/id 1] [:person/id 2]]},
  1
  {:list/label "Enemies",
   :list/id 1,
   :list/people [[:person/id 3] [:person/id 4]]}},
 :person/id
 {1 {:person/id 1, :person/name "Sally", :person/age 32},
  2 {:person/id 2, :person/name "Joe", :person/age 22},
  3 {:person/id 3, :person/name "Fred", :person/age 11},
  4 {:person/id 4, :person/name "Bobby", :person/age 55}}}

Without idents we get the initial state tree as-is because there is no normalization (and we could omit all the :*/id keys as they aren’t used for anything):

{:friends
 {:list/label "Friends",
  :list/id 0,
  :list/people
  [{:person/id 1, :person/name "Sally", :person/age 32}
   {:person/id 2, :person/name "Joe", :person/age 22}]},
 :enemies
 {:list/label "Enemies",
  :list/id 1,
  :list/people
  [{:person/id 3, :person/name "Fred", :person/age 11}
   {:person/id 4, :person/name "Bobby", :person/age 55}]}}
What is wrong?

There is no warning anywhere but an attempt to route fails with st. like "no route target" and the DB has no routing data.

:query [:tmp/router (comp/get-query OffboardingIdRouter)]

A: {..} around

Missing router props form a 2nd load

Upon initial load, everything is OK. After I load! in data, the data for the child router is suddenly not there (though it is in the query and DB).

Any idea why my router is getting nil props? This is the relevant part of the Root query:

...
:tem-organization/organization-number
 {:tem-organization/latest-bill-run
  [:bill-run/id
   {:ui/subscribers-list-router
    [:com.fulcrologic.fulcro.routing.dynamic-routing/id
     [:com.fulcrologic.fulcro.ui-state-machines/asm-id
      :minbedrift.ui/SubscribersListRouter]
     {:com.fulcrologic.fulcro.routing.dynamic-routing/current-route
      [*]}
     {:alt0
      [{:bill-run/subscribers
        [:br-subscriber/subscriber-id]}
       [:ui.fulcro.client.data-fetch.load-markers/by-id _]]}]}]}]}]

but when I resolve it against the app state (com.fulcrologic.fulcro.algorithms.denormalize/db→tree), it completely lacks the :ui/subscribers-list-router part. :ui/* keys are removed only from server-side queries, no? The data I get includes:

...
{:tem-organization/organization-number "987699321",
:tem-organization/latest-bill-run
{:bill-run/id 47143}}

The router is in my DB.

A: The load! loads the entity but somehow the key :ui/my-router, set originally correctly from the initial state, gets removed. Init DB: :bill-run/id → nil → :ui/subscribers-list-router = [::dr/id :../SubscribersListRouter] DB after the load: Same but added the loaded bill run, w/o the router: :bill-run/id → 53518 → various :bill-run/* keys, no :ui/*

Troubleshooting facilities

It has to be query/ident/destructuring composition, or that the data isn’t linked in the db. There’s really nothing else.

Get a component’s props (see The Book - The Component Registry):

(-> (comp/get-indexes app.application/SPA) :class->components :app.ui.root/OffboardingPages first comp/props)
(comp/class->any SPA root/Root)
(comp/registry-key->class :app.ui.root/Information)
(comp/registry-key->class `app.ui/Root)

(see also comp/ident→any / ident→components)

(comp/get-query Root) ;; must include children (check metadata)
(comp/get-initial-state Root) ;; e.g routers require non-nil init state to work

;; Query manually against the *client* data
(let [state (app/current-state app.application/SPA)]
    (com.fulcrologic.fulcro.algorithms.denormalize/db->tree
      (comp/get-query Root)
      state
      state))

@(::app/state-atom SPA)

(comp/get-query root/Root (app/current-state SPA))

(let [s (app/current-state SPA)]
    (fdn/db->tree [{[:component/id :login] [:ui/open? :ui/error :account/email
                                            {[:root/current-session '_] (comp/get-query root/Session)}
                                            [::uism/asm-id ::session/session]]}] {} s))

Pathom v2

(get env ::pc/indexes) in any resolver ⇒ what resolvers and keywords it knows

Various

Make query params visible to any Pathom resolver

Install the query-params-to-env-plugin. Without this any params you add to queries are visible at the top level but not to any resolver. With this, they will be included in the env (I think).

Insights from the Slack

Tony on dynamic queries, 4/2021

On dynamic queries: It is impossible (without additional help) to set the query of a component instance because there can be more than one on the screen at a time, even with the same ident, because such components are path dependent, and dynamic queries should not be tied to component-local state. Dynamic queries actually get normalized into the app db, and are therefore serializable with the entire app state, making a render (even with dyn queries) truly state dependent. If you look at the book there is metadata that can be added to a factory that can be used to alter the query ID (which defaults to the FQ classname). This allows you to use a factory to set the query instead of the component, which in turn lets you target a set query to a particular call-site (you just make extra factories). Now, of course, this doesn’t scale to the example where you want a different query for every row of a table (though technically you could do that as well…but it would be overkill). The “disconnected root” mechanism really is best for scenarios where you want to spring a component into existence without having to pre-compose state/query.

Another perfectly valid alternative for tables that have form inputs is to make the row-level components leverage component-local state for editing, and send a single transaction to the parent of the table to “commit” the edit. The top-level table component just tracks which row is editable, and the rows themselves do not need joins to “stateful inputs”. There are, of course, exceptions. Using the new raw support to spring those into existence for just the selected row is perfectly tractable, and should perform quite well. Especially with the latest optimizations that don’t re-send props unless they actually change.

Tony on dynamic UIs (generated from DB data), 7/2021

So, fully dynamic UI can be very expensive from a sustainable software architecture viewpoint, but the implementation is something I’ve talked to others about and have helped clients implement. From a Fulcro perspective the structure I recommend is to have a single Component class with a recursive query, and use multimethod dispatch for the rendering.

(declare ui-node)

(defmulti render-node (fn [props] (:node/type props)))
(defmethod render-node :div [{:node/keys [children class]}]
  (dom/div {:classes [class]}
     (map ui-node children))) ; note the recursive call to ui-node
(defmethod render-node :text [props]
   (:node/value props))

(defsc DOMNode [this props]
  {:query [:node/id
           :node/type
           :node/class
           {:node/children ...}]
   :ident :node/id}
  (render-node props))

(defn ui-node (comp/factory DOMNode {:keyfn :node/id})

(defsc Root [this {:root/keys [node]}]
  {:query [{:root/node (comp/get-query DOMNode)}]}
  (ui-node node))

Initialization can be done with initial-state, but that’s not really the point, right? In this kind of app, I/O is used to build the initial state, so there’d be a load on start like:

(df/load! app :desired-ui/root DOMNode {:target :root/node})

where that resolver would simply return something like:

{:node/type :div
 :node/id some-random-uuid
 :node/children [{:node/type :div
                  :node/id some-random-uuid
                  :node/children [{:node/type text
                                   :node/id some-random-uuid
                                   :node/value "Hello world!"}]}]}

Tony on child query property only needed by the parent, 11/2021

Context: A blog site where the parent of BlogSPostummary needs to know the blog post date to be able to sort its children even though the child itself is not interested in its date.

This is the general rule: The parent HAS to know about the child, but the child should not be coupled to knowledge of the parent. So, if the child has something the parent wants to see, it’s fine to look at the props for the children…in fact, the parent is the ONLY thing in this scenario that can possibly sort them because it is what has the children as a collection. It’s true that this introduces an implicit coupling, but the temptation to “modify a child” from the parent introduces stateful mutation into your system, which becomes much messier than just including the date in the child’s query manually and dealing with the fact that if someone removes it later then the parent won’t sort things. The former creates incidental complexity in the application. The latter results in an easy-to-find and easy-to-fix bug that rarely happens in practice, but has a low cost when it does. If you’re super paranoid, then you could add logging or even startup errors if the query of the child doesn’t include what you want…but I think that’s overkill.

If you still disagree with the above, and really want to screw with the child query, then you can always use set-query! . That, combined with get-query could be used to modify the child query in the lifecycle of the parent. You’ll need to make sure that happens before your load needs to use the query, and of course you’ll want to make sure you preserve the original elements of the query.

How to model multi-step form

Q: I have a design question. I am building a mobile-first web app. I have several screens of forms that will not be submitted until the last screen is filled out. Each screen has a route. What is the best way to organize my project so that I can submit the data correctly from these screen when the front-end is in the right state?

A: So, I would tend to make it a single form (with subforms) so the state can be managed as a single "unit", then do the rendering according to what "page" you’re on. I would not use a router. that way your overall validation, diff, save, load, etc are way more unified. If you really want a collection of related components to work together towards a goal, then yes, I’d definitely use a UISM.

Enable hot code-reloading of hooks/use-memo etc.

You likely need to umount+mount the component to Force React to re-compute the memoizied function. As a hack, you could add a hook to hot reload that simply increments a global atom holding an int, and include that int in your dependency list for use-memo:

(use-memo (fn [] …​) [@hot-reload-counter])

Passing ref to child components

As an alternative to wrapping a fn component in React.forwardRef. The solution is to pass a custom prop with the ref value from the hook/class-based parent (possibly via computed props, to be 100% safe). Code: https://github.com/fulcrologic/fulcro/blob/develop/src/workspaces/com/fulcrologic/fulcro/cards/ref_cards.cljs

Tony on pre-merge, 7/2022

I rarely use pre-merge. It’s a contributed feature that has some nice aspects, but I personally prefer to put that logic in a UISM or mutation. But, if you don’t have a logic system around your components and are just using load, then pre-merge is kinda nice.

Tony on computing derived state 11/2022

People sometimes ask about something like re-frame subscription for computing derived data and caching it in the state:

I don’t personally use any kind of derived data mechanism outside of Fulcro. I just haven’t felt the personal need for it, and thus I’ve provided requested hooks whenever asked as a means by which the community can experiment with their own solutions (as @dvingo has said [see the :before-render hook]).

For me, I put derived data into two categories:

  1. Stuff that can be easily computed in the UI. Write a function. Query core data, Call it in UI. Easy to understand, almost always plenty fast. (premature optimization leading to feature bloat)

  2. Write the derivation as part of the operation of the component, which is implemented in a UISM (e.g. RAD reports do this heavily). For example RAD reports load the raw report data, then trigger a sequence of things (data transforms, pivots, sorting, filtering). The logic is very easy to follow because it is all in one computational unit that has to do with the thing in question. All updates to that data model go through the state machine, so it is trivial to keep all the derived data correct. (insufficient consideration of the complexity of the real problem might mean derivation system is actually the wrong solution)

In my experience what most people want from derived data systems is (1). If they really want (2), then the cascade of things gets too confusing with subscriptions and I/O and derived data systems are also a poor fit.

In my experience ppl worry WAY too much that calling a function in render (i.e. (1)) is going to be too slow. I’ve been writing apps with Fulcro for 5 years now. That has not been my experience, and even in cases where an optimization was needed, the rare optimization was not a huge burden that made me want a derivation system in my stack.

Note: There is also generic https://github.com/matterandvoid-space/subscriptions library that also has an example of usage with Fulcro. The author comments: [..] but ultimately [I] found the dev experience of just using helix to render the dom with subscriptions for reactivity and using fulcro for only data manipulation to be a much more enjoyable and understandable developer experience. it’s very simple.

Tony on speeding up db→tree 2/2022

nivekuil: I’m running into dropped frames, and profiling seems to show db→tree is a bottleneck. It can take 12ms+, which is usually longer than react’s own render. …​

Tony:

  • Always measure a production build

  • the way to optimize db>tree is to use dynamic queries (dynamic routers) or union queries to reduce the average query down to just what is on-screen.

  • Another possiblity is you’ve normalized a huge amount of data for something like a report. In that case, denormalization can help significantly

  • Doing remote operations can trigger multiple renders: optimistic action schedules a render, going from net queue to active does as well (so loading marker can show), also progress updates, and network completion/merge. Using the syncrhonous tx processing reduces this considerably, and is a configurable option.

  • another trick: Denormalize the normalized data for rendering. In other words, change the query of the worst area to ask for something like :ui/render-data. Normalize your base data using queries from QUery-only components, then on transactions pre-denormalize the data to be rendered into this new field. Then you only pay for db→tree on changes.

  • the other thing to watch out for is the built-in shouldComponentUpdate. Large trees are slow to diff. So if you’ve pre-denormalized the data, change SCU to do that instead, also you might change SCU in parent components to be “true” so that they don’t compare the even larger tree

Tony on Dynamic Routing, I/O and complexity 2023-03-18

In general I would not recommend tying loads to things this way [i.e. complex decision whether to df/load! or not in :will-enter]. If you have a complex scenario, I highly recommend you put a UISM or statechart around it, and control it that way. Treat DR as a ui-only concern most of the time. The side-effect integration in DR is for very simple cases (start something when a route is entered), and should use the DR lifecycle hooks. Putting I/O in the router itself is seriously discouraged.

Drive your UI and I/O from your logic. Logic drives data shape, which drives rendering as a pure function. Do not invert that except in very simple cases, or where there is literally no other way (e.g. focus an input). You’re sort of in a middle ground here. The logic of the DR is what you’re trying to sort of hook into, but it’s a really gray area. You’re trying to do something in a way it wasn’t designed for, which is making kind of a mess.

I.e., in my understanding: basic data → logic → derived data (e.g. :current-route of a router) → rendering. Do not use Fulcro-produced derived data (such as router state) to drive logic. Instead, make a state machine - for any more complicated case - that manages the state and flow of logic, and everything follows from there.

Tony on when to use hooks 2023-07-24

Places I personally use hooks:

  • Drag n drop interactions or complex UI interactions where direct DOM/event interaction is complex and the primary concern.

  • Certain levels of dynamism. For example a dropdown that needs to be dynamically placed in the UI and needs to load options. If you don’t know where it is going to be in the tree via a predefined query, then hooks might be a good answer.

Server-Side Rendering (SSR)

Pathom

DIY Pathom3 env for invoking the parser manually inside RAD (by sheluchin @ clojurians slack):

(p.eql/process-one
  ((-> (attr/wrap-env all-attributes)
       (xtdb/wrap-env (fn [env] {:production (:main xtdb-nodes)}))) ; if using XTDB as the backend
   (-> {}
     ;; `convert-resolvers` call is necessary for now,
     ;; even if you don't use P2 resolvers/mutations at all
     (pci/register (pathom3/convert-resolvers
                    [automatic-resolvers
                     foo-model/resolvers
                     bar-model/resolvers])))
  {::foo/id #uuid "ffffffff-ffff-ffff-ffff-000000000001"}
  ::foo/name)
  ```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment