abstract-state-router
Version:
Like ui-router, but without all the Angular. The best way to structure a single-page webapp.
701 lines (578 loc) • 20.3 kB
JavaScript
import combine from 'combine-arrays';
import pathToRegexp from 'path-to-regexp-with-reversible-keys';
import denodeify from 'then-denodeify';
import EventEmitter from 'eventemitter3';
import newHashBrownRouter from 'hash-brown-router';
import buildPath from 'page-path-builder';
import nextTick from 'iso-next-tick';
var parse = stateString => stateString.split(`.`).reduce((stateNames, latestNameChunk) => {
stateNames.push(
stateNames.length
? `${stateNames[stateNames.length - 1] }.${ latestNameChunk}`
: latestNameChunk,
);
return stateNames
}, []);
function StateState() {
const states = {};
function getHierarchy(name) {
const names = parse(name);
return names.map(name => {
if (!states[name]) {
throw new Error(`State ${ name } not found`)
}
return states[name]
})
}
function getParent(name) {
const parentName = getParentName(name);
return parentName && states[parentName]
}
function getParentName(name) {
const names = parse(name);
if (names.length > 1) {
const secondToLast = names.length - 2;
return names[secondToLast]
} else {
return null
}
}
function guaranteeAllStatesExist(newStateName) {
const stateNames = parse(newStateName);
const statesThatDontExist = stateNames.filter(name => !states[name]);
if (statesThatDontExist.length > 0) {
throw new Error(`State ${ statesThatDontExist[statesThatDontExist.length - 1] } does not exist`)
}
}
function buildFullStateRoute(stateName) {
return getHierarchy(stateName).map(state => `/${ state.route || `` }`)
.join(``)
.replace(/\/{2,}/g, `/`)
}
function applyDefaultChildStates(stateName) {
const state = states[stateName];
const defaultChildStateName = state && (
typeof state.defaultChild === `function`
? state.defaultChild()
: state.defaultChild
);
if (!defaultChildStateName) {
return stateName
}
const fullStateName = `${ stateName }.${ defaultChildStateName }`;
return applyDefaultChildStates(fullStateName)
}
return {
add(name, state) {
states[name] = state;
},
get(name) {
return name && states[name]
},
getHierarchy,
getParent,
getParentName,
guaranteeAllStatesExist,
buildFullStateRoute,
applyDefaultChildStates,
}
}
function StateComparison(stateState) {
const getPathParameters = pathParameters();
const parametersChanged = args => parametersThatMatterWereChanged({ ...args, stateState, getPathParameters });
return args => stateComparison({ ...args, parametersChanged })
}
function pathParameters() {
const parameters = {};
return path => {
if (!path) {
return []
}
if (!parameters[path]) {
parameters[path] = pathToRegexp(path).keys.map(key => key.name);
}
return parameters[path]
}
}
function parametersThatMatterWereChanged({ stateState, getPathParameters, stateName, fromParameters, toParameters }) {
const state = stateState.get(stateName);
const querystringParameters = state.querystringParameters || [];
const parameters = getPathParameters(state.route).concat(querystringParameters);
return Array.isArray(parameters) && parameters.some(
key => fromParameters[key] !== toParameters[key],
)
}
function stateComparison({ parametersChanged, original, destination }) {
const states = combine({
start: parse(original.name),
end: parse(destination.name),
});
return states.map(({ start, end }) => ({
nameBefore: start,
nameAfter: end,
stateNameChanged: start !== end,
stateParametersChanged: start === end && parametersChanged({
stateName: start,
fromParameters: original.parameters,
toParameters: destination.parameters,
}),
}))
}
function CurrentState() {
let current = {
name: ``,
parameters: {},
};
return {
get() {
return current
},
set(name, parameters) {
current = {
name,
parameters,
};
},
}
}
function stateChangeLogic(stateComparisonResults) {
let hitDestroyedState = false;
const output = {
destroy: [],
create: [],
};
stateComparisonResults.forEach(state => {
hitDestroyedState = hitDestroyedState || state.stateNameChanged || state.stateParametersChanged;
if (state.nameBefore && hitDestroyedState) {
output.destroy.push(state.nameBefore);
}
if (state.nameAfter && hitDestroyedState) {
output.create.push(state.nameAfter);
}
});
return output
}
var StateTransitionManager = emitter => {
let currentTransitionAttempt = null;
let nextTransition = null;
function doneTransitioning() {
currentTransitionAttempt = null;
if (nextTransition) {
beginNextTransitionAttempt();
}
}
const isTransitioning = () => !!currentTransitionAttempt;
function beginNextTransitionAttempt() {
currentTransitionAttempt = nextTransition;
nextTransition = null;
currentTransitionAttempt.beginStateChange();
}
function cancelCurrentTransition() {
currentTransitionAttempt.transition.cancelled = true;
const err = new Error(`State transition cancelled by the state transition manager`);
err.wasCancelledBySomeoneElse = true;
emitter.emit(`stateChangeCancelled`, err);
}
emitter.on(`stateChangeAttempt`, beginStateChange => {
nextTransition = createStateTransitionAttempt(beginStateChange);
if (isTransitioning() && currentTransitionAttempt.transition.cancellable) {
cancelCurrentTransition();
} else if (!isTransitioning()) {
beginNextTransitionAttempt();
}
});
emitter.on(`stateChangeError`, doneTransitioning);
emitter.on(`stateChangeCancelled`, doneTransitioning);
emitter.on(`stateChangeEnd`, doneTransitioning);
function createStateTransitionAttempt(beginStateChange) {
const transition = {
cancelled: false,
cancellable: true,
};
return {
transition,
beginStateChange: (...args) => beginStateChange(transition, ...args),
}
}
};
var defaultRouterOptions = { reverse: false };
// Pulled from https://github.com/joliss/promise-map-series and prettied up a bit
function sequence(array, iterator) {
let currentPromise = Promise.resolve();
return Promise.all(
array.map((value, i) => currentPromise = currentPromise.then(() => iterator(value, i, array))),
)
}
const getProperty = name => obj => obj[name];
const reverse = ary => ary.slice().reverse();
const isFunction = property => obj => typeof obj[property] === `function`;
const expectedPropertiesOfAddState = [ `name`, `route`, `defaultChild`, `data`, `template`, `resolve`, `activate`, `querystringParameters`, `defaultParameters`, `canLeaveState` ];
function StateProvider(makeRenderer, rootElement, stateRouterOptions = {}) {
const prototypalStateHolder = StateState();
const lastCompletelyLoadedState = CurrentState();
const lastStateStartedActivating = CurrentState();
const stateProviderEmitter = new EventEmitter();
const compareStartAndEndStates = StateComparison(prototypalStateHolder);
const stateNameToArrayofStates = stateName => parse(stateName).map(prototypalStateHolder.get);
StateTransitionManager(stateProviderEmitter);
const { throwOnError, pathPrefix } = {
throwOnError: true,
pathPrefix: `#`,
...stateRouterOptions,
};
const router = stateRouterOptions.router || newHashBrownRouter(defaultRouterOptions);
router.on(`not found`, (route, parameters) => {
stateProviderEmitter.emit(`routeNotFound`, route, parameters);
});
let destroyDom = null;
let getDomChild = null;
let renderDom = null;
let activeStateResolveContent = {};
const activeDomApis = {};
const activeEmitters = {};
function handleError(event, err) {
nextTick(() => {
stateProviderEmitter.emit(event, err);
console.error(`${ event } - ${ err.message }`);
if (throwOnError) {
throw err
}
});
}
async function destroyStateName(stateName) {
const state = prototypalStateHolder.get(stateName);
stateProviderEmitter.emit(`beforeDestroyState`, {
state,
domApi: activeDomApis[stateName],
});
activeEmitters[stateName].emit(`destroy`);
activeEmitters[stateName].removeAllListeners();
delete activeEmitters[stateName];
delete activeStateResolveContent[stateName];
await destroyDom(activeDomApis[stateName], { name: stateName });
delete activeDomApis[stateName];
stateProviderEmitter.emit(`afterDestroyState`, {
state,
});
}
async function getChildElementForStateName(stateName) {
const parent = prototypalStateHolder.getParent(stateName);
if (parent) {
const childDomApi = await getDomChild(activeDomApis[parent.name], { name: parent.name });
if (!childDomApi) {
throw new Error(`getDomChild returned a falsey element, did you forget to add a place for a child state to go?`)
}
return childDomApi
} else {
return rootElement
}
}
async function renderStateName(parameters, stateName) {
const element = await getChildElementForStateName(stateName);
const state = prototypalStateHolder.get(stateName);
const content = getContentObject(activeStateResolveContent, stateName);
stateProviderEmitter.emit(`beforeCreateState`, {
state,
content,
parameters,
});
const domApi = await renderDom({
template: state.template,
element,
content,
parameters,
name: stateName,
});
activeDomApis[stateName] = domApi;
stateProviderEmitter.emit(`afterCreateState`, {
state,
domApi,
content,
parameters,
});
return domApi
}
function renderAll(stateNames, parameters) {
return sequence(stateNames, stateName => renderStateName(parameters, stateName))
}
function statesAreEquivalent(stateA, stateB) {
const { create, destroy } = stateChangeLogic(
compareStartAndEndStates({
original: stateA,
destination: stateB,
}),
);
return create.length === 0 && destroy.length === 0
}
function allowStateChangeOrRevert(newStateName, newParameters) {
const lastState = lastCompletelyLoadedState.get();
if (lastState.name && statesAreEquivalent(lastState, lastStateStartedActivating.get())) {
const { destroy } = stateChangeLogic(
compareStartAndEndStates({
original: lastState,
destination: {
name: newStateName,
parameters: newParameters,
},
}),
);
const canLeaveStates = destroy.every(stateName => {
const state = prototypalStateHolder.get(stateName);
if (state.canLeaveState && typeof state.canLeaveState === 'function') {
const stateChangeAllowed = state.canLeaveState(activeDomApis[stateName], {
name: newStateName,
parameters: newParameters,
});
if (!stateChangeAllowed) {
stateProviderEmitter.emit('stateChangePrevented', {
name: stateName,
parameters: lastState.parameters,
}, {
name: newStateName,
parameters: newParameters,
});
}
return stateChangeAllowed
}
return true
});
if (!canLeaveStates) {
stateProviderEmitter.go(lastState.name, lastState.parameters, { replace: true });
}
return canLeaveStates
}
return true
}
function onRouteChange(state, parameters) {
try {
const finalDestinationStateName = prototypalStateHolder.applyDefaultChildStates(state.name);
if (finalDestinationStateName === state.name && allowStateChangeOrRevert(state.name, parameters)) {
emitEventAndAttemptStateChange(finalDestinationStateName, parameters);
} else if (finalDestinationStateName !== state.name) {
// There are default child states that need to be applied
const theRouteWeNeedToEndUpAt = makePath(finalDestinationStateName, parameters);
const currentRoute = router.location.get();
if (theRouteWeNeedToEndUpAt !== currentRoute) {
// change the url to match the full default child state route
stateProviderEmitter.go(finalDestinationStateName, parameters, { replace: true });
} else if (allowStateChangeOrRevert(finalDestinationStateName, parameters)) {
// the child state has the same route as the current one, just start navigating there
emitEventAndAttemptStateChange(finalDestinationStateName, parameters);
}
}
} catch (err) {
handleError(`stateError`, err);
}
}
function addState(state) {
if (typeof state === `undefined`) {
throw new Error(`Expected 'state' to be passed in.`)
} else if (typeof state.name === `undefined`) {
throw new Error(`Expected the 'name' option to be passed in.`)
} else if (typeof state.template === `undefined`) {
throw new Error(`Expected the 'template' option to be passed in.`)
}
Object.keys(state).filter(key => expectedPropertiesOfAddState.indexOf(key) === -1).forEach(key => {
console.warn(`Unexpected property passed to addState:`, key);
});
prototypalStateHolder.add(state.name, state);
const route = prototypalStateHolder.buildFullStateRoute(state.name);
router.add(route, parameters => onRouteChange(state, parameters));
}
function computeDefaultParams(defaultParams, parameters) {
const computedDefaultParams = {};
parameters.forEach(key => {
computedDefaultParams[key] = typeof defaultParams[key] === `function` ? defaultParams[key]() : defaultParams[key];
});
return computedDefaultParams
}
function getStatesToResolve(stateChanges) {
return stateChanges.create.map(prototypalStateHolder.get)
}
function emitEventAndAttemptStateChange(newStateName, parameters) {
stateProviderEmitter.emit(`stateChangeAttempt`, function stateGo(transition) {
attemptStateChange(newStateName, parameters, transition);
});
}
async function attemptStateChange(newStateName, parameters, transition) {
function ifNotCancelled(fn) {
return (...args) => {
if (transition.cancelled) {
const err = new Error(`The transition to ${newStateName} was cancelled`);
err.wasCancelledBySomeoneElse = true;
throw err
} else {
return fn(...args)
}
}
}
try {
await prototypalStateHolder.guaranteeAllStatesExist(newStateName);
const state = prototypalStateHolder.get(newStateName);
const defaultParams = prototypalStateHolder.getHierarchy(newStateName).reduce((acc, state) => {
return { ...acc, ...state.defaultParameters }
}, {});
const parametersThatNeedDefaultsApplied = Object.keys(defaultParams).filter(param => typeof parameters[param] === 'undefined');
if (parametersThatNeedDefaultsApplied.length > 0) {
throw redirector(newStateName, { ...parameters, ...computeDefaultParams(defaultParams, parametersThatNeedDefaultsApplied) })
}
await ifNotCancelled(() => {
stateProviderEmitter.emit(
`stateChangeStart`,
state,
parameters,
stateNameToArrayofStates(state.name),
);
lastStateStartedActivating.set(state.name, parameters);
})();
const stateComparisonResults = compareStartAndEndStates({
original: lastCompletelyLoadedState.get(),
destination: {
name: newStateName,
parameters,
},
});
const stateChanges = await stateChangeLogic(stateComparisonResults);
const stateResolveResultsObject = await ifNotCancelled(async() => {
try {
return await resolveStates(getStatesToResolve(stateChanges), { ...parameters })
} catch (e) {
e.stateChangeError = true;
throw e
}
})();
await ifNotCancelled(async() => {
transition.cancellable = false;
const activateAll = () => activateStates(stateChanges.create);
await sequence(reverse(stateChanges.destroy), destroyStateName);
activeStateResolveContent = { ...activeStateResolveContent, ...stateResolveResultsObject };
await renderAll(stateChanges.create, { ...parameters });
activateAll();
})();
lastCompletelyLoadedState.set(newStateName, parameters);
try {
stateProviderEmitter.emit(
`stateChangeEnd`,
prototypalStateHolder.get(newStateName),
parameters,
stateNameToArrayofStates(newStateName),
);
} catch (e) {
handleError(`stateError`, e);
}
} catch (err) {
if (transition.cancelled && err.wasCancelledBySomeoneElse) ; else if (err.redirectTo) {
stateProviderEmitter.emit(`stateChangeCancelled`, err);
await stateProviderEmitter.go(err.redirectTo.name, err.redirectTo.params, { replace: true });
} else {
handleError(`stateChangeError`, err);
}
}
function activateStates(stateNames) {
stateNames.map(prototypalStateHolder.get).forEach(state => {
const emitter = new EventEmitter();
const context = Object.create(emitter);
context.domApi = activeDomApis[state.name];
context.data = state.data;
context.parameters = parameters;
context.content = getContentObject(activeStateResolveContent, state.name);
activeEmitters[state.name] = emitter;
try {
state.activate && state.activate(context);
} catch (e) {
nextTick(() => {
throw e
});
}
});
}
}
function makePath(stateName, parameters, options) {
function getGuaranteedPreviousState() {
if (!lastStateStartedActivating.get().name) {
throw new Error(`makePath required a previous state to exist, and none was found`)
}
return lastStateStartedActivating.get()
}
if (options && options.inherit) {
parameters = { ...(getGuaranteedPreviousState().parameters), ...parameters };
}
const destinationStateName = stateName === null ? getGuaranteedPreviousState().name : stateName;
const destinationState = prototypalStateHolder.get(destinationStateName) || {};
const defaultParams = destinationState.defaultParameters || {};
parameters = { ...computeDefaultParams(defaultParams, Object.keys(defaultParams)), ...parameters };
prototypalStateHolder.guaranteeAllStatesExist(destinationStateName);
const route = prototypalStateHolder.buildFullStateRoute(destinationStateName);
return buildPath(route, parameters || {})
}
const defaultOptions = {
replace: false,
};
stateProviderEmitter.addState = addState;
stateProviderEmitter.go = async(newStateName, parameters, options) => {
options = { ...defaultOptions, ...options };
const goFunction = options.replace ? router.replace : router.go;
try {
const path = makePath(newStateName, parameters, options);
await goFunction(path);
} catch (err) {
handleError(`stateChangeError`, err);
}
};
// eslint-disable-next-line require-await
stateProviderEmitter.evaluateCurrentRoute = async(defaultState, defaultParams) => {
try {
const defaultPath = makePath(defaultState, defaultParams);
router.evaluateCurrent(defaultPath);
} catch (err) {
handleError(`stateError`, err);
}
};
stateProviderEmitter.makePath = (stateName, parameters, options) => pathPrefix + makePath(stateName, parameters, options);
stateProviderEmitter.getActiveState = () => lastCompletelyLoadedState.get();
stateProviderEmitter.stateIsActive = (stateName = null, parameters = null) => {
const currentState = lastCompletelyLoadedState.get();
const stateNameMatches = currentState.name === stateName
|| currentState.name.indexOf(`${stateName }.`) === 0
|| stateName === null;
const parametersWereNotPassedIn = !parameters;
return stateNameMatches
&& (parametersWereNotPassedIn || Object.keys(parameters).every(key => `${ parameters[key] }` === currentState.parameters[key]))
};
const renderer = makeRenderer(stateProviderEmitter);
destroyDom = denodeify(renderer.destroy);
getDomChild = denodeify(renderer.getChildElement);
renderDom = denodeify(renderer.render);
return stateProviderEmitter
}
function getContentObject(stateResolveResultsObject, stateName) {
const allPossibleResolvedStateNames = parse(stateName);
return allPossibleResolvedStateNames
.filter(stateName => stateResolveResultsObject[stateName])
.reduce((obj, stateName) => {
return { ...obj, ...stateResolveResultsObject[stateName] }
}, {})
}
function redirector(newStateName, parameters) {
return {
redirectTo: {
name: newStateName,
params: parameters,
},
}
}
// { [stateName]: resolveResult }
async function resolveStates(states, parameters) {
const statesWithResolveFunctions = states.filter(isFunction(`resolve`));
const stateNamesWithResolveFunctions = statesWithResolveFunctions.map(getProperty(`name`));
const resolveResults = await Promise.all(statesWithResolveFunctions.map(state => state.resolve(state.data, parameters)));
return combine({
stateName: stateNamesWithResolveFunctions,
resolveResult: resolveResults,
}).reduce((obj, result) => {
obj[result.stateName] = result.resolveResult;
return obj
}, {})
}
export { StateProvider as default };
//# sourceMappingURL=bundle.js.map