import { writable } from 'svelte/store.mjs'; import Sapper from '@sapper/internal/Sapper.html'; import { stores } from '@sapper/internal/shared'; import { Root, root_preload, ErrorComponent, ignore, components, routes } from '@sapper/internal/manifest-client'; import { Target, ScrollPosition, Component, Redirect, ComponentLoader, ComponentConstructor, Route, Page } from './types'; import goto from './goto'; declare const __SAPPER__; export const initial_data = typeof __SAPPER__ !== 'undefined' && __SAPPER__; let ready = false; let root_component: Component; let current_token: {}; let root_preloaded: Promise; let current_branch = []; const session = writable(initial_data && initial_data.session); let $session; let session_dirty: boolean; session.subscribe(async value => { $session = value; if (!ready) return; session_dirty = true; const target = select_target(new URL(location.href)); const token = current_token = {}; const { redirect, props, branch } = await hydrate_target(target); if (token !== current_token) return; // a secondary navigation happened while we were loading await render(redirect, branch, props, target.page); }); export let prefetching: { href: string; promise: Promise<{ redirect?: Redirect, data?: any }>; } = null; export function set_prefetching(href, promise) { prefetching = { href, promise }; } export let store; export function set_store(fn) { store = fn(initial_data.store); } export let target: Node; export function set_target(element) { target = element; } export let uid = 1; export function set_uid(n) { uid = n; } export let cid: number; export function set_cid(n) { 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 select_target(url: URL): Target { if (url.origin !== location.origin) return null; if (!url.pathname.startsWith(initial_data.baseUrl)) return null; const path = url.pathname.slice(initial_data.baseUrl.length); // 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: Record = Object.create(null); if (url.search.length > 0) { url.search.slice(1).split('&').forEach(searchParam => { let [, key, value] = /([^=]*)(?:=(.*))?/.exec(decodeURIComponent(searchParam)); value = (value || '').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; }); } const part = route.parts[route.parts.length - 1]; const params = part.params ? part.params(match) : {}; const page = { path, query, params }; return { href: url.href, route, match, page }; } } } export function scroll_state() { return { x: pageXOffset, y: pageYOffset }; } export async function navigate(target: 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; if (root_component) stores.preloading.set(true); const loaded = prefetching && prefetching.href === target.href ? prefetching.promise : hydrate_target(target); prefetching = null; const token = current_token = {}; const { redirect, props, branch } = await loaded; if (token !== current_token) return; // a secondary navigation happened while we were loading await render(redirect, branch, props, target.page); if (document.activeElement) 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.querySelector(hash); if (deep_linked) { scroll = { x: 0, y: deep_linked.getBoundingClientRect().top }; } } scroll_history[cid] = scroll; if (scroll) scrollTo(scroll.x, scroll.y); } } async function render(redirect: Redirect, branch: any[], props: any, page: Page) { if (redirect) return goto(redirect.location, { replaceState: true }); stores.page.set(page); stores.preloading.set(false); if (root_component) { root_component.props = props; } else { // first load — remove SSR'd contents const start = document.querySelector('#sapper-head-start'); const end = document.querySelector('#sapper-head-end'); if (start && end) { while (start.nextSibling !== end) detach(start.nextSibling); detach(start); detach(end); } root_component = new Sapper({ target, props: { Root, props, session }, hydrate: true }); } current_branch = branch; ready = true; session_dirty = false; } export async function hydrate_target(target: Target): Promise<{ redirect?: Redirect; props?: any; branch?: Array<{ Component: ComponentConstructor, preload: (page) => Promise, segment: string }> }> { const { route, page } = target; const segments = page.path.split('/').filter(Boolean); let redirect: Redirect = null; let error: { statusCode: number, message: Error | string } = null; const preload_context = { fetch: (url: string, opts?: any) => fetch(url, opts), redirect: (statusCode: number, location: string) => { if (redirect && (redirect.statusCode !== statusCode || redirect.location !== location)) { throw new Error(`Conflicting redirects`); } redirect = { statusCode, location }; }, error: (statusCode: number, message: Error | string) => { error = { statusCode, message }; } }; if (!root_preloaded) { root_preloaded = initial_data.preloaded[0] || root_preload.call(preload_context, { path: page.path, query: page.query, params: {} }, $session); } let branch; try { branch = await Promise.all(route.parts.map(async (part, i) => { if (!part) return null; const segment = segments[i]; if (!session_dirty && current_branch[i] && current_branch[i].segment === segment) return current_branch[i]; const { default: Component, preload } = await load_component(components[part.i]); let preloaded; if (ready || !initial_data.preloaded[i + 1]) { preloaded = preload ? await preload.call(preload_context, { path: page.path, query: page.query, params: part.params ? part.params(target.match) : {} }, $session) : {}; } else { preloaded = initial_data.preloaded[i + 1]; } return { Component, preloaded, segment }; })); } catch (e) { error = { statusCode: 500, message: e }; branch = []; } if (redirect) return { redirect }; if (error) { // TODO be nice if this was less of a special case return { props: { child: { component: ErrorComponent, props: { error: typeof error.message === 'string' ? new Error(error.message) : error.message, status: error.statusCode } } }, branch }; } const props = Object.assign({}, await root_preloaded, { child: { segment: segments[0] } }); let level = props.child; branch.forEach((node, i) => { if (!node) return; level.component = node.Component; level.props = Object.assign({}, node.preloaded, { child: { segment: segments[i + 1] } }); level = level.props.child; }); return { props, branch }; } function load_css(chunk: string) { const href = `client/${chunk}`; if (document.querySelector(`link[href="${href}"]`)) return; return new Promise((fulfil, reject) => { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; link.onload = () => fulfil(); link.onerror = reject; document.head.appendChild(link); }); } export function load_component(component: ComponentLoader): Promise<{ default: ComponentConstructor, preload?: (input: any) => any }> { // TODO this is temporary — once placeholders are // always rewritten, scratch the ternary const promises: Array> = (typeof component.css === 'string' ? [] : component.css.map(load_css)); promises.unshift(component.js()); return Promise.all(promises).then(values => values[0]); } function detach(node: Node) { node.parentNode.removeChild(node); }