Implementing a Router for bap-client

Hi,

In the <PROJECT_NAME> project we are struggling with a quite unusual scene setup. In our “Editor” activity we are using three regions to show the Views, two inside a (custom) SplitPane and one outside the pane:

…because we need to replace one View inside the SplitPane, when the user selects a “field” (marked yellow). The other two Views are staying and should not be reloaded. The resulting screen looks like this:

…The new View (marked yellow) is a FormEngine and new A12 models must be loaded and applied to this form. There are more detail types that can be opened like this, so not every detail form uses the same A12 model. In Addition, we are using the DeepLinking extension to provide a better refresh behaviour.

In the current implementation, every detail form has its own Scene in the AppModel. But since we need to be able to refresh, the other two Views (from the Editor-Scene) get added in every Detail-Scene, and the data is loaded accordingly and so on… And since we don’t want the staying Views to be reloaded if the the Detail-Scene is opened from the overview (and not by refresh), I implemented a Layout for the Regions that prevent the data reloading, if a View already exists.

This implementation feels quite hacky and I am not happy with it. Maybe someone comes up with a better idea?

Things I’ve already tried:

  • Not using the AppModel for Detail-Scene-Changes. But I failed loading A12 models into the application state that are not defined in the AppModel.

  • Using Cases inside a main Scene. But models defined in a Case don’t get loaded at all.

“This implementation feels quite hacky and I am not happy with it.”

…I have to admit that this statement is still euphemestic.

In a discussion with @baschir-loud-bluff and @conrad-solid-stream we concluded that the current implementation is definitely not an acceptable way, because it generates a potentially infinite stack of activities. Further, we pointed out, that the DeepLinking feature is the critical part that needs to be customized, and should not be circumvented by abusing the AppModel.
So I am tinkering with a custom DeepLinking extension for now, and as soon as I have figured out an implementation that suites our needs in the <PROJECT_NAME> project, I will post it to resume the discussion.

I just finished a first version of a “Router” for the <PROJECT_NAME> Editor and want to leave it here for inspiration, if someone else struggles with similar requirements.

Our primary goal in the <PROJECT_NAME> project is that the result of a site-refresh action should not be too unexpected. An ideal solution would also provide an intuitive back- and forward-navigation using the browser and show a self-descriptive URL (buzzword: Deep-Linking).

As mentioned before, the difficulties are showing up when dealing with nested activities. If not, one could also make use of the Deep-Linking-Extension. My implementation of a Router is also inspired by this extension and I am using the same Actions to trigger side-effects:

export function* routingSagas() {
  // push a state to the history when activities get added or removed
  yield takeLatest([ActivityActions.push, ActivityActions.cancel], createRoute);
  // start activities that match the current url when the application loads / reloads
  yield takeLatest(ApplicationActions.loading, applyRoute);
  // watch for foreign history state transition
  yield takeLatest(ApplicationActions.loading, registerPopstateHandler);
}

Creating a Route

Unlike with the Deep-Linking-Extension, I am using of the HTML5 History API to push new routes, which enables me to store a stack of location states. A history entry is not only an URL but also a title and a (serializable) data object, so, technically, one could put all activities in the data object of the pushed state…

history.pushState({ activities }, ...state);

But I am currently using the URL part to store all relevant informaton, so that a user can work with the URL himself (like making a bookmark or sending it to another user).

For every activity I am storing an URL that represents the corresponding scene with all needed params, for example:

{ route: "my-usecase:param1:param2", ...activityReference }

That way I can also describe nested activity concepts, like the <PROJECT_NAME> setup. Real world example:

// parent activity
{ route: "o-model:oModelId", ...activityReference }
// nested activity
{ route: "o-model:oModelId/field:omFieldId", ...activityReference }
// another nested activity
{ route: "o-model:oModelId/section:omSectionId", ...activityReference }

The createRoute Saga is looking for the most actual activity, and if none is present it will wait for the next activity to be pushed. Then it will look for a route that is matching the activity, build an URL with the descriptor parameters, and finally push a new state to the history:

function* createRoute() {
  let activity: Activity | undefined = yield select(ActivitySelectors.latestActivity());
  while (!activity) {
    yield take(ActivityActions.push);
    activity = yield select(ActivitySelectors.latestActivity());
  }
  const route = routes.find((r) => isMatchingDescriptor(r, activity.descriptor));
  if (!route) {
    throw new Error(`CreateRouteError: no matching route found for activity ${activity.id}.`);
  }
  const encodedRoute = route.replace(/\:\w*/g, (key) => `=${activity.descriptor[key.substring(1)]}`);
  const absoluteUrl: URL = new URL(`${baseClientUrl}${encodedRoute}`, location.origin);
  if (absoluteUrl.href !== location.href) {
    history.pushState({}, "", absoluteUrl.href);
  }
}

The crucial part is how the routes gets found by an activity reference. My first approach was to extend the ApplicationModel and store a route for every scene, but later I decided to introduce a custom structure element to link the route references (see last paragraph “Defining the Routes”).

However, the result is a beautiful, self-descriptive URL and a history stack:

Note that pushing a state like this does not trigger a request or reloading.

Further, I have to mention, that using this kind of URLs (without “#”) requires a server setup that will forward all URLs to the same index.html. (This is a common practice, if delivering a Single-Page-App.)

Applying a Route

When the application gets loaded or reloaded and is startet with a blank state, the applyRoute Saga should re-create the needed activities to show the scene that matches the route.

Notice how it iterates over the relevant routes …regarding the URL example above, it would search for the following routes:

  • o-model=stabs50a-2017-v_2017
  • o-model=stabs50a-2017-v_2017/field=eruStAbS50aJahr
function* applyRoute() {
  if (!location.pathname.startsWith(baseClientUrl)) {
    // not handling URLs that don't match the pattern
    return;
  }
  const fullRoute = location.pathname.substring(baseClientUrl.length);
  if (fullRoute.length === 0) {
    // not starting activities if the base URL is called
    return;
  }
  const relevantRoutes: string[] = [];
  fullRoute.split("/").reduce((accRoutes, curSplit) => {
    const relevantRoute = accRoutes.concat(curSplit);
    relevantRoutes.push(relevantRoute);
    return relevantRoute.concat("/");
  }, "");
  for (const relevantRoute of relevantRoutes) {
    const regex = new RegExp(relevantRoute.replace(/\=[^\=\/]*/g, "\\:\\w*"));
    const route = routes.find((r) => Boolean(r.match(regex)));
    if (!route) {
      throw new Error(`ApplyRouteError: ${relevantRoute} is not a defined route.`);
    }
    const params = {};
    const values = relevantRoute.match(/\=[^\=\/]*/g);
    const keys = route.match(/\:\w*/g);
    if (values && keys) {
      keys.forEach((key, i) => {
        Object.assign(params, { [key.substring(1)]: values[i].substring(1) });
      });
    }
    yield put(ActivityActions.create({ activityDescriptor: getDescriptor(route, params) }));
  }
}

This implementation is working, but has the potential for improvement…

  1. The Error-Handling could also (or maybe should) trigger a 404 Site.

  2. The creation of the descriptor (especially its params) relies heavily on the consistency of the routes with the activity descriptor. Thats why I felt like I needed a better structure for the relating code parts…

Defining the Routes (and Structuring the Code)

The mentioned consistency is not the only reason for the following structure changes. I personally have a desire to structure my code as domain-driven as possible and I always stumbled on the following related code parts:

  • The ApplicationModel: I don’t want to define a monolytic representation model of my whole application, but rather divide it into the scenes that belong to specific domains (or usecases, or components).
  • The Actions that create new activities: I found that a descriptor looks always the same for a certain activity, but it relies on some magic strings defined in the ApplicationModel (for example the name of model).
  • The Views defined in a scene: Again strings that need to match exactly and are defined in two independent locations.
  • The Sagas and DataLoaders that handle the ActivityActions: For nearly all activities I am writing at least a Saga that handles the loading-action and I sometimes forget to register it.
  • Conditions defined in these Sagas: In the canHandle function I am writing conditions that mostly match the scenes matchConditions and the activity descriptor too.

I don’t want to start a discussion here, these statements are the result of my very subjective thoughts. Nevertheless, it seems to me like a good practice to gather all these parts and put it into the appropriate usecase directory, in the context of a route:

interface Route<Params> {
  scene: ApplicationModel.Scene;
  route: string;
  containerMap: { [name: string]: React.ComponentType<View> };
  dataLoaders: DataLoader[];
  platformSagas: ApplicationSaga.Descriptor[];
  customSagas: (() => SagaIterator)[];
  getDescriptor(params: Params): Activity.Descriptor;
  isMatchingDescriptor(descriptor: Activity.Descriptor): boolean;
}

Listening to popstate

At this point our primary needs are already satisfied, but building up an application-intern history of routes leads to the assumption that a user can use it for navigation.

There is a built-in event handler that can be implemented to react to back- and forward-navigation. I guess, it would be possible to reuse existing activities and cancel or push only the activities that are affected, but I implemented a very simple solution that suites our requirements for now:

function registerPopstateHandler() {
  window.onpopstate = () => {
    location.reload();
  };
}

Wow, impressive :smiley: