Skip to content

Latest commit

 

History

History
392 lines (296 loc) · 23.7 KB

dynamic-routing.adoc

File metadata and controls

392 lines (296 loc) · 23.7 KB

Routing Features

The routing system under design has the following feature requirements:

Composition

Routers should compose in a flexible manner that includes the easy ability to refactor the application and restructure.

There should be a way to run pessimistic operations before moving to a given route

This should be a chain of operations that is derived from the target routers themselves (or their children?).

UI Code should be able to prevent a route change

E.g. say there are unsaved changes on some page in a tree: a UI component must be able to "hook into" the routing system in order to prevent changes. NOTE: This is not a feature of the routers, but a feature of the content under a router. This can be modelled as a global concern, since routing (esp. that involves URI changes) is a global concern.

Code Splitting

A complete routing system for SPAs should make it easy to do code splitting at particular routes so that an initial load of the application need not load the code for every feature.

DRY

It should not be necessary to repeat the "route path" as an external data structure when it matches the UI structure. The UI composition itself can easily act as a "default" routing path structure.

URI <⇒ UI Routes should be flexible and able to "alias"

Reshaping the URI can be done by optional functions that sit in-between browser URI and code.

Navigable Code

During development it should be easy to see (as local concerns) what happens along a given UI route. Code navigation (jumping from root through subcomponents) should make it trivial to understand all routing concerns. Route "operations" like loading should be co-located with the components that act as routing targets.

Introspection

One should be able to query for the available routes (of loaded code), and the current visible route.

Dynamic routing in Fulcro can be easily facilitated by leveraging the UI query, which is a tool of composition that is always guaranteed to be present in a properly-structured application. Each component’s state will be normalized, and the class and relative UI position can be determined by examining the current UI query.

Take the following UI layout:

   +-------+
   |Root   |
   +---+---+
       |
   +---+---+
   |Router |  (acts like a "/" in a URI)
   +-+---+-+
     |   |
     |   +------+
     |          | D
 +---+---+ +----+----+
 |User[n]| |Settings |   Router targets (route segments act like URI elements)
 +-------+ +----+----+   /user/n or /settings (initial "/" derived from UI path)
                |
           +----+--+
           |Router | (acts like a "/")
           +-+--+--+
             |  |
    +--------+  |
  D |           |
 +--+----+ +----+----+
 |Pane 1 | |Pane 2   |  Router targets (segments acts like URI elements)
 +-------+ +---------+  /settings/pane1 or /settings/pane2 ("/settings" derived from UI path)

The routers in this system can easily be autogenerated by a macro that is given nothing more than the classes of the components that are the targets of routing (i.e. User, Settings, etc.). The macro can simply compose them together into a component that has a dynamic query whose "current route" points to the first class listed (marked with D in the diagram). If the given router is to be shown on initial startup, then these default routing targets must be singletons (have an ident that does not depend on their props).

This delegates the novelty of routing targets to the target itself. Interestingly, this is quite convenient for composition and refactoring. The router is not programmed with any foreknowledge of the routing novelty of a target…​only it’s symbolic name!

(defrouter RootRouter [this props]
  {:router-targets [Settings User]})
(def ui-root-router (prim/factory RootRouter))

(defrouter SettingsRouter [this props]
  {:router-targets [Pane1 Pane2]})
(def ui-settings-router (prim/factory SettingsRouter))

The parameter list (this and props) are used with deferred routing, explained below.

The use of query scanning and dynamic queries for routing mean that you can easily add or remove a sub-route just by moving the symbol to a different router.

Such routers are simple Fulcro components, and can be composed into the UI just like any other components. The initial state parameters passed to such a router are forwarded to the default (first listed) router target (if it has initial state).

Routers are true singletons. A router ends up with a dynamic query keyed by the class itself. This means that a given router cannot be used in a UI in more than one place. This seems like a reasonable restriction given that they are so simple to declare at a given tree (sub)root, and are typically very positional in nature.

Most of the novelty about routes can now be encoded into normal components with simple declarations. The routing novelty is specified by two protocols:

(ns app
  (:require-macros [fulcro.incubator.dynamic-routing :as dr :refer [defrouter defsc-route-target]])
  (:require
    ...
    [fulcro.incubator.dynamic-routing :as dr :refer [defrouter]]))

(defsc X [this props]
 {:protocols [static dr/RouteTarget
              (route-segment [_] ...path segments...)
              (will-enter [_ reconciler route-params] ...defer or immediate...)
              (route-cancelled [_ route-params] ...called if deferred route to here is cancelled before it completes...)
              dr/RouteLifecycle ; non-static, so `this` is available
              (will-leave [this props] ...true or false...)]}
 (dom/div ...))

;; OR using an extended defsc macro:
(defsc-route-target X [this props]
 {...
  :route-segment   (fn [] ...path segments...)
  :will-enter      (fn [reconciler route-params] ...defer or immediate...)
  :route-cancelled (fn [route-params] ...called if deferred route to here is cancelled before it completes...)
  ;; `this` is avaiable in will-leave, but not in the above
  :will-leave      (fn [props] ...true or false...)]}
 (dom/div ...))
route-segment

A (relative) path segment that this component can "consume" from an incoming route. This is purely static data, and the argument is the class itself (to satisfy protocols). The current composition of routing targets in the UI determines the overall "absolute" path of a route. Each router in the UI should be thought of as a stand-in for a "/" in an HTML5 URI path.

will-enter

A notification that this route target should be shown. Can return a value indicating a desire to do so immediately, or that it would like a delay (for some I/O). This method is called before the component is on-screen, so it cannot receive a react component instance. It is instead passed the reconciler and router parameters which can be used to do things like issues loads and run mutations.

route-cancelled

A notification that this route target was in a deferred state but the user made some other routing decision during that delay. This can be used to cancel heavy I/O operations for this target.

will-leave

A method that can prevent a route change that causes this component to leave the screen. This is called on the instance, so this and props are available. A request to change routes will signal this method from deepest child towards the parent, and will stop if any returns false.

⚠️
will-enter SHOULD NOT side-effect (order of operations could cause strange behavior), but must instead do any I/O in the lambda passed to route-deferred. It must also trigger the dr/target-ready mutation to indicate that the route is ready. You can use plain transact! to start a mutation with route-deferred, but the mutation must use target-ready! to send the signal (since you’re already in a mutation at that point).

Route targets can be singletons or regular components that have multiple instances. In the latter case you must be sure that the ident returned from will-enter points to valid data in state by the time the route is resolved.

ℹ️
There is a complete working demo in routing_ws.cljs.

The dynamic routing relies on a call to change-route in order to start the routing system. Therefore you MUST make a call to change-route on start in order for the dynamic routers to work; however, there is also the concern of what gets rendered on the "first frame" of application mount.

Your top-most router will be in an "uninitialized" state on initial load. You can use the body of that router to render the "first frame" of your mounted app:

(defrouter RootRouter2 [this props]
  {:router-targets     [Settings User]}
  (case current-state
    :pending (dom/div "Loading...")
    :failed (dom/div "Failed!")
    ;; default will be used when the current state isn't yet set
    (dom/div "No route selected.")))

If you are doing SSR, then you will need to simulate calling change-route there. The function dr/ssr-initial-state (written, but untested) can be used to help you construct the proper state for a given path (which must be used for the server-side render, and also as the initial state for the client). Technically, this means that the function can also be used to generate initial state for the client on the front-end as well.

UI Composition determines the available routes, and each route target must declare what part of the current "route" they can consume. The declaration is a vector of literal strings and keywords:

["user" :user-id]

Strings in the route segment MUST exactly match an incoming path prefix or the route does not match. The keyword parameters are route parameters, and capture the incoming route element as a string (this ensures that URI’s will work just as well as code-based paths that might contain other data types). Any data types you pass in the vector are converted via str, so if you need a more complicated coercion please do it before using it to change the route.

Path segments compose in the UI. In our earlier diagram the Settings component might have the route segments: ["settings"] and the User component ["user" :user-id]". The `Pane2 component might list ["pane1"]. Now, since the pane 1 component is currently nested as a target of the router underneath the settings component, we can derive that the full path to Pane 1 in this particular UI layout is ["settings" "pane1"]. This is the next critical step in our composition: Routers in a tree look for targets that can consume what remains of the path after parent targets have consumed the portion that matched those route segments.

Hopefully you can see how this directly matches the necessary logic for HTML5 URI routing. The following URIs are trivial to convert between the two forms:

"/settings/pane1"  <==>  ["settings" "pane1"]
"/user/1"          <==>  ["user" "1"]

This mechanism makes routing as simple as "read the URI, split the string, and call a function".

The function to cause a route change is:

(dr/change-route this ["user" "1"])

and it always starts from the root of your application and causes a full update of the correct route.

Notice that since the command to control the route is up to you, so is the path you pass to it. This makes it easy to do things like alias one path found in the URI to a different UI path, which is useful when you restructure the real UI but would like to maintain support for old paths that users may have bookmarked.

Additional useful functions are:

(current-route component-or-reconciler starting-component)

Returns a vector of the path components on the current (live) route starting at the given starting-component. If you use your root component it will be the absolute path, and using some other component router will give the relative path from there.

(change-route-relative this-or-reconciler relative-class-or-instance new-route timeouts)

Just like change-route, but can take a relative new-route and apply it starting and the given relative-class-or-instance. Thus, some module of a program can route in a relative manner which will further decouple the components, making it easier to use a module in a development card or refactor it to a different location in the app without breaking local concerns.

ℹ️
This library will not have any code that connects HTML5 routing events to UI routing. That is a relatively simple exercise and there are plenty of libraries that can help with the task. The logic of transforming a URI to the correct vector and calling a function is trivial, and the concern of aliasing and legacy path transforms is something you will likely want to put in the middle of that.

The will-leave method is called when a target is going to leave the screen, and may return false. If it does so AND is active on the screen then it prevents the entire route change. This allows a screen to hold up routing in case edits would be lost, etc. Of course you should do something in this method to change the UI so the user knows what is going on. This is a non-static method and receives the component, so it can transact!, etc.

TODO: Probably needs more parameters, such as the "route being attempted" in case the component wants to save it for a later "continue" operation (e.g. "Are You Sure?", "Yes").

There are times when you want to delay a route change based on some I/O operation, like a load or mutation. A router can do this via the return value of the will-enter method:

(df/route-deferred ident)

Record the fact that the route wants to change, but don’t actually apply it. The ident passed should be the ident of the component that should be routed to (of the current type).

(df/route-immediate ident)

Immediately apply the route for this router.

Of course you should not do immediate routing if the ident you’re returning does not point to something that already exists in the database. Perhaps you need to load it.

Pending routes can be completed by calling the dr/target-ready mutation with a target parameter that matches the ident you passed with route-deferred. For example, say you wanted to load a user before routing to them:

(defsc User [this props]
  {:query     [:user/id :user/name]
   :ident     [:user/id :user/id]
   :protocols [static dr/RouteTarget
               (route-segment [_] ["user" :user-id])
               (route-cancelled [{:keys [user-id] (my-abort-load (keyword "user" user-id)))
               (will-enter [_ reconciler {:keys [user-id]}]
                 (when-let [user-id (some-> user-id (js/parseInt))]
                   (dr/route-deferred [:user/id user-id]
                     #(df/load reconciler [:user/id user-id] User {:post-mutation        `dr/target-ready
                                                                   :marker               false
                                                                   :post-mutation-params {:target [:user/id user-id]}}))))]
  (dom/div ...))

Note that the route parameters come in via a map keyed by the keyword in your route-segment. Remember that the value of these elements is guaranteed to be a string, so be sure you coerce them if you need them to be a different type.

The will-enter method MUST return the value of a call to either route-immediate or route-deferred.

The mutation target-ready is meant for use with loads as a post-mutation. The assumption is that there will be some natural delay before it is called. The routers use UI state machines internally, whose event triggering works on a delay so they can be used in mutations. Unfortunately, this make it impossible to write a simple mutation helper.

If you need to signal that a target is ready from within a mutation, please use the following (non-standard) pattern instead:

(defmutation do-stuff-and-finish-routing [params]
  (action [{:keys [reconciler]}]
    (js/console.log "Pretending to do stuff to app state before finishing the route")
    (dr/target-ready! reconciler ident-of-target)))

earlier versions had a mutation helper, but that is insufficient in this case and that helper was useless (and it was removed).

The router uses a state machine internally and sets two timeouts with respect to deferred routes: and :error-timeout, and a :deferred-timeout (which can be sent with your calls to change-route). The error timeout is how long a route can be deferred before it moves to the :failed state, and the deferred timeout is how long a route can be deferred before it moves to a :pending state.

The router can be defined with custom UI for these various states using the defrouter macro, which looks much like defsc, but only allows :router-targets in the options map:

(defrouter MyRouter [this {:keys [current-state pending-path-segment route-factory route-props]}]
  {:router-targets [A B C]}
  (case current-state
    :initial (dom/div "What to show when the router is on screen but has never been asked to route")
    :pending (dom/div "Loading...")
    :failed (dom/div "Oops")}))
ℹ️
If you do not specify a body for the defrouter (e.g. (defrouter A [_ _] {:router-targets [X Y]})), then it will render whatever route is currently active instead of this "alternate" context-sensitive UI.

this is a real Fulcro component instance and turns into a defsc, but the body is only rendered in the initial/pending/failure states to do whatever you deem necessary. The options map will be passed through to defsc (though query/ident/protocols/initial-state will be overridden), so you can define React lifecycle methods and such if that is useful for your particular use-case. See the next section for critical notes, though.

The incoming props are actually generated to include a number of useful things:

  • :current-state - The current (transitory) state of the router (:initial, :pending, or :failed)

  • :pending-path-segment - Where the router is trying to go in the :pending and :failed state. This will be the concrete path segment that was requested (e.g. ["user" "1"] and not ["user" :user/id]).

  • :route-props - The props for the current (old, non-pending) route (if there was a route on-screen).

  • :route-factory - The function to call to render the current (old, non-pending) route (if there was a route on-screen).

The route factory/props are useful if you want to continue to render the "current" page even though a timeout has occurred, but perhaps you want to pass some computed data to indicate progress (e.g. (route-factory (prim/computed route-props {:waiting true}))). Of course, you can instead route-immediate, skip timeouts altogether, and do normal load markers on the target screen. The former method is useful on a deferred route that simply has nothing to render until the data is present. Regular named load markers are also a good way of giving user feedback.

You can specify overrides for the default timeouts when you call change-route:

(change-route this ["new" "path"] {:error-timeout 2000 :deferred-timeout 200})

They default to 5000ms and 100ms.

ℹ️
A deferred route that resolves after an error timeout can still auto-recover if target-ready is called after the error timeout (it will move to the correct resolved route and stop showing the error).
ℹ️
A request to change the route when a deferred route was in progress will cancel the timeouts and immediately attempt the new route.

The props seen in react lifecycles will not be what you see in the props of the router body. The props of the router body are synthesized for your convenience, but raw react lifecycles will see the low-level internal props of the router instead. The id of the router is the same as the ID for the router’s UI state machine. Using uism/current-state on that ASM will give you the current route state, and looking in that ASM’s local store will give you things like the pending segment. The ID of the active state machine will be the keyword name of the router:

(defrouter MyRouter [this props]
  {:router-target ...
   :componentDidUpdate (fn [pprops pstate]
      (let [current-state (uism/current-state this :MyRouter)
            route-props (:fulcro.incubator.dynamic-routing/current-route pprops)]
         ...))
   ...

The route defer mechanism should be sufficient to implement code splitting, where the routing target is the "join point" for the dynamic code. Basically the component would not include the code-split child in the query or UI initially, but could trigger a code load and defer routing (storing the ident in a place where the loaded code could trigger the completion of the route, and a dynamic query change of the original component to point to the newly loaded component).

Something like:

(defsc CodeSplit [this props]
  {:ident     (fn [] [:CodeSplit 1])     ; constant ident
   :query     [{:loaded-component ['*]}] ; a placeholder join. Set dynamically after code load
   :initial-state {:loaded-component {}} ; placeholder state data
   :protocols [static dr/RouteTarget
               (route-cancelled [route-params])
               (will-enter [_ reconciler _]
                 (fn []
                   (dr/route-deferred [:CodeSplit 1]
                   ;; trigger a code load
                   (loader/load :some-module)
                   ;; The loaded code would use this data (at some well-known location)
                   ;; to figure out how to set the query of CodeSplit, join up some data in app
                   ;; state, and run the target-ready mutation:
                   (swap! common-ns/pending-route-atom assoc :some-module {:reconciler reconciler
                                                                           :class CodeSplit}))))]}
   ...
   ;; The DOM can use query introspection to find the component that ended up in the query, make
   ;; a factory for it, and render it.  See how the dr/current-route-class macro for an example
   ;; of how to do that. something like:
   ;; (let [factory (some-> this prim/get-query prim/query->ast1 :component prim/factory)]
   ;;   (when factory (factory (:loaded-component props))))

TODO: A macro and small lib that wraps this concern.

TODO: A dynamic code load means that there may be path segments in the current route that cannot be evaluated until the code load is complete. It may be necessary to "re-trigger" a route after a code load to ensure that the path segments have been fully evaluated. This would be a good use of a relative change route function, which could be run on the newly-loaded sub-components with the remaining path. I think it should be relatively easy to just defer the rest of the sub-routing until the given route is resolved…​that is probably best, as it doesn’t require user intervention. The problem with that is that sub-routes may also want to queue I/O, and getting it all queued at once might be preferable to delaying. We could support something like route-blocked which would resume routing after the ready signal, and allow the route-deferred to continue down the route resolving sub-paths and queuing I/O. Undecided.

The workspaces source contains a full working example of this routing system in https://github.com/fulcrologic/fulcro-incubator/blob/develop/src/workspaces/fulcro/incubator/routing_ws.cljs