A stateful router for single page applications.
Abyssa is a stateful, hierarchical client side router.
It is meant to be used in those single page apps where using a big, comprehensive framework is unwanted: You wish to keep absolute control
of your data, the DOM, and only willing to use a few libraries at most.
This would be applications with a high performance risk (Complex requirements, High frequency of DOM updates (From SSEs, websockets, animations, etc), mobile apps and so on.
For straightforward CRUDS and frankly, many other kinds of apps, a good framework like AngularJS will probably make your life easier without much compromises.
'Stateful' is a swear word for servers but modern clients just love state! Mainframe terminals no longer cut it.
State on the client can greatly improve the user's experience by providing immediate feedback and minimizing server roundtrips.
On the other hand, smart clients with a lot of state are obviously more difficult to code, maintain and debug;
A stateful router can help you manage this complexity by compartmentalizing the app's states, while keeping your URLs in sync with all the different states. It will
also take the complexity of transition asynchronicity and cancellation away from you.
Stateful routing powered clients are also more performant, as they avoid doing wasteful work as much as possible by extracting commonalities into parent states.
For instance, two child states could share 90% of their layout and only concentrate on the differences. The same principle applies when sharing data between states.
Read this excellent blog post for an in-depth explanation: Make the most of your routes
Simple demo application (the server may take a bit of time to start): Abyssa demo
Source code: Abyssa demo source
Example using the monolithic, declarative notation:
Router({
home: State({
enter: function() { console.log('We are home'); }
}),
articles: State('articles', {
item: State(':id?filter', {
enterPrereqs: function(params) {
return $.getJSON('api/articles/' + params.id);
},
enter: function(params, article) {
// params.id is the article's id
// params.filter is the query string value for the key 'filter' (Or undefined if unavailable)
// article is the JSON representation of an article, ready to be rendered
}
}
}
}).init();
Initialize and freeze the router (states can not be added afterwards).
The router will immediately initiate a transition to, in order of priority:
- The state captured by the current URL
- The init state passed as an argument
- The default state (pathless and queryless)
Add a new root state to the router.
Request a programmatic state change.
Only leaf states can be transitionned to.
While you can change state programmatically, keep in mind the most idiomatic way to do it is using anchor tags with the proper href.
Two notations are supported:
// Fully qualified state name
state('my.target.state', {id: 33, filter: 'desc'})
// Path and (optionally) query
state('target/33?filter=desc')
Compute a link that can be used in anchors' href attributes
from a state name and a list of params, a.k.a reverse routing.
The router dispatches some signals. The signals' API is: add
, addOnce
and remove
.
All signals receive the current state and the next state as arguments.
Dispatched when a transition started.
Dispatched when a transition either completed, failed or got cancelled.
Dispatched when a transition successfuly completed
Dispatched when a transition failed to complete
Dispatched when a transition got cancelled
Dispatched once after the router successfully reached its initial state.
// Create a router with one state named 'index'.
var router = Router({
index: State()
});
States represent path segments of an url.
Additionally, a state can own a list of query params: While all states will be able to read these params, isolated changes to these
will only trigger a transition up to the state owning them (it will be exited and re-entered). The same applies to dynamic query params.
How much you decompose your applications into states is completely up to you;
For instance you could have just one state (A la stateless):
State('some/path/:slug/:id')
Just like you could have four different states to break down the complexity of that one path combination:
State('some')
State('path')
State(':slug')
State(':id')
Add a child state
Get or Set some data by key on this state.
child states have access to their parents' data.
This can be useful when using external models/services as a mean to communicate between states is not desired.
Specify a function that should be called when the state is entered.
The params are the dynamic params (path and query alike in one object) of the current url.
userData
is any data you returned in enterPrereqs, or the resolved value of a promise if you did return one.
This is where you could render the data into the DOM or do some general work once for many child states.
Specify a prerequisite that must be satisfied before the state is entered.
It can be any value or a promise. Should the promise get rejected, the state will never be entered and the router will remain in its current state.
Examples of enterPrereqs include fetching an html template file, data via ajax/local cache or get some prerendered html from the server.
Same as the enter function but called when the state is exited. This is where you could teardown any state or side effects introduced by the enter function, if needed.
Same as enterPrereqs but for the exit phase.
An example of an exitPrereqs would be a prompt (Backed by a promise) asking the user if she wants to leave the screen with unsaved data.
A state represented by the path "articles", with a child state named "item" represented by the dynamic path "id".
When the router is in the state "articles.item" with the id param equal to 33, the browser url is http://domain/articles/33
var router = Router({
articles: State('articles', {
item: State(':id', {
// state definition
})
}).init();
This is equivalent to:
var router = Router();
var articles = State('articles');
articles.addState('item', State(':id'));
router.init();
A state represented by the path "articles" with a path-less child state named "item"
When the router is in the state "articles.show", the browser url is http://domain/articles
var state = State('articles': {
show: State()
});
router.addState('articles', state);
Now the articles state also tells us it owns the query param named 'filter' in its state hierarchy.
This means any isolated change to the filter query param (Added, removed or changed) is going to make that state exit and re-enter so that it can take action with that new filter.
var state = State('articles?filter': {
show: State()
});
You can set arbitrary data declaratively instead of using the data()
method by just specifying a custom property in the State options.
This data can be read by all descendant states (Using this.data('myArbitraryData')
) and from signal handlers.
var state = State('articles?filter': {
myArbitraryData: 66,
show: State()
});
router.transition.ended.add(function(oldState, newState) {
// Do something based on newState.data('myArbitraryData')
});
Options
var state = State('articles': {
enter: function(params, ajaxData) { console.log('articles entered'); },
exit: function() { console.log('articles exit'); },
enterPrereqs: function(params) { return $.ajax(...); },
item: State(':id', {
enter: function(params) { console.log('item entered with id ' + params.id); }
})
});
None
history.js (devote/HTML5-History-API): Used to support HTML4 browsers and abstract the HTML5 history implementation differences.
crossroads.js: A stateless, solid traditional low level router. Abyssa builds on top of it.
signals.js: A dependency of crossroads.js; Provide an alternative to string based events. Abyssa uses signals instead of events.
when.js: A small and solid implementation of Promise/A+. This is used internally to orchestrate asynchronous behaviors.
Tested with most modern browsers, IE9 and IE8 (with the inclusion of proper es5 shims).