UNPKG

@roxi/routify

Version:

235 lines (190 loc) 7.53 kB
import { getCreateUrl } from '../helpers/index.js' import { populateUrl } from '../utils/index.js' import { RouteFragment } from './RouteFragment.js' import { LoadCache } from './utils.js' /** @type {UrlState[]} */ const URL_STATES = ['pushState', 'replaceState', 'popState'] const loadCache = new LoadCache() let id = 0 export class Route { /** @type {RouteFragment[]} */ allFragments = [] /** @type {RouteFragment[]} only fragments with components */ fragments = [] /** @type {RoutifyLoadReturn} */ load = { status: 200, error: null, maxage: null, props: {}, redirect: null, } /** @type {Route} */ prevRoute /** @type {Route} */ nextRoute /** * @param {Router} router * @param {string} url * @param {UrlState} mode * @param {Object} state a state to attach to the route */ constructor(router, url, mode, state = {}) { this.sourceUrl = typeof url === 'string' ? new URL(url, 'http://localhost') : url this.router = router this.mode = mode this.state = state this.state.createdAt = new Date() this.state.id = this.state.id ?? Date.now() + '-' + id++ if (!router.rootNode) { const err = new Error( "Can't navigate without a rootNode. Have you imported routes?", ) Object.assign(err, { routify: { router } }) throw err } if (!URL_STATES.includes(mode)) throw new Error('url.mode must be pushState, replaceState or popState') this.allFragments = this._createFragments(this.sourceUrl.pathname) this.params = { ...this.queryParams, ...this.fragmentParams } this.fragments = this.router.transformFragments.run(this.allFragments) this.url = this._createUrl() this.rendered = Promise.all(this.fragments.map(f => f.renderContext)) this.log = router.log.createChild('[route]') // ROUTIFY-DEV-ONLY this.log.debug('created', this) // ROUTIFY-DEV-ONLY } get fragmentParams() { return this.allFragments.reduce((acc, curr) => ({ ...acc, ...curr.params }), {}) } get queryParams() { return this.router.queryHandler.parse(this.sourceUrl.search, this) } get leaf() { return [...this.fragments].pop() } get isPendingOrPrefetch() { return this === this.router.pendingRoute.get() || this.state.prefetch } get isActive() { return this.router.activeRoute.get() === this } async loadRoute() { const pipeline = [ this.runBeforeUrlChangeHooks, this.loadComponents, this.runPreloads, ] // todo get rid of this.loaded and use loadRoute() 's promise instead for (const pretask of pipeline) { const passedPreTask = await pretask.bind(this)() // if this is not the pending route // or a pipeline entry failed, halt and return if (!this.isPendingOrPrefetch || !passedPreTask) return false } this.log.debug('loaded route', this) // ROUTIFY-DEV-ONLY return true } /** * converts async module functions to sync functions */ async loadComponents() { this.log.debug('load components', this) // ROUTIFY-DEV-ONLY const nodes = this.fragments.map(fragment => fragment.node) const multiNodes = nodes .map(node => node.children.find( node => node.meta.isDecorator || node.meta.isInlineWrapper, ), ) .filter(Boolean) await Promise.all([...nodes, ...multiNodes].map(node => node.loadModule())) return true } async runPreloads() { this.log.debug('run preloads', this) // ROUTIFY-DEV-ONLY const prevRoute = this.router.activeRoute.get() for (const [index, fragment] of this.fragments.entries()) { // if something happened and this route is no longer pending, abort if (!this.isPendingOrPrefetch) return false const prevFragmentInSpot = prevRoute?.fragments[index] const isSameBranch = fragment.node === prevFragmentInSpot?.node /** @type { RoutifyLoadContext } */ const ctx = { route: this, url: getCreateUrl(this.router, fragment), prevRoute, isNew: !isSameBranch, fetch, fragment, } if (fragment.node.module?.load) { const cacheId = JSON.stringify([this.params, fragment.node.id]) const load = await loadCache.fetch(cacheId, { hydrate: () => fragment.node.module.load(ctx), // if no expire is set clear it on load, unless it's a prefetch clear: res => res?.expire || !this.state.prefetch, }) fragment.load = { ...(isSameBranch && prevFragmentInSpot.load), ...load, } Object.assign(this.load, fragment.load) if (this.load.redirect && !this.state.prefetch) return this.router.url.replace(this.load.redirect, { redirectedBy: this, }) } } return this } async runBeforeUrlChangeHooks() { return await this.router.beforeUrlChange.run({ route: this }) } get meta() { return this.fragments.reduce((acc, curr) => ({ ...acc, ...curr.node.meta }), {}) } /** * @param {RNodeRuntime} node the node that corresponds to the fragment * @param {String=} urlFragment a fragment of the url (fragments = url.split('/')) * @param {Object<string, any>=} params * @returns {RouteFragment} */ createFragment(node, urlFragment = '', params = {}) { return new RouteFragment(this, node, urlFragment, params) } /** * creates fragments. A fragment is the section between each / in the URL */ _createFragments(pathname) { const rootNode = this.router.rootNode const nodeChain = this.router.rootNode.getChainTo(pathname, { rootNode, allowDynamic: true, includeIndex: true, navigableChildrenOnly: true, }) const fragments = nodeChain.map(nc => this.createFragment(nc.node, nc.fragment, nc.params), ) return fragments } _createUrl() { const queryStringifier = obj => this.router.queryHandler.stringify(obj, this) // index 0 is the root node, which is always empty const path = this.allFragments.map((f, i) => (i ? f.node.name : '')).join('/') const populatedUrl = populateUrl(path, this.params, queryStringifier) const shouldAddSlash = this.router.options.trailingSlash === 'always' || (this.router.options.trailingSlash === 'preserve' && this.sourceUrl.pathname.endsWith('/')) || (this.router.options.trailingSlash === 'contextual' && [...this.allFragments].pop().node.name === 'index') const slash = shouldAddSlash ? '/' : '' return ( populatedUrl.path.replace(/\/index$/, '') + slash + populatedUrl.query + this.sourceUrl.hash ) } }