diff --git a/src/router/router.js b/src/router/router.js index 436ef4ce..079fa098 100644 --- a/src/router/router.js +++ b/src/router/router.js @@ -15,7 +15,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { default as fadeInFadeOutTransition } from './transitions/fadeInOut.js' +import { getHash, isObject, isString, matchHash } from './utils.js' import { reactive } from '../lib/reactivity/reactive.js' import symbols from '../lib/symbols.js' @@ -85,157 +85,6 @@ let previousFocus // Skips internal router navigation when set to true only for the next "navigate" // execution, needed for window.history management let preventHashChangeNavigation = false -/** - * Get the current hash - * @returns {Hash} - */ -export const getHash = (hash) => { - if (!hash) hash = '/' - const hashParts = hash.replace(/^#/, '').split('?') - return { - path: hashParts[0], - queryParams: new URLSearchParams(hashParts[1]), - hash: hash, - } -} - -const normalizePath = (path) => { - return ( - path - // remove leading and trailing slashes - .replace(/^\/+|\/+$/g, '') - .toLowerCase() - ) -} - -/** - * Check if a value is an object - * @param {any} v - * @returns {boolean} True if v is an object - */ -const isObject = (v) => typeof v === 'object' && v !== null - -/** - * Check if a value is a function - * @param {any} v - * @returns {boolean} True if v is a string - */ -const isString = (v) => typeof v === 'string' - -const queryParamsToObject = (queryParams) => { - if (!queryParams) return {} - const object = {} - const queryParamsEntries = [...queryParams.entries()] - for (let i = 0; i < queryParamsEntries.length; i++) { - object[queryParamsEntries[i][0]] = queryParamsEntries[i][1] - } - - return object -} - -/** - * Match a path to a route - * - * @param {object} hashObject - * @param {Route[]} routes - * @returns {Route} - */ -export const matchHash = ({ hash, path, queryParams }, routes = []) => { - // remove trailing slashes - const originalPath = path.replace(/^\/+|\/+$/g, '') - const originalNormalizedPath = normalizePath(path) - - const override = { - hash: hash, - queryParams: queryParamsToObject(queryParams), - path: path, - } - - /** @type {boolean|Route} */ - let matchingRoute = false - let i = 0 - while (!matchingRoute && i < routes.length) { - const route = routes[i] - - const normalizedPath = normalizePath(route.path) - if (normalizePath(normalizedPath) === originalNormalizedPath) { - matchingRoute = makeRouteObject(route, override) - } else if (normalizedPath.indexOf(':') > -1) { - // match dynamic route parts - const dynamicRouteParts = [...normalizedPath.matchAll(/:([^\s/]+)/gi)] - - // construct a regex for the route with dynamic parts - let dynamicRoutePartsRegex = normalizedPath - dynamicRouteParts.reverse().forEach((part) => { - dynamicRoutePartsRegex = - dynamicRoutePartsRegex.substring(0, part.index) + - '([^\\s/]+)' + - dynamicRoutePartsRegex.substring(part.index + part[0].length) - }) - - dynamicRoutePartsRegex = '^' + dynamicRoutePartsRegex - - // test if the constructed regex matches the path - const match = originalPath.match(new RegExp(`${dynamicRoutePartsRegex}`, 'i')) - - if (match) { - // map the route params to a params object - override.params = dynamicRouteParts.reverse().reduce((acc, part, index) => { - acc[part[1]] = match[index + 1] - return acc - }, {}) - - matchingRoute = makeRouteObject(route, override) - } - } else if (normalizedPath.endsWith('*')) { - const regex = new RegExp(normalizedPath.replace(/\/?\*/, '/?([^\\s]*)'), 'i') - const match = originalNormalizedPath.match(regex) - - if (match) { - override.params = {} - if (match[1]) override.params.path = match[1] - matchingRoute = makeRouteObject(route, override) - } - } - i++ - } - - // @ts-ignore - Remove me when we have a better way to handle this - return matchingRoute -} - -/** - * Default Route options - * - */ -const defaultOptions = { - inHistory: true, - keepAlive: false, - passFocus: true, - reuseComponent: false, -} - -const makeRouteObject = (route, overrides) => { - // FIX: exclude keepAlive from the destination route options. Unlike other - // overrides, keepAlive applies to the route being LEFT, not the destination. - // It is consumed by removeView() instead. - const { keepAlive: _keepAlive, ...destOverrides } = overrideOptions // eslint-disable-line no-unused-vars - - const cleanRoute = { - hash: overrides.hash, - path: route.path, - component: route.component, - transition: 'transition' in route ? route.transition : fadeInFadeOutTransition, - options: { ...defaultOptions, ...route.options, ...destOverrides }, - announce: route.announce || false, - hooks: route.hooks || {}, - data: { ...route.data, ...navigationData, ...overrides.queryParams }, - params: overrides.params || {}, - meta: route.meta || {}, - } - - return cleanRoute -} /** * Navigate to a route @@ -250,316 +99,246 @@ const makeRouteObject = (route, overrides) => { * @returns {Promise} */ export const navigate = async function () { + // early return when in preventHashChange mode + if (preventHashChangeNavigation !== false) return + // early return when no routes + if (!this[symbols.parent][symbols.routes] || this[symbols.parent][symbols.routes].length === 0) + return + + state.navigating = true + Announcer.stop() Announcer.clear() - state.navigating = true - let reuse = false - if (preventHashChangeNavigation === false && this[symbols.parent][symbols.routes]) { - let previousRoute = currentRoute //? Object.assign({}, currentRoute) : undefined - let route = matchHash(getHash(location.hash), this[symbols.parent][symbols.routes]) - - currentRoute = route - - if (route) { - const currentPath = currentRoute.path - let beforeEachResult - if (this[symbols.parent][symbols.routerHooks]) { - const hooks = this[symbols.parent][symbols.routerHooks] - if (hooks.beforeEach) { - try { - beforeEachResult = await hooks.beforeEach.call( - this[symbols.parent], - route, - previousRoute - ) - if (isString(beforeEachResult)) { - currentRoute = previousRoute - to(beforeEachResult) - return - } - } catch (error) { - Log.error('Error or Rejected Promise in "BeforeEach" Hook', error) - - if (history.length > 0) { - preventHashChangeNavigation = true - currentRoute = previousRoute - window.history.back() - - navigatingBack = false - state.navigating = false - return - } - } - // If the resolved result is an object, redirect if the path in the object was changed - if (isObject(beforeEachResult) === true && beforeEachResult.path !== currentPath) { - currentRoute = previousRoute - to(beforeEachResult.path, beforeEachResult.data, beforeEachResult.options) - return - } - // If the resolved result is false, cancel navigation - if (beforeEachResult === false && history.length > 0) { - preventHashChangeNavigation = true - currentRoute = previousRoute - window.history.back() - - navigatingBack = false - state.navigating = false - return - } - } - } - let beforeHookOutput - if (route.hooks.before) { - try { - beforeHookOutput = await route.hooks.before.call( - this[symbols.parent], - route, - previousRoute - ) - if (isString(beforeHookOutput)) { - currentRoute = previousRoute - to(beforeHookOutput) - return - } - } catch (error) { - Log.error('Error or Rejected Promise in "Before" Hook', error) + const hash = getHash(location.hash) + // try to find the route + let route = matchHash(hash, this[symbols.parent][symbols.routes], overrideOptions, navigationData) - if (history.length > 0) { - preventHashChangeNavigation = true - currentRoute = previousRoute - window.history.back() + // early return when route not found + if (route === false) { + state.navigating = false - navigatingBack = false - state.navigating = false - return - } - } - // If the resolved result is an object, redirect if the path in the object was changed - if (isObject(beforeHookOutput) === true && beforeHookOutput.path !== currentPath) { - currentRoute = previousRoute - to(beforeHookOutput.path, beforeHookOutput.data, beforeHookOutput.options) - return - } - // If the resolved result is false, cancel navigation - if (beforeHookOutput === false && history.length > 0) { - preventHashChangeNavigation = true - currentRoute = previousRoute - window.history.back() - - navigatingBack = false - state.navigating = false - return - } - } + Log.error(`Route ${hash} not found`) + const routerHooks = this[symbols.parent][symbols.routerHooks] + if (routerHooks && typeof routerHooks.error === 'function') { + routerHooks.error.call(this[symbols.parent], `Route ${hash} not found`) + } + return + } - // add the previous route (technically still the current route at this point) - // into the history stack when inHistory is true and we're not navigating back - // - // FIX: use truthy check instead of `!== undefined` because matchHash() - // can return `false`, which survives `!== undefined` but has no `.options`. - if ( - previousRoute && - previousRoute.options && - previousRoute.options.inHistory === true && - navigatingBack === false - ) { - history.push(previousRoute) - } + let reuse = false + let previousRoute = currentRoute + currentRoute = route + const currentPath = currentRoute.path + + // execute before each hook + const beforeEachResult = await executeBeforeHook( + this[symbols.parent][symbols.routerHooks], + 'beforeEach', + this[symbols.parent], + route, + previousRoute, + currentPath + ) + if (beforeEachResult === false) return + + // execute before route hook + const beforeResult = await executeBeforeHook( + route.hooks, + 'before', + this[symbols.parent], + route, + previousRoute, + currentPath + ) + if (beforeResult === false) return - // a transition can be a function returning a dynamic transition object - // based on current and previous route - if (typeof route.transition === 'function') { - route.transition = route.transition(previousRoute, route) - } + // add the previous route (technically still the current route at this point) + // into the history stack when inHistory is true and we're not navigating back + // + // FIX: use truthy check instead of `!== undefined` because matchHash() + // can return `false`, which survives `!== undefined` but has no `.options`. + if ( + previousRoute && + previousRoute.options && + previousRoute.options.inHistory === true && + navigatingBack === false + ) { + history.push(previousRoute) + } - /** @type {import('../engines/L3/element.js').BlitsElement} */ - let holder - - /** @type {RouteViewWithOptionalDefault|undefined|null} */ - let view - let focus - // when navigating back let's see if we're navigating back to a route that was kept alive - if (navigatingBack === true && navigatingBackTo !== undefined) { - view = navigatingBackTo.view - focus = navigatingBackTo.focus - navigatingBackTo = null - } - // merge props with potential route params, navigation data and route data to be injected into the component instance - const props = { - ...this[symbols.props], - ...route.params, - ...route.data, - } + // a transition can be a function returning a dynamic transition object + // based on current and previous route + if (typeof route.transition === 'function') { + route.transition = route.transition(previousRoute, route) + } - // see if the component of the previous route can be reused for the - // current route - if ( - previousRoute && - route.options.reuseComponent === true && - route.options.keepAlive !== true && - route.component === previousRoute.component - ) { - reuse = true - view = this[symbols.children][this[symbols.children].length - 1] - for (const prop in props) { - view[symbols.props][prop] = props[prop] - } - } + /** @type {import('../engines/L3/element.js').BlitsElement} */ + let holder + + /** @type {RouteViewWithOptionalDefault|undefined|null} */ + let view + let focus + // when navigating back let's see if we're navigating back to a route that was kept alive + if (navigatingBack === true && navigatingBackTo !== undefined) { + view = navigatingBackTo.view + focus = navigatingBackTo.focus + navigatingBackTo = null + } + // merge props with potential route params, navigation data and route data to be injected into the component instance + const props = { + ...this[symbols.props], + ...route.params, + ...route.data, + } - // Announce route change if a message has been specified for this route - if (route.announce) { - if (typeof route.announce === 'string') { - route.announce = { - message: route.announce, - } - } - Announcer.speak(route.announce.message, route.announce.politeness) - } + // see if the component of the previous route can be reused for the + // current route + if ( + previousRoute && + route.options.reuseComponent === true && + route.options.keepAlive !== true && + route.component === previousRoute.component + ) { + reuse = true + view = this[symbols.children][this[symbols.children].length - 1] + for (const prop in props) { + view[symbols.props][prop] = props[prop] + } + } - // Update router state after announcements and final route resolution, - // right before initializing or restoring the view - state.path = route.path - state.params = Object.keys(route.params).length === 0 ? null : route.params - state.hash = route.hash - state.data = null - state.data = route.data || {} - - if (!view) { - // create a holder element for the new view - holder = stage.element({ parent: this[symbols.children][0] }) - holder.populate({}) - holder.set('w', '100%') - holder.set('h', '100%') - - view = await route.component({ props }, holder, this) - - // is the component a dynamic module? - if (view[Symbol.toStringTag] === 'Module') { - if (view.default && typeof view.default === 'function') { - view = view.default({ props }, holder, this) - } else { - Log.error("Dynamic import doesn't have a default export or default is not a function") - } - } + // Announce route change if a message has been specified for this route + if (route.announce) { + if (typeof route.announce === 'string') { + Announcer.speak(route.announce) + } else { + Announcer.speak(route.announce.message, route.announce.politeness) + } + } - if (typeof view === 'function') { - // had to inline this because the tscompiler does not like LHS reassignments - // that also change the type of the variable in a variable union - view = /** @type {BlitsComponentFactory} */ (view)({ props }, holder, this) - } - } else { - holder = view[symbols.holder] - - // Check, whether cached view holder's alpha prop is exists in transition or not - let hasAlphaProp = false - if (route.transition.before) { - if (Array.isArray(route.transition.before)) { - for (let i = 0; i < route.transition.before.length; i++) { - if (route.transition.before[i].prop === 'alpha') { - hasAlphaProp = true - break - } - } - } else if (route.transition.before.prop === 'alpha') { + // Update router state after announcements and final route resolution, + // right before initializing or restoring the view + state.path = route.path + state.params = Object.keys(route.params).length === 0 ? null : route.params + state.hash = route.hash + state.data = null + state.data = route.data || {} + + // routing to a new page (instead of routing back to a keepAlive page) + if (view === undefined) { + // create a holder element for the new view + holder = stage.element({ parent: this[symbols.children][0] }) + holder.populate({}) + holder.set('w', '100%') + holder.set('h', '100%') + + view = await loadPage.call(this, route, holder, props) + } else { + holder = view[symbols.holder] + + // Check, whether cached view holder's alpha prop is exists in transition or not + let hasAlphaProp = false + if (route.transition.before) { + if (Array.isArray(route.transition.before)) { + for (let i = 0; i < route.transition.before.length; i++) { + if (route.transition.before[i].prop === 'alpha') { hasAlphaProp = true + break } } - // set holder alpha when alpha prop is not exists in route transition - if (hasAlphaProp === false) { - holder.set('alpha', 1) - } - } - - // store the new view as new child, only if we're not reusing the previous page component - if (reuse === false) { - this[symbols.children].push(view) + } else if (route.transition.before.prop === 'alpha') { + hasAlphaProp = true } + } + // set holder alpha when alpha prop is not exists in route transition + if (hasAlphaProp === false) { + holder.set('alpha', 1) + } + } - // keep reference to the previous focus for storing in cache - previousFocus = Focus.get() + // store the new view as new child, only if we're not reusing the previous page component + if (reuse === false) { + this[symbols.children].push(view) + } - const children = this[symbols.children] - this.activeView = children[children.length - 1] + // keep reference to the previous focus for storing in cache + previousFocus = Focus.get() - // set focus to the view that we're routing to (unless explicitly disabling passing focus) - if (route.options.passFocus !== false) { - focus ? focus.$focus() : /** @type {BlitsComponent} */ (view).$focus() - } + const children = this[symbols.children] + this.activeView = children[children.length - 1] - // apply before settings to holder element - if (route.transition.before) { - if (Array.isArray(route.transition.before)) { - for (let i = 0; i < route.transition.before.length; i++) { - holder.set(route.transition.before[i].prop, route.transition.before[i].value) - } - } else { - holder.set(route.transition.before.prop, route.transition.before.value) - } - } + // set focus to the view that we're routing to (unless explicitly disabling passing focus) + if (route.options.passFocus !== false) { + focus ? focus.$focus() : /** @type {BlitsComponent} */ (view).$focus() + } - let shouldAnimate = false - - // apply out out transition on previous view if available, unless - // we're reusing the prvious page component - // FIX: truthy guard — previousRoute can be `false` (see history-push comment above). - if (previousRoute && reuse === false) { - // only animate when there is a previous route - shouldAnimate = true - const oldView = this[symbols.children].splice(1, 1).pop() - if (oldView) { - await removeView(previousRoute, oldView, route.transition.out, navigatingBack) - } - } + // apply starting state of transition + if (route.transition.before) { + await executeTransition(route.transition.before, holder, false) + } - // apply in transition - if (route.transition.in) { - if (Array.isArray(route.transition.in)) { - for (let i = 0; i < route.transition.in.length; i++) { - i === route.transition.in.length - 1 - ? await setOrAnimate(holder, route.transition.in[i], shouldAnimate) - : setOrAnimate(holder, route.transition.in[i], shouldAnimate) - } - } else { - await setOrAnimate(holder, route.transition.in, shouldAnimate) + let shouldAnimate = false + + // apply out out transition on previous view if available, unless + // we're reusing the prvious page component + // FIX: truthy guard — previousRoute can be `false` (see history-push comment above). + if (previousRoute && reuse === false) { + // only animate when there is a previous route + shouldAnimate = true + let oldView = this[symbols.children].splice(1, 1).pop() + if (oldView) { + executeTransition(previousRoute.transition.out, oldView[symbols.holder], true) + + // Resolve effective keepAlive: runtime override from $router.to() takes precedence + // over the static route config option + const keepAlive = + overrideOptions.keepAlive !== undefined + ? overrideOptions.keepAlive + : previousRoute.options && previousRoute.options.keepAlive + + // cache the page when it's as 'keepAlive' instead of destroying + if ( + navigatingBack === false && + previousRoute.options && + keepAlive === true && + route.options.inHistory === true + ) { + const historyItem = history[history.length - 1] + if (historyItem !== undefined) { + historyItem.view = oldView + historyItem.focus = previousFocus } } - if (this[symbols.parent][symbols.routerHooks]) { - const hooks = this[symbols.parent][symbols.routerHooks] - if (hooks.afterEach) { - try { - await hooks.afterEach.call( - this[symbols.parent], - route, // to - previousRoute // from - ) - } catch (error) { - Log.error('Error in "AfterEach" Hook', error) - } - } + /* Destroy the view in the following cases: + * 1. Navigating forward, and the previous route is not configured with "keep alive" set to true. + * 2. Navigating back, and the previous route is configured with "keep alive" set to true. + * 3. Navigating back, and the previous route is not configured with "keep alive" set to true. + */ + if (previousRoute.options && (keepAlive !== true || navigatingBack === true)) { + oldView.destroy() + oldView = null } - if (route.hooks.after) { - try { - await route.hooks.after.call( - this[symbols.parent], - route, // to - previousRoute // from - ) - } catch (error) { - Log.error('Error or Rejected Promise in "After" Hook', error) - } - } - } else { - Log.error(`Route ${route.hash} not found`) - const routerHooks = this[symbols.parent][symbols.routerHooks] - if (routerHooks && typeof routerHooks.error === 'function') { - routerHooks.error.call(this[symbols.parent], `Route ${route.hash} not found`) - } + previousFocus = null } } + // apply in transition + if (route.transition.in) await executeTransition(route.transition.in, holder, shouldAnimate) + + // execute after each Hook + await executeAfterHook( + this[symbols.parent][symbols.routerHooks], + 'afterEach', + this[symbols.parent], + route, + previousRoute + ) + + // execute after route Hook + await executeAfterHook(route.hooks, 'after', this[symbols.parent], route, previousRoute) + // Clear module-level variables after removeView has consumed them. // Placed here so it executes for all navigation flows, not only when // previousRoute exists and reuse is false. @@ -572,81 +351,118 @@ export const navigate = async function () { preventHashChangeNavigation = false } -/** - * Remove the currently active view - * - * @param {Route} route - * @param {BlitsComponent} view - * @param {Object} transition - */ -const removeView = async (route, view, transition, navigatingBack) => { - // apply out transition - if (transition) { - if (Array.isArray(transition)) { - for (let i = 0; i < transition.length; i++) { - i === transition.length - 1 - ? await setOrAnimate(view[symbols.holder], transition[i]) - : setOrAnimate(view[symbols.holder], transition[i]) +const setOrAnimate = (element, transition, shouldAnimate = true) => { + if (shouldAnimate === true) { + return new Promise((resolve) => { + // resolve the promise in the transition end-callback + // ("extending" end callback when one is already specified) + let existingEndCallback = transition.end + transition.end = (...args) => { + existingEndCallback && existingEndCallback(args) + // null the callback to enable memory cleanup + existingEndCallback = null + resolve() } - } else { - await setOrAnimate(view[symbols.holder], transition) + if (element !== undefined) element.set(transition.prop, { transition }) + else resolve() + }) + } else { + element !== undefined && element.set(transition.prop, transition.value) + return true + } +} + +const executeBeforeHook = async function ( + hooks, + hookName, + parent, + route, + previousRoute, + currentPath +) { + let result + if (hooks && hooks[hookName]) { + try { + result = await hooks[hookName].call(parent, route, previousRoute) + if (isString(result)) { + currentRoute = previousRoute + to(result) + return false + } + } catch (error) { + Log.error(`Error or Rejected Promise in "${hookName}" Hook`, error) + if (history.length > 0) { + preventHashChangeNavigation = true + currentRoute = previousRoute + window.history.back() + navigatingBack = false + state.navigating = false + return false + } + } + // If the resolved result is an object, redirect if the path in the object was changed + if (isObject(result) === true && result.path !== currentPath) { + currentRoute = previousRoute + to(result.path, result.data, result.options) + return false + } + // If the resolved result is false, cancel navigation + if (result === false && history.length > 0) { + preventHashChangeNavigation = true + currentRoute = previousRoute + window.history.back() + navigatingBack = false + state.navigating = false + return false } } +} - // Resolve effective keepAlive: runtime override from $router.to() takes precedence - // over the static route config option - const keepAlive = - overrideOptions.keepAlive !== undefined - ? overrideOptions.keepAlive - : route.options && route.options.keepAlive +const loadPage = async function (route, holder, props) { + let view = await route.component({ props }, holder, this) - // cache the page when it's as 'keepAlive' instead of destroying - if ( - navigatingBack === false && - route.options && - keepAlive === true && - route.options.inHistory === true - ) { - const historyItem = history[history.length - 1] - if (historyItem !== undefined) { - historyItem.view = view - historyItem.focus = previousFocus + // is the component a dynamic module? + if (view[Symbol.toStringTag] === 'Module') { + if (view.default && typeof view.default === 'function') { + view = view.default({ props }, holder, this) + } else { + Log.error("Dynamic import doesn't have a default export or default is not a function") } } - /* Destroy the view in the following cases: - * 1. Navigating forward, and the previous route is not configured with "keep alive" set to true. - * 2. Navigating back, and the previous route is configured with "keep alive" set to true. - * 3. Navigating back, and the previous route is not configured with "keep alive" set to true. - */ - if (route.options && (keepAlive !== true || navigatingBack === true)) { - view.destroy() - view = null + if (typeof view === 'function') { + // had to inline this because the tscompiler does not like LHS reassignments + // that also change the type of the variable in a variable union + view = /** @type {BlitsComponentFactory} */ (view)({ props }, holder, this) } - previousFocus = null - route = null + return view } -const setOrAnimate = (node, transition, shouldAnimate = true) => { - return new Promise((resolve) => { - if (shouldAnimate === true) { - // resolve the promise in the transition end-callback - // ("extending" end callback when one is already specified) - let existingEndCallback = transition.end - transition.end = (...args) => { - existingEndCallback && existingEndCallback(args) - // null the callback to enable memory cleanup - existingEndCallback = null - resolve() - } - if (node !== undefined) node.set(transition.prop, { transition }) - else resolve() - } else { - node !== undefined && node.set(transition.prop, transition.value) - resolve() +const executeAfterHook = async function (hooks, hookName, parent, route, previousRoute) { + if (hooks && hooks[hookName]) { + try { + await hooks[hookName].call( + parent, + route, // to + previousRoute // from + ) + } catch (error) { + Log.error(`Error or Rejected Promise in "${hookName}" Hook`, error) } - }) + } +} + +const executeTransition = async (transition, element, animate) => { + if (Array.isArray(transition)) { + for (let i = 0; i < transition.length; i++) { + i === transition.length - 1 + ? await setOrAnimate(element, transition[i], animate) + : setOrAnimate(element, transition[i], animate) + } + } else { + await setOrAnimate(element, transition, animate) + } } export const to = (path, data = {}, options = {}) => { @@ -690,7 +506,12 @@ export const back = function () { } // Construct new path to backtrack to path = path.replace(hashEnd, '') - const route = matchHash(getHash(path), this[symbols.parent][symbols.routes]) + const route = matchHash( + getHash(path), + this[symbols.parent][symbols.routes], + overrideOptions, + navigationData + ) if (route && backtrack) { to(route.path, route.data, route.options) diff --git a/src/router/router.test.js b/src/router/router.test.js index 6360cc85..09088602 100644 --- a/src/router/router.test.js +++ b/src/router/router.test.js @@ -17,7 +17,8 @@ import test from 'tape' import { initLog } from '../lib/log.js' -import { matchHash, getHash, to, navigate, back, state } from './router.js' +import { to, navigate, back, state } from './router.js' +import { matchHash, getHash } from './utils.js' import { stage } from '../launch.js' import Component from '../component.js' import symbols from '../lib/symbols.js' @@ -920,7 +921,7 @@ test('keepAlive override does not bleed into the destination route options', asy // Verify destination route (/srcB) does NOT have keepAlive in its options — // the override only applies to the route being left - const destRoute = matchHash({ path: '/srcB' }, routesList) + const destRoute = matchHash({ path: '/srcB' }, routesList, {}) assert.equal( destRoute.options.keepAlive, false, diff --git a/src/router/utils.js b/src/router/utils.js new file mode 100644 index 00000000..6348ff07 --- /dev/null +++ b/src/router/utils.js @@ -0,0 +1,158 @@ +import { default as fadeInFadeOutTransition } from './transitions/fadeInOut.js' + +/** + * Get the current hash + * @returns {Hash} + */ +export const getHash = (hash) => { + if (!hash) hash = '/' + const hashParts = hash.replace(/^#/, '').split('?') + return { + path: hashParts[0], + queryParams: new URLSearchParams(hashParts[1]), + hash: hash, + } +} + +export const normalizePath = (path) => { + return ( + path + // remove leading and trailing slashes + .replace(/^\/+|\/+$/g, '') + .toLowerCase() + ) +} + +/** + * Check if a value is an object + * @param {any} v + * @returns {boolean} True if v is an object + */ +export const isObject = (v) => typeof v === 'object' && v !== null + +/** + * Check if a value is a function + * @param {any} v + * @returns {boolean} True if v is a string + */ +export const isString = (v) => typeof v === 'string' + +export const queryParamsToObject = (queryParams) => { + if (!queryParams) return {} + const object = {} + const queryParamsEntries = [...queryParams.entries()] + for (let i = 0; i < queryParamsEntries.length; i++) { + object[queryParamsEntries[i][0]] = queryParamsEntries[i][1] + } + + return object +} + +/** + * Default Route options + * + */ +const defaultOptions = { + inHistory: true, + keepAlive: false, + passFocus: true, + reuseComponent: false, +} + +export const makeRouteObject = (route, overrides, overrideOptions, navigationData) => { + // FIX: exclude keepAlive from the destination route options. Unlike other + // overrides, keepAlive applies to the route being LEFT, not the destination. + // It is consumed by removeView() instead. + const { keepAlive: _keepAlive, ...destOverrides } = overrideOptions // eslint-disable-line no-unused-vars + + const cleanRoute = { + hash: overrides.hash, + path: route.path, + component: route.component, + transition: 'transition' in route ? route.transition : fadeInFadeOutTransition, + options: { ...defaultOptions, ...route.options, ...destOverrides }, + announce: route.announce || false, + hooks: route.hooks || {}, + data: { ...route.data, ...navigationData, ...overrides.queryParams }, + params: overrides.params || {}, + meta: route.meta || {}, + } + + return cleanRoute +} + +/** + * Match a path to a route + * + * @param {object} hashObject + * @param {Route[]} routes + * @returns {Route} + */ +export const matchHash = ( + { hash, path, queryParams }, + routes = [], + overrideOptions = {}, + navigationData = {} +) => { + // remove trailing slashes + const originalPath = path.replace(/^\/+|\/+$/g, '') + const originalNormalizedPath = normalizePath(path) + + const override = { + hash: hash, + queryParams: queryParamsToObject(queryParams), + path: path, + } + + /** @type {boolean|Route} */ + let matchingRoute = false + let i = 0 + while (!matchingRoute && i < routes.length) { + const route = routes[i] + + const normalizedPath = normalizePath(route.path) + if (normalizePath(normalizedPath) === originalNormalizedPath) { + matchingRoute = makeRouteObject(route, override, overrideOptions, navigationData) + } else if (normalizedPath.indexOf(':') > -1) { + // match dynamic route parts + const dynamicRouteParts = [...normalizedPath.matchAll(/:([^\s/]+)/gi)] + + // construct a regex for the route with dynamic parts + let dynamicRoutePartsRegex = normalizedPath + dynamicRouteParts.reverse().forEach((part) => { + dynamicRoutePartsRegex = + dynamicRoutePartsRegex.substring(0, part.index) + + '([^\\s/]+)' + + dynamicRoutePartsRegex.substring(part.index + part[0].length) + }) + + dynamicRoutePartsRegex = '^' + dynamicRoutePartsRegex + + // test if the constructed regex matches the path + const match = originalPath.match(new RegExp(`${dynamicRoutePartsRegex}`, 'i')) + + if (match) { + // map the route params to a params object + override.params = dynamicRouteParts.reverse().reduce((acc, part, index) => { + acc[part[1]] = match[index + 1] + return acc + }, {}) + + matchingRoute = makeRouteObject(route, override, overrideOptions, navigationData) + } + } else if (normalizedPath.endsWith('*')) { + const regex = new RegExp(normalizedPath.replace(/\/?\*/, '/?([^\\s]*)'), 'i') + const match = originalNormalizedPath.match(regex) + + if (match) { + override.params = {} + if (match[1]) override.params.path = match[1] + matchingRoute = makeRouteObject(route, override, overrideOptions, navigationData) + } + } + i++ + } + + // @ts-ignore - Remove me when we have a better way to handle this + return matchingRoute +}