From a3635d263ac826982aff9370a5872b7c43ca27e5 Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Fri, 18 Sep 2020 11:36:52 -0700 Subject: [PATCH] Organize routing functions (#1434) --- runtime/src/app/app.ts | 124 +++------------ runtime/src/app/goto/index.ts | 2 +- runtime/src/app/index.ts | 2 +- runtime/src/app/prefetch/index.ts | 3 +- runtime/src/app/router/index.ts | 253 ++++++++++++++++++++++++++++++ runtime/src/app/start/index.ts | 147 ----------------- 6 files changed, 275 insertions(+), 256 deletions(-) create mode 100644 runtime/src/app/router/index.ts delete mode 100644 runtime/src/app/start/index.ts diff --git a/runtime/src/app/app.ts b/runtime/src/app/app.ts index 29d8283d5..43f097b9c 100644 --- a/runtime/src/app/app.ts +++ b/runtime/src/app/app.ts @@ -1,19 +1,21 @@ import { writable } from 'svelte/store'; import App from '@sapper/internal/App.svelte'; -import { Query } from '@sapper/internal/shared'; +import { + extract_query, + init, + load_current_page, + select_target +} from './router'; import { DOMComponentLoader, DOMComponentModule, ErrorComponent, - ignore, components, - root_comp, - routes + root_comp } from '@sapper/internal/manifest-client'; import { HydratedTarget, Target, - ScrollPosition, Redirect, Page } from './types'; @@ -71,69 +73,23 @@ export function set_target(element) { target = element; } -export let uid = 1; -export function set_uid(n) { - uid = n; -} +export default function start(opts: { + target: Node +}): Promise { + set_target(opts.target); -export let cid: number; -export function set_cid(n) { - cid = n; -} + init(initial_data.baseUrl, handle_target); -const _history = typeof history !== 'undefined' ? history : { - pushState: (state: any, title: string, href: string) => {}, - replaceState: (state: any, title: string, href: string) => {}, - scrollRestoration: '' -}; -export { _history as history }; - -export const scroll_history: Record = {}; - -export function extract_query(search: string) { - const query = Object.create(null); - if (search.length > 0) { - search.slice(1).split('&').forEach(searchParam => { - const [, key, value = ''] = /([^=]*)(?:=(.*))?/.exec(decodeURIComponent(searchParam.replace(/\+/g, ' '))); - if (typeof query[key] === 'string') query[key] = [query[key]]; - if (typeof query[key] === 'object') (query[key] as string[]).push(value); - else query[key] = value; + if (initial_data.error) { + return Promise.resolve().then(() => { + return handle_error(new URL(location.href)); }); } - return query; -} - -export function select_target(url: URL): Target { - if (url.origin !== location.origin) return null; - if (!url.pathname.startsWith(initial_data.baseUrl)) return null; - - let path = url.pathname.slice(initial_data.baseUrl.length); - - if (path === '') { - path = '/'; - } - // avoid accidental clashes between server routes and page routes - if (ignore.some(pattern => pattern.test(path))) return; - - for (let i = 0; i < routes.length; i += 1) { - const route = routes[i]; - - const match = route.pattern.exec(path); - - if (match) { - const query: Query = extract_query(url.search); - const part = route.parts[route.parts.length - 1]; - const params = part.params ? part.params(match) : {}; - - const page = { host: location.host, path, query, params }; - - return { href: url.href, route, match, page }; - } - } + return load_current_page(); } -export function handle_error(url: URL) { +function handle_error(url: URL) { const { host, pathname, search } = location; const { session, preloaded, status, error } = initial_data; @@ -162,29 +118,7 @@ export function handle_error(url: URL) { render([], props, { host, path: pathname, query, params: {} }); } -export function scroll_state() { - return { - x: pageXOffset, - y: pageYOffset - }; -} - -export async function navigate(dest: Target, id: number, noscroll?: boolean, hash?: string): Promise { - if (id) { - // popstate or initial navigation - cid = id; - } else { - const current_scroll = scroll_state(); - - // clicked on a link. preserve scroll state - scroll_history[cid] = current_scroll; - - id = cid = ++uid; - scroll_history[cid] = noscroll ? current_scroll : { x: 0, y: 0 }; - } - - cid = id; - +async function handle_target(dest: Target): Promise { if (root_component) stores.preloading.set(true); const loaded = prefetching && prefetching.href === dest.href ? @@ -204,28 +138,6 @@ export async function navigate(dest: Target, id: number, noscroll?: boolean, has const { props, branch } = loaded_result; await render(branch, props, dest.page); } - if (document.activeElement && (document.activeElement instanceof HTMLElement)) document.activeElement.blur(); - - if (!noscroll) { - let scroll = scroll_history[id]; - - if (hash) { - // scroll is an element id (from a hash), we need to compute y. - const deep_linked = document.getElementById(hash.slice(1)); - - if (deep_linked) { - scroll = { - x: 0, - y: deep_linked.getBoundingClientRect().top + scrollY - }; - } - } - - scroll_history[cid] = scroll; - if (scroll) { - redirect ? scrollTo(0, 0) : scrollTo(scroll.x, scroll.y); - } - } } async function render(branch: any[], props: any, page: Page) { diff --git a/runtime/src/app/goto/index.ts b/runtime/src/app/goto/index.ts index d648c54dc..a9305e5a5 100644 --- a/runtime/src/app/goto/index.ts +++ b/runtime/src/app/goto/index.ts @@ -1,4 +1,4 @@ -import { history, select_target, navigate, cid } from '../app'; +import { cid, history, navigate, select_target } from '../router'; export default function goto( href: string, diff --git a/runtime/src/app/index.ts b/runtime/src/app/index.ts index 23fea1619..8b1e3cbb2 100644 --- a/runtime/src/app/index.ts +++ b/runtime/src/app/index.ts @@ -3,7 +3,7 @@ import { CONTEXT_KEY } from '@sapper/internal/shared'; export const stores = () => getContext(CONTEXT_KEY); -export { default as start } from './start/index'; +export { default as start } from './app'; export { default as goto } from './goto/index'; export { default as prefetch } from './prefetch/index'; export { default as prefetchRoutes } from './prefetchRoutes/index'; \ No newline at end of file diff --git a/runtime/src/app/prefetch/index.ts b/runtime/src/app/prefetch/index.ts index 3942215a8..c201df59b 100644 --- a/runtime/src/app/prefetch/index.ts +++ b/runtime/src/app/prefetch/index.ts @@ -1,4 +1,5 @@ -import { select_target, prefetching, set_prefetching, hydrate_target } from '../app'; +import { prefetching, set_prefetching, hydrate_target } from '../app'; +import { select_target } from '../router'; export default function prefetch(href: string) { const target = select_target(new URL(href, document.baseURI)); diff --git a/runtime/src/app/router/index.ts b/runtime/src/app/router/index.ts new file mode 100644 index 000000000..4a8f5501a --- /dev/null +++ b/runtime/src/app/router/index.ts @@ -0,0 +1,253 @@ +import { + ScrollPosition, + Target +} from '../types'; +import prefetch from '../prefetch/index'; +import { + ignore, + routes +} from '@sapper/internal/manifest-client'; +import { Query } from '@sapper/internal/shared'; + +export let uid = 1; +export function set_uid(n: number) { + uid = n; +} + +export let cid: number; +export function set_cid(n: number) { + cid = n; +} + +const _history = typeof history !== 'undefined' ? history : { + pushState: (state: any, title: string, href: string) => {}, + replaceState: (state: any, title: string, href: string) => {}, + scrollRestoration: '' +}; +export { _history as history }; + +export const scroll_history: Record = {}; + +export function load_current_page(): Promise { + return Promise.resolve().then(() => { + const { hash, href } = location; + + _history.replaceState({ id: uid }, '', href); + + const target = select_target(new URL(location.href)); + if (target) return navigate(target, uid, true, hash); + }); +} + +let base_url: string; +let handle_target: (dest: Target) => Promise; + +export function init(base: string, handler: (dest: Target) => Promise): void { + base_url = base; + handle_target = handler; + + if ('scrollRestoration' in _history) { + _history.scrollRestoration = 'manual'; + } + + // Adopted from Nuxt.js + // Reset scrollRestoration to auto when leaving page, allowing page reload + // and back-navigation from other pages to use the browser to restore the + // scrolling position. + addEventListener('beforeunload', () => { + _history.scrollRestoration = 'auto'; + }); + + // Setting scrollRestoration to manual again when returning to this page. + addEventListener('load', () => { + _history.scrollRestoration = 'manual'; + }); + + addEventListener('click', handle_click); + addEventListener('popstate', handle_popstate); + + // prefetch + addEventListener('touchstart', trigger_prefetch); + addEventListener('mousemove', handle_mousemove); +} + +export function extract_query(search: string) { + const query = Object.create(null); + if (search.length > 0) { + search.slice(1).split('&').forEach(searchParam => { + const [, key, value = ''] = /([^=]*)(?:=(.*))?/.exec(decodeURIComponent(searchParam.replace(/\+/g, ' '))); + if (typeof query[key] === 'string') query[key] = [query[key]]; + if (typeof query[key] === 'object') (query[key] as string[]).push(value); + else query[key] = value; + }); + } + return query; +} + +export function select_target(url: URL): Target { + if (url.origin !== location.origin) return null; + if (!url.pathname.startsWith(base_url)) return null; + + let path = url.pathname.slice(base_url.length); + + if (path === '') { + path = '/'; + } + + // avoid accidental clashes between server routes and page routes + if (ignore.some(pattern => pattern.test(path))) return; + + for (let i = 0; i < routes.length; i += 1) { + const route = routes[i]; + + const match = route.pattern.exec(path); + + if (match) { + const query: Query = extract_query(url.search); + const part = route.parts[route.parts.length - 1]; + const params = part.params ? part.params(match) : {}; + + const page = { host: location.host, path, query, params }; + + return { href: url.href, route, match, page }; + } + } +} + +let mousemove_timeout: NodeJS.Timer; + +function handle_mousemove(event: MouseEvent) { + clearTimeout(mousemove_timeout); + mousemove_timeout = setTimeout(() => { + trigger_prefetch(event); + }, 20); +} + +function trigger_prefetch(event: MouseEvent | TouchEvent) { + const a: HTMLAnchorElement = find_anchor(event.target); + if (!a || a.rel !== 'prefetch') return; + + prefetch(a.href); +} + +function handle_click(event: MouseEvent) { + // Adapted from https://github.com/visionmedia/page.js + // MIT license https://github.com/visionmedia/page.js#license + if (which(event) !== 1) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if (event.defaultPrevented) return; + + const a: HTMLAnchorElement | SVGAElement = find_anchor(event.target); + if (!a) return; + + if (!a.href) return; + + // check if link is inside an svg + // in this case, both href and target are always inside an object + const svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString'; + const href = String(svg ? (a).href.baseVal : a.href); + + if (href === location.href) { + if (!location.hash) event.preventDefault(); + return; + } + + // Ignore if tag has + // 1. 'download' attribute + // 2. rel='external' attribute + if (a.hasAttribute('download') || a.getAttribute('rel') === 'external') return; + + // Ignore if has a target + if (svg ? (a).target.baseVal : a.target) return; + + const url = new URL(href); + + // Don't handle hash changes + if (url.pathname === location.pathname && url.search === location.search) return; + + const target = select_target(url); + if (target) { + const noscroll = a.hasAttribute('sapper:noscroll'); + navigate(target, null, noscroll, url.hash); + event.preventDefault(); + _history.pushState({ id: cid }, '', url.href); + } +} + +function which(event: MouseEvent) { + return event.which === null ? event.button : event.which; +} + +function find_anchor(node: Node) { + while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG elements have a lowercase name + return node; +} + +function scroll_state() { + return { + x: pageXOffset, + y: pageYOffset + }; +} + +function handle_popstate(event: PopStateEvent) { + scroll_history[cid] = scroll_state(); + + if (event.state) { + const url = new URL(location.href); + const target = select_target(url); + if (target) { + navigate(target, event.state.id); + } else { + // eslint-disable-next-line + location.href = location.href; // nosonar + } + } else { + // hashchange + set_uid(uid + 1); + set_cid(uid); + _history.replaceState({ id: cid }, '', location.href); + } +} + +export async function navigate(dest: Target, id: number, noscroll?: boolean, hash?: string): Promise { + const popstate = !!id; + if (popstate) { + cid = id; + } else { + const current_scroll = scroll_state(); + + // clicked on a link. preserve scroll state + scroll_history[cid] = current_scroll; + + cid = id = ++uid; + scroll_history[cid] = noscroll ? current_scroll : { x: 0, y: 0 }; + } + + await handle_target(dest); + if (document.activeElement && (document.activeElement instanceof HTMLElement)) document.activeElement.blur(); + + if (!noscroll) { + let scroll = scroll_history[id]; + + let deep_linked; + if (hash) { + // scroll is an element id (from a hash), we need to compute y. + deep_linked = document.getElementById(hash.slice(1)); + + if (deep_linked) { + scroll = { + x: 0, + y: deep_linked.getBoundingClientRect().top + scrollY + }; + } + } + + scroll_history[cid] = scroll; + if (popstate || deep_linked) { + scrollTo(scroll.x, scroll.y); + } else { + scrollTo(0, 0); + } + } +} diff --git a/runtime/src/app/start/index.ts b/runtime/src/app/start/index.ts deleted file mode 100644 index 71b8e4622..000000000 --- a/runtime/src/app/start/index.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { - cid, - history, - initial_data, - navigate, - scroll_history, - scroll_state, - select_target, - handle_error, - set_target, - uid, - set_uid, - set_cid -} from '../app'; -import prefetch from '../prefetch/index'; - -export default function start(opts: { - target: Node -}): Promise { - if ('scrollRestoration' in history) { - history.scrollRestoration = 'manual'; - } - - // Adopted from Nuxt.js - // Reset scrollRestoration to auto when leaving page, allowing page reload - // and back-navigation from other pages to use the browser to restore the - // scrolling position. - addEventListener('beforeunload', () => { - history.scrollRestoration = 'auto'; - }); - - // Setting scrollRestoration to manual again when returning to this page. - addEventListener('load', () => { - history.scrollRestoration = 'manual'; - }); - - set_target(opts.target); - - addEventListener('click', handle_click); - addEventListener('popstate', handle_popstate); - - // prefetch - addEventListener('touchstart', trigger_prefetch); - addEventListener('mousemove', handle_mousemove); - - return Promise.resolve().then(() => { - const { hash, href } = location; - - history.replaceState({ id: uid }, '', href); - - const url = new URL(location.href); - - if (initial_data.error) return handle_error(url); - - const target = select_target(url); - if (target) return navigate(target, uid, true, hash); - }); -} - -let mousemove_timeout: NodeJS.Timer; - -function handle_mousemove(event: MouseEvent) { - clearTimeout(mousemove_timeout); - mousemove_timeout = setTimeout(() => { - trigger_prefetch(event); - }, 20); -} - -function trigger_prefetch(event: MouseEvent | TouchEvent) { - const a: HTMLAnchorElement = find_anchor(event.target); - if (!a || a.rel !== 'prefetch') return; - - prefetch(a.href); -} - -function handle_click(event: MouseEvent) { - // Adapted from https://github.com/visionmedia/page.js - // MIT license https://github.com/visionmedia/page.js#license - if (which(event) !== 1) return; - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - if (event.defaultPrevented) return; - - const a: HTMLAnchorElement | SVGAElement = find_anchor(event.target); - if (!a) return; - - if (!a.href) return; - - // check if link is inside an svg - // in this case, both href and target are always inside an object - const svg = typeof a.href === 'object' && a.href.constructor.name === 'SVGAnimatedString'; - const href = String(svg ? (a).href.baseVal : a.href); - - if (href === location.href) { - if (!location.hash) event.preventDefault(); - return; - } - - // Ignore if tag has - // 1. 'download' attribute - // 2. rel='external' attribute - if (a.hasAttribute('download') || a.getAttribute('rel') === 'external') return; - - // Ignore if has a target - if (svg ? (a).target.baseVal : a.target) return; - - const url = new URL(href); - - // Don't handle hash changes - if (url.pathname === location.pathname && url.search === location.search) return; - - const target = select_target(url); - if (target) { - const noscroll = a.hasAttribute('sapper:noscroll'); - navigate(target, null, noscroll, url.hash); - event.preventDefault(); - history.pushState({ id: cid }, '', url.href); - } -} - -function which(event: MouseEvent) { - return event.which === null ? event.button : event.which; -} - -function find_anchor(node: Node) { - while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG elements have a lowercase name - return node; -} - -function handle_popstate(event: PopStateEvent) { - scroll_history[cid] = scroll_state(); - - if (event.state) { - const url = new URL(location.href); - const target = select_target(url); - if (target) { - navigate(target, event.state.id); - } else { - // eslint-disable-next-line - location.href = location.href; // nosonar - } - } else { - // hashchange - set_uid(uid + 1); - set_cid(uid); - history.replaceState({ id: cid }, '', location.href); - } -}