summaryrefslogtreecommitdiffstats
path: root/packages/kadira-flow-router/client/router.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/kadira-flow-router/client/router.js')
-rw-r--r--packages/kadira-flow-router/client/router.js587
1 files changed, 587 insertions, 0 deletions
diff --git a/packages/kadira-flow-router/client/router.js b/packages/kadira-flow-router/client/router.js
new file mode 100644
index 00000000..ae91751f
--- /dev/null
+++ b/packages/kadira-flow-router/client/router.js
@@ -0,0 +1,587 @@
+Router = function () {
+ var self = this;
+ this.globals = [];
+ this.subscriptions = Function.prototype;
+
+ this._tracker = this._buildTracker();
+ this._current = {};
+
+ // tracks the current path change
+ this._onEveryPath = new Tracker.Dependency();
+
+ this._globalRoute = new Route(this);
+
+ // holds onRoute callbacks
+ this._onRouteCallbacks = [];
+
+ // if _askedToWait is true. We don't automatically start the router
+ // in Meteor.startup callback. (see client/_init.js)
+ // Instead user need to call `.initialize()
+ this._askedToWait = false;
+ this._initialized = false;
+ this._triggersEnter = [];
+ this._triggersExit = [];
+ this._routes = [];
+ this._routesMap = {};
+ this._updateCallbacks();
+ this.notFound = this.notfound = null;
+ // indicate it's okay (or not okay) to run the tracker
+ // when doing subscriptions
+ // using a number and increment it help us to support FlowRouter.go()
+ // and legitimate reruns inside tracker on the same event loop.
+ // this is a solution for #145
+ this.safeToRun = 0;
+
+ // Meteor exposes to the client the path prefix that was defined using the
+ // ROOT_URL environement variable on the server using the global runtime
+ // configuration. See #315.
+ this._basePath = __meteor_runtime_config__.ROOT_URL_PATH_PREFIX || '';
+
+ // this is a chain contains a list of old routes
+ // most of the time, there is only one old route
+ // but when it's the time for a trigger redirect we've a chain
+ this._oldRouteChain = [];
+
+ this.env = {
+ replaceState: new Meteor.EnvironmentVariable(),
+ reload: new Meteor.EnvironmentVariable(),
+ trailingSlash: new Meteor.EnvironmentVariable()
+ };
+
+ // redirect function used inside triggers
+ this._redirectFn = function(pathDef, fields, queryParams) {
+ if (/^http(s)?:\/\//.test(pathDef)) {
+ var message = "Redirects to URLs outside of the app are not supported in this version of Flow Router. Use 'window.location = yourUrl' instead";
+ throw new Error(message);
+ }
+ self.withReplaceState(function() {
+ var path = FlowRouter.path(pathDef, fields, queryParams);
+ self._page.redirect(path);
+ });
+ };
+ this._initTriggersAPI();
+};
+
+Router.prototype.route = function(pathDef, options, group) {
+ if (!/^\/.*/.test(pathDef)) {
+ var message = "route's path must start with '/'";
+ throw new Error(message);
+ }
+
+ options = options || {};
+ var self = this;
+ var route = new Route(this, pathDef, options, group);
+
+ // calls when the page route being activates
+ route._actionHandle = function (context, next) {
+ var oldRoute = self._current.route;
+ self._oldRouteChain.push(oldRoute);
+
+ var queryParams = self._qs.parse(context.querystring);
+ // _qs.parse() gives us a object without prototypes,
+ // created with Object.create(null)
+ // Meteor's check doesn't play nice with it.
+ // So, we need to fix it by cloning it.
+ // see more: https://github.com/meteorhacks/flow-router/issues/164
+ queryParams = JSON.parse(JSON.stringify(queryParams));
+
+ self._current = {
+ path: context.path,
+ context: context,
+ params: context.params,
+ queryParams: queryParams,
+ route: route,
+ oldRoute: oldRoute
+ };
+
+ // we need to invalidate if all the triggers have been completed
+ // if not that means, we've been redirected to another path
+ // then we don't need to invalidate
+ var afterAllTriggersRan = function() {
+ self._invalidateTracker();
+ };
+
+ var triggers = self._triggersEnter.concat(route._triggersEnter);
+ Triggers.runTriggers(
+ triggers,
+ self._current,
+ self._redirectFn,
+ afterAllTriggersRan
+ );
+ };
+
+ // calls when you exit from the page js route
+ route._exitHandle = function(context, next) {
+ var triggers = self._triggersExit.concat(route._triggersExit);
+ Triggers.runTriggers(
+ triggers,
+ self._current,
+ self._redirectFn,
+ next
+ );
+ };
+
+ this._routes.push(route);
+ if (options.name) {
+ this._routesMap[options.name] = route;
+ }
+
+ this._updateCallbacks();
+ this._triggerRouteRegister(route);
+
+ return route;
+};
+
+Router.prototype.group = function(options) {
+ return new Group(this, options);
+};
+
+Router.prototype.path = function(pathDef, fields, queryParams) {
+ if (this._routesMap[pathDef]) {
+ pathDef = this._routesMap[pathDef].pathDef;
+ }
+
+ var path = "";
+
+ // Prefix the path with the router global prefix
+ if (this._basePath) {
+ path += "/" + this._basePath + "/";
+ }
+
+ fields = fields || {};
+ var regExp = /(:[\w\(\)\\\+\*\.\?]+)+/g;
+ path += pathDef.replace(regExp, function(key) {
+ var firstRegexpChar = key.indexOf("(");
+ // get the content behind : and (\\d+/)
+ key = key.substring(1, (firstRegexpChar > 0)? firstRegexpChar: undefined);
+ // remove +?*
+ key = key.replace(/[\+\*\?]+/g, "");
+
+ // this is to allow page js to keep the custom characters as it is
+ // we need to encode 2 times otherwise "/" char does not work properly
+ // So, in that case, when I includes "/" it will think it's a part of the
+ // route. encoding 2times fixes it
+ return encodeURIComponent(encodeURIComponent(fields[key] || ""));
+ });
+
+ // Replace multiple slashes with single slash
+ path = path.replace(/\/\/+/g, "/");
+
+ // remove trailing slash
+ // but keep the root slash if it's the only one
+ path = path.match(/^\/{1}$/) ? path: path.replace(/\/$/, "");
+
+ // explictly asked to add a trailing slash
+ if(this.env.trailingSlash.get() && _.last(path) !== "/") {
+ path += "/";
+ }
+
+ var strQueryParams = this._qs.stringify(queryParams || {});
+ if(strQueryParams) {
+ path += "?" + strQueryParams;
+ }
+
+ return path;
+};
+
+Router.prototype.go = function(pathDef, fields, queryParams) {
+ var path = this.path(pathDef, fields, queryParams);
+
+ var useReplaceState = this.env.replaceState.get();
+ if(useReplaceState) {
+ this._page.replace(path);
+ } else {
+ this._page(path);
+ }
+};
+
+Router.prototype.reload = function() {
+ var self = this;
+
+ self.env.reload.withValue(true, function() {
+ self._page.replace(self._current.path);
+ });
+};
+
+Router.prototype.redirect = function(path) {
+ this._page.redirect(path);
+};
+
+Router.prototype.setParams = function(newParams) {
+ if(!this._current.route) {return false;}
+
+ var pathDef = this._current.route.pathDef;
+ var existingParams = this._current.params;
+ var params = {};
+ _.each(_.keys(existingParams), function(key) {
+ params[key] = existingParams[key];
+ });
+
+ params = _.extend(params, newParams);
+ var queryParams = this._current.queryParams;
+
+ this.go(pathDef, params, queryParams);
+ return true;
+};
+
+Router.prototype.setQueryParams = function(newParams) {
+ if(!this._current.route) {return false;}
+
+ var queryParams = _.clone(this._current.queryParams);
+ _.extend(queryParams, newParams);
+
+ for (var k in queryParams) {
+ if (queryParams[k] === null || queryParams[k] === undefined) {
+ delete queryParams[k];
+ }
+ }
+
+ var pathDef = this._current.route.pathDef;
+ var params = this._current.params;
+ this.go(pathDef, params, queryParams);
+ return true;
+};
+
+// .current is not reactive
+// This is by design. use .getParam() instead
+// If you really need to watch the path change, use .watchPathChange()
+Router.prototype.current = function() {
+ // We can't trust outside, that's why we clone this
+ // Anyway, we can't clone the whole object since it has non-jsonable values
+ // That's why we clone what's really needed.
+ var current = _.clone(this._current);
+ current.queryParams = EJSON.clone(current.queryParams);
+ current.params = EJSON.clone(current.params);
+ return current;
+};
+
+// Implementing Reactive APIs
+var reactiveApis = [
+ 'getParam', 'getQueryParam',
+ 'getRouteName', 'watchPathChange'
+];
+reactiveApis.forEach(function(api) {
+ Router.prototype[api] = function(arg1) {
+ // when this is calling, there may not be any route initiated
+ // so we need to handle it
+ var currentRoute = this._current.route;
+ if(!currentRoute) {
+ this._onEveryPath.depend();
+ return;
+ }
+
+ // currently, there is only one argument. If we've more let's add more args
+ // this is not clean code, but better in performance
+ return currentRoute[api].call(currentRoute, arg1);
+ };
+});
+
+Router.prototype.subsReady = function() {
+ var callback = null;
+ var args = _.toArray(arguments);
+
+ if (typeof _.last(args) === "function") {
+ callback = args.pop();
+ }
+
+ var currentRoute = this.current().route;
+ var globalRoute = this._globalRoute;
+
+ // we need to depend for every route change and
+ // rerun subscriptions to check the ready state
+ this._onEveryPath.depend();
+
+ if(!currentRoute) {
+ return false;
+ }
+
+ var subscriptions;
+ if(args.length === 0) {
+ subscriptions = _.values(globalRoute.getAllSubscriptions());
+ subscriptions = subscriptions.concat(_.values(currentRoute.getAllSubscriptions()));
+ } else {
+ subscriptions = _.map(args, function(subName) {
+ return globalRoute.getSubscription(subName) || currentRoute.getSubscription(subName);
+ });
+ }
+
+ var isReady = function() {
+ var ready = _.every(subscriptions, function(sub) {
+ return sub && sub.ready();
+ });
+
+ return ready;
+ };
+
+ if (callback) {
+ Tracker.autorun(function(c) {
+ if (isReady()) {
+ callback();
+ c.stop();
+ }
+ });
+ } else {
+ return isReady();
+ }
+};
+
+Router.prototype.withReplaceState = function(fn) {
+ return this.env.replaceState.withValue(true, fn);
+};
+
+Router.prototype.withTrailingSlash = function(fn) {
+ return this.env.trailingSlash.withValue(true, fn);
+};
+
+Router.prototype._notfoundRoute = function(context) {
+ this._current = {
+ path: context.path,
+ context: context,
+ params: [],
+ queryParams: {},
+ };
+
+ // XXX this.notfound kept for backwards compatibility
+ this.notFound = this.notFound || this.notfound;
+ if(!this.notFound) {
+ console.error("There is no route for the path:", context.path);
+ return;
+ }
+
+ this._current.route = new Route(this, "*", this.notFound);
+ this._invalidateTracker();
+};
+
+Router.prototype.initialize = function(options) {
+ options = options || {};
+
+ if(this._initialized) {
+ throw new Error("FlowRouter is already initialized");
+ }
+
+ var self = this;
+ this._updateCallbacks();
+
+ // Implementing idempotent routing
+ // by overriding page.js`s "show" method.
+ // Why?
+ // It is impossible to bypass exit triggers,
+ // because they execute before the handler and
+ // can not know what the next path is, inside exit trigger.
+ //
+ // we need override both show, replace to make this work
+ // since we use redirect when we are talking about withReplaceState
+ _.each(['show', 'replace'], function(fnName) {
+ var original = self._page[fnName];
+ self._page[fnName] = function(path, state, dispatch, push) {
+ var reload = self.env.reload.get();
+ if (!reload && self._current.path === path) {
+ return;
+ }
+
+ original.call(this, path, state, dispatch, push);
+ };
+ });
+
+ // this is very ugly part of pagejs and it does decoding few times
+ // in unpredicatable manner. See #168
+ // this is the default behaviour and we need keep it like that
+ // we are doing a hack. see .path()
+ this._page.base(this._basePath);
+ this._page({
+ decodeURLComponents: true,
+ hashbang: !!options.hashbang
+ });
+
+ this._initialized = true;
+};
+
+Router.prototype._buildTracker = function() {
+ var self = this;
+
+ // main autorun function
+ var tracker = Tracker.autorun(function () {
+ if(!self._current || !self._current.route) {
+ return;
+ }
+
+ // see the definition of `this._processingContexts`
+ var currentContext = self._current;
+ var route = currentContext.route;
+ var path = currentContext.path;
+
+ if(self.safeToRun === 0) {
+ var message =
+ "You can't use reactive data sources like Session" +
+ " inside the `.subscriptions` method!";
+ throw new Error(message);
+ }
+
+ // We need to run subscriptions inside a Tracker
+ // to stop subs when switching between routes
+ // But we don't need to run this tracker with
+ // other reactive changes inside the .subscription method
+ // We tackle this with the `safeToRun` variable
+ self._globalRoute.clearSubscriptions();
+ self.subscriptions.call(self._globalRoute, path);
+ route.callSubscriptions(currentContext);
+
+ // otherwise, computations inside action will trigger to re-run
+ // this computation. which we do not need.
+ Tracker.nonreactive(function() {
+ var isRouteChange = currentContext.oldRoute !== currentContext.route;
+ var isFirstRoute = !currentContext.oldRoute;
+ // first route is not a route change
+ if(isFirstRoute) {
+ isRouteChange = false;
+ }
+
+ // Clear oldRouteChain just before calling the action
+ // We still need to get a copy of the oldestRoute first
+ // It's very important to get the oldest route and registerRouteClose() it
+ // See: https://github.com/kadirahq/flow-router/issues/314
+ var oldestRoute = self._oldRouteChain[0];
+ self._oldRouteChain = [];
+
+ currentContext.route.registerRouteChange(currentContext, isRouteChange);
+ route.callAction(currentContext);
+
+ Tracker.afterFlush(function() {
+ self._onEveryPath.changed();
+ if(isRouteChange) {
+ // We need to trigger that route (definition itself) has changed.
+ // So, we need to re-run all the register callbacks to current route
+ // This is pretty important, otherwise tracker
+ // can't identify new route's items
+
+ // We also need to afterFlush, otherwise this will re-run
+ // helpers on templates which are marked for destroying
+ if(oldestRoute) {
+ oldestRoute.registerRouteClose();
+ }
+ }
+ });
+ });
+
+ self.safeToRun--;
+ });
+
+ return tracker;
+};
+
+Router.prototype._invalidateTracker = function() {
+ var self = this;
+ this.safeToRun++;
+ this._tracker.invalidate();
+ // After the invalidation we need to flush to make changes imediately
+ // otherwise, we have face some issues context mix-maches and so on.
+ // But there are some cases we can't flush. So we need to ready for that.
+
+ // we clearly know, we can't flush inside an autorun
+ // this may leads some issues on flow-routing
+ // we may need to do some warning
+ if(!Tracker.currentComputation) {
+ // Still there are some cases where we can't flush
+ // eg:- when there is a flush currently
+ // But we've no public API or hacks to get that state
+ // So, this is the only solution
+ try {
+ Tracker.flush();
+ } catch(ex) {
+ // only handling "while flushing" errors
+ if(!/Tracker\.flush while flushing/.test(ex.message)) {
+ return;
+ }
+
+ // XXX: fix this with a proper solution by removing subscription mgt.
+ // from the router. Then we don't need to run invalidate using a tracker
+
+ // this happens when we are trying to invoke a route change
+ // with inside a route chnage. (eg:- Template.onCreated)
+ // Since we use page.js and tracker, we don't have much control
+ // over this process.
+ // only solution is to defer route execution.
+
+ // It's possible to have more than one path want to defer
+ // But, we only need to pick the last one.
+ // self._nextPath = self._current.path;
+ Meteor.defer(function() {
+ var path = self._nextPath;
+ if(!path) {
+ return;
+ }
+
+ delete self._nextPath;
+ self.env.reload.withValue(true, function() {
+ self.go(path);
+ });
+ });
+ }
+ }
+};
+
+Router.prototype._updateCallbacks = function () {
+ var self = this;
+
+ self._page.callbacks = [];
+ self._page.exits = [];
+
+ _.each(self._routes, function(route) {
+ self._page(route.pathDef, route._actionHandle);
+ self._page.exit(route.pathDef, route._exitHandle);
+ });
+
+ self._page("*", function(context) {
+ self._notfoundRoute(context);
+ });
+};
+
+Router.prototype._initTriggersAPI = function() {
+ var self = this;
+ this.triggers = {
+ enter: function(triggers, filter) {
+ triggers = Triggers.applyFilters(triggers, filter);
+ if(triggers.length) {
+ self._triggersEnter = self._triggersEnter.concat(triggers);
+ }
+ },
+
+ exit: function(triggers, filter) {
+ triggers = Triggers.applyFilters(triggers, filter);
+ if(triggers.length) {
+ self._triggersExit = self._triggersExit.concat(triggers);
+ }
+ }
+ };
+};
+
+Router.prototype.wait = function() {
+ if(this._initialized) {
+ throw new Error("can't wait after FlowRouter has been initialized");
+ }
+
+ this._askedToWait = true;
+};
+
+Router.prototype.onRouteRegister = function(cb) {
+ this._onRouteCallbacks.push(cb);
+};
+
+Router.prototype._triggerRouteRegister = function(currentRoute) {
+ // We should only need to send a safe set of fields on the route
+ // object.
+ // This is not to hide what's inside the route object, but to show
+ // these are the public APIs
+ var routePublicApi = _.pick(currentRoute, 'name', 'pathDef', 'path');
+ var omittingOptionFields = [
+ 'triggersEnter', 'triggersExit', 'action', 'subscriptions', 'name'
+ ];
+ routePublicApi.options = _.omit(currentRoute.options, omittingOptionFields);
+
+ _.each(this._onRouteCallbacks, function(cb) {
+ cb(routePublicApi);
+ });
+};
+
+Router.prototype._page = page;
+Router.prototype._qs = qs;