UNPKG

react-concurrent-router

Version:

Performant routing embracing React concurrent UI patterns

637 lines (618 loc) 20.1 kB
'use strict'; 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;