@sveltejs/kit
Version:
Here be dragons, etc etc.
518 lines (420 loc) • 11.7 kB
JavaScript
import Root from '../../generated/root.svelte';
import { layout, pages, ignore } from '../../generated/manifest.js';
import { writable } from 'svelte/store';
import { init } from './singletons.js';
function find_anchor(node) {
while (node && node.nodeName.toUpperCase() !== 'A') node = node.parentNode; // SVG <a> elements have a lowercase name
return node;
}
function which(event) {
return event.which === null ? event.button : event.which;
}
function scroll_state() {
return {
x: pageXOffset,
y: pageYOffset
};
}
class Router {
constructor({ base, pages, ignore }) {
this.base = base;
this.pages = pages;
this.ignore = ignore;
this.uid = 1;
this.cid = null;
this.scroll_history = {};
this.history = window.history || {
pushState: () => {},
replaceState: () => {},
scrollRestoration: 'auto'
};
}
init({ renderer }) {
this.renderer = renderer;
renderer.router = this;
if ('scrollRestoration' in this.history) {
this.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', () => {
this.history.scrollRestoration = 'auto';
});
// Setting scrollRestoration to manual again when returning to this page.
addEventListener('load', () => {
this.history.scrollRestoration = 'manual';
});
addEventListener('click', event => {
// 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 = 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 <a> 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 page = this.select(url);
if (page) {
const noscroll = a.hasAttribute('sapper:noscroll');
this.navigate(page, null, noscroll, url.hash);
event.preventDefault();
this.history.pushState({ id: this.cid }, '', url.href);
}
});
addEventListener('popstate', event => {
this.scroll_history[this.cid] = scroll_state();
if (event.state) {
const url = new URL(location.href);
const page = this.select(url);
if (page) {
this.navigate(page, event.state.id);
} else {
// eslint-disable-next-line
location.href = location.href; // nosonar
}
} else {
// hashchange
this.uid += 1;
this.cid = this.uid;
this.history.replaceState({ id: this.cid }, '', location.href);
}
});
// load current page
this.history.replaceState({ id: this.uid }, '', location.href);
this.scroll_history[this.uid] = scroll_state();
const page = this.select(new URL(location.href));
// if (page) return this.navigate(page, this.uid, true, hash);
if (page) return this.renderer.start(page);
}
select(url) {
if (url.origin !== location.origin) return null;
if (!url.pathname.startsWith(this.base)) return null;
let path = url.pathname.slice(this.base.length);
if (path === '') {
path = '/';
}
// avoid accidental clashes between server routes and page routes
if (this.ignore.some(pattern => pattern.test(path))) return;
for (const route of this.pages) {
const match = route.pattern.exec(path);
if (match) {
const query = new URLSearchParams(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 };
}
}
}
async navigate(
page,
id,
noscroll,
hash
) {
const popstate = !!id;
if (popstate) {
this.cid = id;
} else {
const current_scroll = scroll_state();
// clicked on a link. preserve scroll state
this.scroll_history[this.cid] = current_scroll;
this.cid = id = ++this.uid;
this.scroll_history[this.cid] = noscroll ? current_scroll : { x: 0, y: 0 };
}
await this.renderer.render(page);
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
if (!noscroll) {
let scroll = this.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
};
}
}
this.scroll_history[this.cid] = scroll;
if (popstate || deep_linked) {
scrollTo(scroll.x, scroll.y);
} else {
scrollTo(0, 0);
}
}
}
}
function page_store(value) {
const store = writable(value);
let ready = true;
function notify() {
ready = true;
store.update((val) => val);
}
function set(new_value) {
ready = false;
store.set(new_value);
}
function subscribe(run) {
let old_value;
return store.subscribe((new_value) => {
if (old_value === undefined || (ready && new_value !== old_value)) {
run((old_value = new_value));
}
});
}
return { notify, set, subscribe };
}
class Renderer {
constructor({
Root,
layout,
target,
error,
status,
preloaded,
session
}) {
this.Root = Root;
this.layout = layout;
this.layout_loader = () => layout;
// TODO ideally we wouldn't need to store these...
this.target = target;
this.initial = {
preloaded,
error,
status
};
this.current_branch = [];
this.prefetching = {
href: null,
promise: null
};
this.stores = {
page: page_store({}),
navigating: writable(false),
session: writable(session)
};
this.$session = null;
this.session_dirty = false;
this.root = null;
const trigger_prefetch = (event) => {
const a = find_anchor(event.target);
if (a && a.rel === 'prefetch') { // TODO make this svelte-prefetch or something
this.prefetch(new URL(a.href));
}
};
let mousemove_timeout;
const handle_mousemove = (event) => {
clearTimeout(mousemove_timeout);
mousemove_timeout = setTimeout(() => {
trigger_prefetch(event);
}, 20);
};
addEventListener('touchstart', trigger_prefetch);
addEventListener('mousemove', handle_mousemove);
let ready = false;
this.stores.session.subscribe(async (value) => {
this.$session = value;
if (!ready) return;
this.session_dirty = true;
const page = this.router.select(new URL(location.href));
this.render(page);
});
ready = true;
}
async start(page) {
const props = {
stores: this.stores,
error: this.initial.error,
status: this.initial.status,
page: {
...page.page,
params: {}
}
};
if (this.initial.error) {
props.components = [this.layout.default];
} else {
const hydrated = await this.hydrate(page);
if (hydrated.redirect) {
throw new Error('TODO client-side redirects');
}
Object.assign(props, hydrated.props);
this.current_branch = hydrated.branch;
this.current_query = hydrated.query;
this.current_path = hydrated.path;
}
this.root = new this.Root({
target: this.target,
props,
hydrate: true
});
this.initial = null;
}
async render(page) {
const token = this.token = {};
this.stores.navigating.set(true);
const hydrated = await this.hydrate(page);
if (this.token === token) { // check render wasn't aborted
this.current_branch = hydrated.branch;
this.current_query = hydrated.query;
this.current_path = hydrated.path;
this.root.$set(hydrated.props);
this.stores.navigating.set(false);
}
}
async hydrate({ route, page }) {
let redirect = null;
const props = {
error: null,
status: 200,
components: []
};
const preload_context = {
fetch: (url, opts) => fetch(url, opts),
redirect: (status, location) => {
if (redirect && (redirect.status !== status || redirect.location !== location)) {
throw new Error('Conflicting redirects');
}
redirect = { status, location };
},
error: (status, error) => {
props.error = typeof error === 'string' ? new Error(error) : error;
props.status = status;
}
};
const query = page.query.toString();
const query_dirty = query !== this.current_query;
let branch;
try {
const match = route.pattern.exec(page.path);
branch = await Promise.all(
[[this.layout_loader], ...route.parts].map(async ([loader, get_params], i) => {
const params = get_params ? get_params(match) : {};
const stringified_params = JSON.stringify(params);
const previous = this.current_branch[i];
if (previous) {
const changed = (
(previous.loader !== loader) ||
(previous.uses_session && this.session_dirty) ||
(previous.uses_query && query_dirty) ||
(previous.stringified_params !== stringified_params)
);
if (!changed) {
props.components[i] = previous.component;
return previous;
}
}
const { default: component, preload } = await loader();
const uses_session = preload && preload.length > 1;
let uses_query = false;
const preloaded = this.initial?.preloaded[i] || (
preload
? await preload.call(
preload_context,
{
get query() {
uses_query = true;
return page.query;
},
host: page.host,
path: page.path,
params
},
this.$session
)
: {}
);
// TODO weird to have side-effects inside a map, but
// if they're not here, then setting props_n objects
// only for changed parts becomes trickier
props.components[i] = component;
props[`props_${i}`] = preloaded;
return {
component,
params,
stringified_params,
props: preloaded,
match,
loader,
uses_session,
uses_query
};
})
);
if (page.path !== this.current_path) {
props.page = {
...page,
params: branch[branch.length - 1].params
};
}
} catch (error) {
props.error = error;
props.status = 500;
branch = [];
}
return { redirect, props, branch, query, path: page.path };
}
async prefetch(url) {
const page = this.router.select(url);
if (page) {
if (url.href !== this.prefetching.href) {
this.prefetching = { href: url.href, promise: this.hydrate(page) };
}
return this.prefetching.promise;
} else {
throw new Error(`Could not prefetch ${url.href}`);
}
}
}
async function start({
base,
target,
session,
preloaded,
error,
status
}) {
const router = new Router({
base,
pages,
ignore
});
const renderer = new Renderer({
Root,
layout,
target,
preloaded,
error,
status,
session
});
init({ router, renderer });
await router.init({ renderer });
}
export { start };
//# sourceMappingURL=start.js.map