-
Notifications
You must be signed in to change notification settings - Fork 44
Routing and the module system
This wiki explains how the application detects changes to which module or submodule the user is on, and loads the appropriate code.
The code which drives our routing is fairly unsophisticated; it's a relic from the time it was written, about 2011. When the application starts, and the user logs in, we subscribe to the window onhashchange event. You can see this in framework/globalUtils/uiInitializer.es6
.
Whenever the hash changes, we first fire an event on our global message bus, and give any active application sections an opportunity to tack on their own changes, which will merge with the current hash change without creating another entry in history. Once the hash change is finalized, we notify the router.
What follows is a very rough, high-level description of how our router works. It was originally written when prototypeJS was driving our entire application; as our architecture has matured, the router has been adjusted and tweaked, while always maintaining backward compatibility. The result is that it's far more complex than it needs to be.
Modules are identified off the url hash like so
http://members.centralreach.dev/2/tasks/list
This indicates that tasks is the module, and list is the submodule therein.
When the module portion of that url changes, the router detects it, then loads the JS and html, which are always of the form 2/${module}/${module}.js
and 2/${module}/${module}.htm
, respectively. Loading is done via SystemJS for now; we plan to upgrade to webpack when able.
The html is injected into the dom at the right place, and the class or constructor function returned for the module is set up: an instance is created, and the initialize
method is called. This gives the module an opportunity to ... initialize itself.
Once a module is initialized, it's essentially responsible for any submodules that need to be loaded. Whenever the hash changes within a module, the router will call the module's smartSwitch
method. This method behaves un-intuitively; its behavior is the result of legacy cruft that serves no actual purpose today: the method receives the current module name, as it is in the hash, and returns true if that name matches the current module's name, which indicates to the router not to re-load the module that we're on.
In practice the smartSwitch method does nothing more than notify the module that the hash has changed, so that either a new submodule can be loaded, or to notify the current (and unchanged) submoudle that some url hash parameters have changed, so it can respond appropriately.
For our new, React-based modules, the submodule behavior is very simple and intuitive. In the module's initialize
method, we render our module like this
ReactDom.render(
<ModuleRouter name="tasks" moduleData={moduleData}>
<Submodule name='list' defaultSubmodule={true} load={() => System.import('tasks/list/list')}></Submodule>
<Submodule name='templates' load={() => System.import('tasks/templates/templates')}></Submodule>
<Submodule name='sandbox' load={() => System.import('tasks/sandbox/sandbox')}></Submodule>
<Submodule name='sandbox2' load={() => System.import('tasks/sandbox2/sandbox2')}></Submodule>
<Submodule name='add' load={() => System.import('tasks/add/add')}></Submodule>
<Submodule name='details' load={() => System.import('tasks/details/details')}></Submodule>
<Submodule name='settings' load={() => System.import('tasks/settings/settings')}></Submodule>
</ModuleRouter>, document.getElementById('__react_drop'));
Each submodule route component takes the submodule name, and a lambda expression producing a promise which resolves to that submodule's component. Note, the System.import
calls are specified statically for the benefit of future webpack development. The ModuleRouter
, conceptually at least, subscribes to hash changes, and passes the current hash down to all its children. When a submodule sees the submodule portion of the hash has changed to match its name, it loads it.
The System.import
will resolve a default export to the React component, and a named store
export. The store is a MobX-based class that will hold the state for the entire submodule. If you have experience with Redux, you might be used to seeing components wired up like this
@connect(stateSelector, actionCreators)
class C extends Component {
}
The MobX store replaces stateSelector
in the code above. When a submodule is first browsed to, after it imports the store class, and submodule component, it will create a new instance of the store, and render the component, passing the store to the component with the prop name of submoduleStore
, and also store
—you can use either in your component; the latter may be more descriptive, but the former was unfortunately chosen initially, and not easily removed.
When the store instance is first set up, the SubmoduleRoute component will call initialize
, if it exists. In practice though the constructor
method could be used instead.
Once the submodule has been created and set up, the SubmoduleRoute will keep track of any hash changes, and will do the following.
When the hash changes, while staying in the same submodule, the SubmoduleRoute will call processHash
on the store instance, if it exists, giving it an opportunity to respond to the changed hash.
When the hash changes, leaving the current submodule, deactivate
will be called on the store, if it exists, and the component will be hidden in the dom; style.display will be set to none. There's also an option to completely un-render the component. Hiding with css is currently the default because that's the old behavior, though un-mounting will be used as needed for performance reasons in the future.
When the hash changes back, and the submodule becomes active again, activate
will be called on the store, and the component will be shown or re-rendered.
And of course hash changes within the submodule will again cause processHash
to be called, and so on, ad infinitum.
When the entire module is exited, those submodules which have been initialized will have uninitialize
called on the store, if that method exists, as an opportunity to dispose any subscriptions, clean up resources, etc. uninitialize
on the module object itself will also be called.
It basically follows essentially the same behavior, but with Knockout viewmodels under the covers, which means there's a lot more boilerplate involved, and the code is much, much harder to follow. The curious developer is of course free to check out how it was implemented, but keep in mind this code is now legacy, and on its way out, slowly but surely. So don't spend any time trying to improve it.