react-concurrent-router
Version:
Performant routing embracing React concurrent UI patterns
637 lines (618 loc) • 20.1 kB
JavaScript
;
var _extends = require('@babel/runtime/helpers/extends');
var _objectWithoutPropertiesLoose = require('@babel/runtime/helpers/objectWithoutPropertiesLoose');
var history = require('history');
var React = require('react');
class SuspendableResource {
constructor(loader, isModule = false) {
this.load = () => {
if (this._result) return this._result;
if (this._promise) return this._promise;
this._promise = this._loader().then(result => {
const returnValue = this._isModule ? result.default || result : result;
this._result = returnValue;
return this._result;
}).catch(error => {
this._error = error;
});
return this._promise;
};
this.isLoaded = () => Boolean(this._result);
this.read = () => {
if (this._result) return this._result;
if (this._error) throw this._error;
if (this._promise) throw this._promise;
throw this.load();
};
this._isModule = isModule;
this._loader = loader;
this._promise = null;
this._result = null;
this._error = null;
}
}
const _excluded$4 = ["path", "children"],
_excluded2 = ["path"];
const lastPreparedMatch = {
pathname: '',
paramsString: '',
value: null
};
const getCanonicalPath = path => path.charAt(0) === '/' ? path : `/${path}`;
const sortAndStringifyRequestParams = params => {
const optimisedParamsArray = [];
for (const param in params) {
if (!Object.prototype.hasOwnProperty.call(params, param)) continue;
optimisedParamsArray.push({
index: optimisedParamsArray.length,
value: param
});
}
return optimisedParamsArray.sort(({
value: firstValue
}, {
value: secondValue
}) => firstValue > secondValue ? 1 : -1).reduce((identifier, element) => {
const rawParamValue = params[element.value];
const paramValue = Array.isArray(rawParamValue) ? rawParamValue.reduce((params, value, index) => {
const encodedValue = encodeURIComponent(value);
return params.concat(index >= 1 ? `&${element.value}=${encodedValue}` : encodedValue);
}, '') : encodeURIComponent(rawParamValue);
return `${identifier}${!identifier ? '?' : '&'}${element.value}=${paramValue}`;
}, '');
};
const aggregateKeyValues = (list, key, value = '') => {
const decodedValue = decodeURIComponent(value);
const keyValue = list[key] ? Array.isArray(list[key]) ? list[key].concat(decodedValue) : [list[key], decodedValue] : decodedValue;
return keyValue;
};
const paramsStringToObject = search => {
if (!search) return {};
const paramsString = search.slice(1).split('&');
return paramsString.reduce((params, current) => {
const [key, value] = current.split('=');
const keyValue = aggregateKeyValues(params, key, value);
params[key] = keyValue;
return params;
}, {});
};
const routesToMap = routes => {
const routesMap = new Map();
const routesIterator = (inputRoutes, parent = {}, groupDescendant = false) => inputRoutes.forEach(route => {
const {
path,
children
} = route,
routeProps = _objectWithoutPropertiesLoose(route, _excluded$4);
const {
path: parentPath = ''
} = parent,
parentProps = _objectWithoutPropertiesLoose(parent, _excluded2);
const canonicalParentPath = parentPath === '/' ? '' : parentPath;
const canonicalRoutePath = path ? getCanonicalPath(path) : '';
const canonicalPath = canonicalParentPath + canonicalRoutePath;
const isGroupRoute = !routeProps.component;
const computedRoute = _extends({}, parentProps, routeProps, !isGroupRoute && {
component: new SuspendableResource(routeProps.component, true)
});
if (!isGroupRoute) routesMap.set(canonicalPath, computedRoute);
if (children && Array.isArray(children)) {
routesIterator(children, _extends({}, isGroupRoute && routeProps || groupDescendant && parent, {
path: canonicalPath
}), isGroupRoute || groupDescendant);
}
});
routesIterator(routes);
if (process.env.NODE_ENV !== 'production' && !routesMap.has('/*')) {
console.warn(`You didn't set a wildcard (*) route to catch any unmatched path.
This is required to make sure you push users to a Not Found page
when they request a route that doesn't exist; e.g. 404.`);
}
return routesMap;
};
const pathToLocation = location => typeof location === 'string' ? history.parsePath(location) : location;
const locationsMatch = (left, right, exact = false) => {
const leftLocation = pathToLocation(left);
const rightLocation = pathToLocation(right);
if (leftLocation.pathname !== rightLocation.pathname) return false;
return exact ? leftLocation.search === rightLocation.search && leftLocation.hash === rightLocation.hash : true;
};
const matchRegexRoute = (referencePath, pathname) => {
const pathToMatch = getCanonicalPath(pathname);
const paramsKeys = [];
const pattern = '^(' + referencePath.replace(/[.*+\-?^$/{}()|[\]\\]/g, '\\$&').replace(/\\\*$/, '.*').replace(/:(\w+)|(.\*)/g, (_, paramKey = 'rest') => {
paramsKeys.push(paramKey);
return `([^${paramKey === 'rest' ? ':(w+)|(.*)' : '\\/'}]+)`;
}) + ')\\/?$';
const matcher = new RegExp(pattern);
const match = pathToMatch.match(matcher);
if (!match) return null;
const params = paramsKeys.reduce((collection, paramKey, index) => {
const value = match[index + 2];
const keyValue = aggregateKeyValues(collection, paramKey, value);
collection[paramKey] = keyValue;
return collection;
}, {});
return {
params
};
};
const matchRoutes = (routes, requestedMatch, ignoreRedirectRules = false) => {
const locationToMatch = pathToLocation(requestedMatch);
const {
pathname,
search
} = locationToMatch;
const params = _extends({}, paramsStringToObject(search));
let matchedRoute = routes.has(pathname) && routes.get(pathname);
if (!matchedRoute) {
for (const [path, route] of routes.entries()) {
if (path !== '/*') {
const match = matchRegexRoute(path, pathname);
if (!match) continue;
_extends(params, match.params);
}
matchedRoute = route;
break;
}
}
if (!matchedRoute) return null;
const redirectPath = !ignoreRedirectRules && matchedRoute.redirectRules && matchedRoute.redirectRules(params);
return redirectPath ? matchRoutes(routes, redirectPath) : {
route: matchedRoute,
params,
location: locationToMatch
};
};
const prepareAssistPrefetchMatch = ({
params,
location
}, prefetchToAssist, awaitPrefetch) => {
const pathnameMatch = location.pathname === lastPreparedMatch.pathname;
const paramsString = pathnameMatch && sortAndStringifyRequestParams(params);
if (pathnameMatch && paramsString === lastPreparedMatch.paramsString) {
return lastPreparedMatch.value;
}
const prefetched = new Map();
const prefetch = prefetchToAssist(params);
for (const property in prefetch) {
if (!Object.prototype.hasOwnProperty.call(prefetch, property)) {
continue;
}
const isFetchFunction = typeof prefetch[property] === 'function';
const fetchFunction = isFetchFunction ? prefetch[property] : prefetch[property].data;
const fetchResource = new SuspendableResource(fetchFunction);
fetchResource.load();
prefetched.set(property, {
defer: !isFetchFunction && prefetch[property].defer !== undefined ? prefetch[property].defer : !awaitPrefetch,
data: fetchResource
});
}
lastPreparedMatch.pathname = location.pathname;
lastPreparedMatch.paramsString = paramsString || sortAndStringifyRequestParams(params);
return prefetched;
};
const prepareMatch = (match, assistPrefetch, awaitPrefetch) => {
const {
route,
params,
location
} = match;
const prefetchToAssist = assistPrefetch && route.prefetch || route.assistedPrefetch;
const prefetched = prefetchToAssist ? prepareAssistPrefetchMatch(match, prefetchToAssist, awaitPrefetch) : route.prefetch == null ? void 0 : route.prefetch(params);
const assistedPrefetch = Boolean(prefetchToAssist && prefetched);
if (assistedPrefetch && prefetched === lastPreparedMatch.value) {
return lastPreparedMatch.value;
}
route.component.load();
const preparedMatch = {
location,
component: route.component,
params,
prefetched,
assistedPrefetch
};
if (assistedPrefetch) lastPreparedMatch.value = preparedMatch;
return preparedMatch;
};
const createRouter = ({
assistPrefetch = false,
awaitComponent = false,
awaitPrefetch = false,
history,
routes
}) => {
const routesMap = routesToMap(routes);
const entryMatch = matchRoutes(routesMap, history.location);
let currentEntry = prepareMatch(entryMatch, assistPrefetch, awaitPrefetch);
if (!locationsMatch(entryMatch.location, history.location, true)) {
history.replace(entryMatch.location);
}
let nextId = 0;
const subscribers = new Map();
history.listen(({
location,
action
}) => {
if (locationsMatch(currentEntry.location, location, true)) return;
const skipRender = location.state && location.state.skipRender && action !== 'POP';
const match = matchRoutes(routesMap, location);
const nextEntry = skipRender ? _extends({}, currentEntry, {
location: match.location,
params: match.params,
skipRender
}) : prepareMatch(match, assistPrefetch, awaitPrefetch);
if (!locationsMatch(match.location, location, true)) {
return history.replace(match.location);
}
currentEntry = nextEntry;
subscribers.forEach(callback => callback(nextEntry));
});
return {
assistPrefetch,
awaitComponent,
history,
isActive: (path, {
exact
} = {}) => locationsMatch(history.location, path, exact),
get: () => currentEntry,
preloadCode: (path, {
ignoreRedirectRules
} = {}) => {
const {
route
} = matchRoutes(routesMap, path, ignoreRedirectRules);
route.component.load();
},
warmRoute: (path, {
ignoreRedirectRules
} = {}) => {
const match = matchRoutes(routesMap, path, ignoreRedirectRules);
prepareMatch(match, assistPrefetch, awaitPrefetch);
},
subscribe: callback => {
const id = nextId++;
const dispose = () => {
subscribers.delete(id);
};
subscribers.set(id, callback);
return dispose;
}
};
};
const _excluded$3 = ["window"];
const createBrowserRouter = _ref => {
let {
window
} = _ref,
routerConfig = _objectWithoutPropertiesLoose(_ref, _excluded$3);
return createRouter(_extends({}, routerConfig, {
history: history.createBrowserHistory({
window
})
}));
};
const _excluded$2 = ["window"];
const createHashRouter = _ref => {
let {
window
} = _ref,
routerConfig = _objectWithoutPropertiesLoose(_ref, _excluded$2);
return createRouter(_extends({}, routerConfig, {
history: history.createHashHistory({
window
})
}));
};
const _excluded$1 = ["initialEntries", "initialIndex"];
const createMemoryRouter = _ref => {
let {
initialEntries,
initialIndex
} = _ref,
routerConfig = _objectWithoutPropertiesLoose(_ref, _excluded$1);
return createRouter(_extends({}, routerConfig, {
history: history.createMemoryHistory({
initialEntries,
initialIndex
})
}));
};
const RouterContext = React.createContext(null);
const useBeforeRouteLeave = ({
toggle = true,
unload = true,
message = ''
}) => {
const {
history: {
block
}
} = React.useContext(RouterContext);
let unblock;
const handleBeforeunload = React.useCallback(event => {
event.preventDefault();
event.returnValue = '';
}, []);
const register = React.useCallback(() => {
unblock = block(message);
if (unload) {
window.addEventListener('beforeunload', handleBeforeunload);
}
}, [block]);
const cleanup = React.useCallback(() => {
unblock();
if (unload) {
window.removeEventListener('beforeunload', handleBeforeunload);
}
}, [unblock]);
React.useEffect(() => {
if (toggle) register();
return () => {
if (toggle) cleanup();
};
}, [toggle, register, cleanup]);
};
const useHistory = () => {
const {
history: {
length,
location,
action,
index,
entries
},
subscribe
} = React.useContext(RouterContext);
const [lastUpdate, setLastUpdate] = React.useState(new Date().getTime());
React.useEffect(() => {
const dispose = subscribe(async () => {
setTimeout(() => setLastUpdate(new Date().getTime()), 1);
});
return () => dispose();
}, []);
return {
length,
location,
action,
index,
entries
};
};
const useNavigation = () => {
const {
history: {
push,
replace,
go,
back,
forward
}
} = React.useContext(RouterContext);
return {
push,
replace,
go,
goBack: back,
goForward: forward
};
};
const useParams = () => {
const {
get,
subscribe
} = React.useContext(RouterContext);
const [params, setParams] = React.useState(get().params);
React.useEffect(() => {
const dispose = subscribe(async nextEntry => {
setTimeout(() => setParams(nextEntry.params), 1);
});
return () => dispose();
}, []);
return params;
};
const useRouter = () => {
const {
isActive,
preloadCode,
warmRoute
} = React.useContext(RouterContext);
return {
isActive,
preloadCode,
warmRoute
};
};
const useSearchParams = () => {
const {
get,
history,
subscribe
} = React.useContext(RouterContext);
const [searchParams, setSearchParams] = React.useState(paramsStringToObject(get().location.search));
const handleSetSearchParams = React.useCallback((newParams, {
replace = false
} = {}) => {
const {
location
} = get();
const currentSearchParams = typeof newParams === 'function' && paramsStringToObject(location.search);
const newSearchParams = typeof newParams === 'function' ? newParams(currentSearchParams) : newParams;
history[replace ? 'replace' : 'push']({
pathname: location.pathname,
search: sortAndStringifyRequestParams(newSearchParams)
}, _extends({}, location.state, replace && {
skipRender: true
}));
}, []);
React.useEffect(() => {
const dispose = subscribe(async nextEntry => {
const newSearchParams = paramsStringToObject(nextEntry.location.search);
setTimeout(() => setSearchParams(newSearchParams), 1);
});
return () => dispose();
}, []);
return [searchParams, handleSetSearchParams];
};
const RouterProvider = ({
children,
router
}) => React.createElement(RouterContext.Provider, {
value: router,
children: children
});
const RouteRenderer = ({
pendingIndicator
}) => {
const {
assistPrefetch,
awaitComponent,
get,
subscribe
} = React.useContext(RouterContext);
const computeInitialEntry = React.useCallback(entry => {
if (!assistPrefetch || !entry.prefetched) return entry;
const prefetched = {};
for (const [property, value] of entry.prefetched.entries()) {
prefetched[property] = value.data;
}
return _extends({}, entry, {
prefetched
});
}, []);
const [isPendingEntry, setIsPendingEntry] = React.useState(false);
const [routeEntry, setRouteEntry] = React.useState(computeInitialEntry(get()));
const Component = React.useMemo(() => routeEntry.component.read(), [routeEntry]);
const processFetchEntities = React.useCallback(pendingEntry => {
if (!pendingEntry.assistedPrefetch) {
return {
prefetched: pendingEntry.prefetched,
toBePrefetched: []
};
}
const prefetched = {};
const toBePrefetched = [];
for (const [property, value] of pendingEntry.prefetched.entries()) {
if (value.defer === false && value.data.isLoaded() === false) {
toBePrefetched.push({
key: property,
data: value.data
});
} else prefetched[property] = value.data;
}
return {
prefetched,
toBePrefetched
};
}, []);
React.useEffect(() => {
const dispose = subscribe(async nextEntry => {
if (nextEntry.skipRender) return;
const {
prefetched,
toBePrefetched
} = processFetchEntities(nextEntry);
const shouldUpdatePendingIndicator = Boolean(pendingIndicator && (awaitComponent && !nextEntry.component.isLoaded() || nextEntry.assistedPrefetch && toBePrefetched.length));
if (shouldUpdatePendingIndicator) setIsPendingEntry(true);
if (awaitComponent) await nextEntry.component.load();
const newlyPrefetched = toBePrefetched.length ? await toBePrefetched.reduce(async (newlyPrefetched, {
key,
data
}) => {
await data.load();
return _extends({}, newlyPrefetched, {
[key]: data
});
}, {}) : {};
const routeEntry = _extends({}, nextEntry, {
prefetched: _extends({}, prefetched, newlyPrefetched)
});
setRouteEntry(routeEntry);
if (shouldUpdatePendingIndicator) setIsPendingEntry(false);
});
return () => dispose();
}, [assistPrefetch, awaitComponent, processFetchEntities, pendingIndicator, subscribe]);
const locationKey = routeEntry.location ? routeEntry.location.pathname + routeEntry.location.search + routeEntry.location.hash : 'default';
return React.createElement(React.Fragment, {
key: locationKey
}, isPendingEntry && pendingIndicator ? pendingIndicator : null, React.createElement(Component, {
key: window.location.href,
params: routeEntry.params,
prefetched: routeEntry.prefetched
}));
};
const _excluded = ["activeClassName", "exact", "target", "to", "onClick"];
const shouldNavigate = event => !event.defaultPrevented && event.button === 0 && (!event.target.target || event.target.target === '_self') && !(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
const Link = React.forwardRef((_ref, ref) => {
let {
activeClassName,
exact,
target,
to,
onClick
} = _ref,
props = _objectWithoutPropertiesLoose(_ref, _excluded);
const {
isActive,
preloadCode,
warmRoute,
history
} = React.useContext(RouterContext);
const toIsActive = isActive(to, {
exact
});
const handleClick = React.useCallback(event => {
if (onClick) onClick(event);
if (!shouldNavigate(event)) return;
event.preventDefault();
const navigationMethod = isActive(to, {
exact: true
}) ? 'replace' : 'push';
history[navigationMethod](to);
}, [onClick, isActive, to, history]);
const handlePreloadCode = React.useCallback(() => {
preloadCode(to);
}, [preloadCode, to]);
const handleWarmRoute = React.useCallback(({
type,
key,
code,
keyCode
}) => {
if (type === 'mousedown' || type === 'keydown' && (key === 'Enter' || code === 'Enter' || code === 'NumpadEnter' || keyCode === 13)) {
warmRoute(to);
}
}, [warmRoute, to]);
const elementProps = _extends({}, props, {
target,
ref,
href: to,
onClick: handleClick,
onMouseOver: handlePreloadCode,
onFocus: handlePreloadCode,
onMouseDown: handleWarmRoute,
onKeyDown: handleWarmRoute
}, toIsActive && {
className: props.className ? `${props.className} ${activeClassName}` : activeClassName,
'aria-current': 'page'
});
return React.createElement("a", elementProps);
});
Link.defaultProps = {
activeClassName: 'active',
exact: false
};
Link.displayName = 'Link';
exports.Link = Link;
exports.RouteRenderer = RouteRenderer;
exports.RouterProvider = RouterProvider;
exports.SuspendableResource = SuspendableResource;
exports.createBrowserRouter = createBrowserRouter;
exports.createHashRouter = createHashRouter;
exports.createMemoryRouter = createMemoryRouter;
exports.useBeforeRouteLeave = useBeforeRouteLeave;
exports.useHistory = useHistory;
exports.useNavigation = useNavigation;
exports.useParams = useParams;
exports.useRouter = useRouter;
exports.useSearchParams = useSearchParams;