From 4d334e3099657b699ef8f71dcbc9056279f14917 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 28 Feb 2022 23:57:26 -0500 Subject: [PATCH 01/34] combine Router and Renderer into Client --- packages/kit/src/runtime/app/navigation.js | 18 +- .../runtime/client/{renderer.js => client.js} | 473 +++++++++++++++++- packages/kit/src/runtime/client/router.js | 453 ----------------- packages/kit/src/runtime/client/singletons.js | 16 +- packages/kit/src/runtime/client/start.js | 29 +- 5 files changed, 478 insertions(+), 511 deletions(-) rename packages/kit/src/runtime/client/{renderer.js => client.js} (64%) delete mode 100644 packages/kit/src/runtime/client/router.js diff --git a/packages/kit/src/runtime/app/navigation.js b/packages/kit/src/runtime/app/navigation.js index 6db75bb57610..944c1b77c9f2 100644 --- a/packages/kit/src/runtime/app/navigation.js +++ b/packages/kit/src/runtime/app/navigation.js @@ -1,4 +1,4 @@ -import { router, renderer } from '../client/singletons.js'; +import { client } from '../client/singletons.js'; import { get_base_uri } from '../client/utils.js'; /** @@ -24,14 +24,14 @@ export const afterNavigate = import.meta.env.SSR ? () => {} : afterNavigate_; * @type {import('$app/navigation').goto} */ async function disableScrollHandling_() { - renderer.disable_scroll_handling(); + client.disable_scroll_handling(); } /** * @type {import('$app/navigation').goto} */ async function goto_(href, opts) { - return router.goto(href, opts, []); + return client.goto(href, opts, []); } /** @@ -39,14 +39,14 @@ async function goto_(href, opts) { */ async function invalidate_(resource) { const { href } = new URL(resource, location.href); - return router.renderer.invalidate(href); + return client.invalidate(href); } /** * @type {import('$app/navigation').prefetch} */ async function prefetch_(href) { - await router.prefetch(new URL(href, get_base_uri(document))); + await client.prefetch(new URL(href, get_base_uri(document))); } /** @@ -54,8 +54,8 @@ async function prefetch_(href) { */ async function prefetchRoutes_(pathnames) { const matching = pathnames - ? router.routes.filter((route) => pathnames.some((pathname) => route[0].test(pathname))) - : router.routes; + ? client.routes.filter((route) => pathnames.some((pathname) => route[0].test(pathname))) + : client.routes; const promises = matching.map((r) => Promise.all(r[1].map((load) => load()))); @@ -66,12 +66,12 @@ async function prefetchRoutes_(pathnames) { * @type {import('$app/navigation').beforeNavigate} */ function beforeNavigate_(fn) { - if (router) router.before_navigate(fn); + client.before_navigate(fn); } /** * @type {import('$app/navigation').afterNavigate} */ function afterNavigate_(fn) { - if (router) router.after_navigate(fn); + client.after_navigate(fn); } diff --git a/packages/kit/src/runtime/client/renderer.js b/packages/kit/src/runtime/client/client.js similarity index 64% rename from packages/kit/src/runtime/client/renderer.js rename to packages/kit/src/runtime/client/client.js index 2cbba5ee41bd..3c9452ab2a29 100644 --- a/packages/kit/src/runtime/client/renderer.js +++ b/packages/kit/src/runtime/client/client.js @@ -4,12 +4,62 @@ import { coalesce_to_error } from '../../utils/error.js'; import { hash } from '../hash.js'; import { normalize } from '../load.js'; import { base } from '../paths.js'; +import { onMount } from 'svelte'; +import { normalize_path } from '../../utils/url'; +import { get_base_uri } from './utils'; /** * @typedef {import('types').CSRComponent} CSRComponent - * @typedef {{ from: URL; to: URL }} Navigating */ +// We track the scroll position associated with each history entry in sessionStorage, +// rather than on history.state itself, because when navigation is driven by +// popstate it's too late to update the scroll position associated with the +// state we're navigating from +const SCROLL_KEY = 'sveltekit:scroll'; + +/** @typedef {{ x: number, y: number }} ScrollPosition */ +/** @type {Record} */ +let scroll_positions = {}; +try { + scroll_positions = JSON.parse(sessionStorage[SCROLL_KEY]); +} catch { + // do nothing +} + +/** @param {number} index */ +function update_scroll_positions(index) { + scroll_positions[index] = scroll_state(); +} + +function scroll_state() { + return { + x: pageXOffset, + y: pageYOffset + }; +} + +/** + * @param {Event} event + * @returns {HTMLAnchorElement | SVGAElement | undefined} + */ +function find_anchor(event) { + const node = event + .composedPath() + .find((e) => e instanceof Node && e.nodeName.toUpperCase() === 'A'); // SVG elements have a lowercase name + return /** @type {HTMLAnchorElement | SVGAElement | undefined} */ (node); +} + +/** + * @param {HTMLAnchorElement | SVGAElement} node + * @returns {URL} + */ +function get_href(node) { + return node instanceof SVGAElement + ? new URL(node.href.baseVal, document.baseURI) + : new URL(node.href); +} + /** @param {any} value */ function notifiable_store(value) { const store = writable(value); @@ -112,22 +162,21 @@ function initial_fetch(resource, opts) { return fetch(resource, opts); } -export class Renderer { +export class Client { /** * @param {{ * Root: CSRComponent; * fallback: [CSRComponent, CSRComponent]; * target: Node; * session: any; + * base: string; + * routes: import('types').CSRRoute[]; + * trailing_slash: import('types').TrailingSlash; * }} opts */ - constructor({ Root, fallback, target, session }) { + constructor({ Root, fallback, target, session, base, routes, trailing_slash }) { this.Root = Root; this.fallback = fallback; - - /** @type {import('./router').Router | undefined} */ - this.router; - this.target = target; this.started = false; @@ -158,7 +207,7 @@ export class Renderer { this.stores = { url: notifiable_store({}), page: notifiable_store({}), - navigating: writable(/** @type {Navigating | null} */ (null)), + navigating: writable(/** @type {import('types').Navigation | null} */ (null)), session: writable(session), updated: create_updated_store() }; @@ -171,13 +220,47 @@ export class Renderer { this.stores.session.subscribe(async (value) => { this.$session = value; - if (!ready || !this.router) return; + if (!ready) return; this.session_id += 1; - const info = this.router.parse(new URL(location.href)); + const info = this.parse(new URL(location.href)); if (info) this.update(info, [], true); }); ready = true; + + // -------- + + this.base = base; + this.routes = routes; + this.trailing_slash = trailing_slash; + /** Keeps tracks of multiple navigations caused by redirects during rendering */ + this.navigating = 0; + + this.enabled = true; + this.initialized = false; + + // keeping track of the history index in order to prevent popstate navigation events if needed + this.current_history_index = history.state?.['sveltekit:index'] ?? 0; + + if (this.current_history_index === 0) { + // create initial history entry, so we can return here + history.replaceState({ ...history.state, 'sveltekit:index': 0 }, '', location.href); + } + + // if we reload the page, or Cmd-Shift-T back to it, + // recover scroll position + const scroll = scroll_positions[this.current_history_index]; + if (scroll) scrollTo(scroll.x, scroll.y); + + this.hash_navigating = false; + + this.callbacks = { + /** @type {Array<({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void>} */ + before_navigate: [], + + /** @type {Array<({ from, to }: { from: URL | null, to: URL }) => void>} */ + after_navigate: [] + }; } disable_scroll_handling() { @@ -334,8 +417,8 @@ export class Renderer { url: info.url }); } else { - if (this.router) { - this.router.goto(new URL(navigation_result.redirect, info.url).href, {}, [ + if (this.enabled) { + this.goto(new URL(navigation_result.redirect, info.url).href, {}, [ ...chain, info.url.pathname ]); @@ -419,13 +502,11 @@ export class Renderer { this.page = navigation_result.props.page; } - if (!this.router) return; - const leaf_node = navigation_result.state.branch[navigation_result.state.branch.length - 1]; if (leaf_node && leaf_node.module.router === false) { - this.router.disable(); + this.disable(); } else { - this.router.enable(); + this.enable(); } } @@ -446,7 +527,7 @@ export class Renderer { if (!this.invalidating) { this.invalidating = Promise.resolve().then(async () => { - const info = this.router && this.router.parse(new URL(location.href)); + const info = this.parse(new URL(location.href)); if (info) await this.update(info, [], true); this.invalidating = null; @@ -482,9 +563,9 @@ export class Renderer { this.started = true; - if (this.router) { + if (this.enabled) { const navigation = { from: null, to: new URL(location.href) }; - this.router.callbacks.after_navigate.forEach((fn) => fn(navigation)); + this.callbacks.after_navigate.forEach((fn) => fn(navigation)); } } @@ -966,4 +1047,358 @@ export class Renderer { error }); } + + init_listeners() { + 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', (e) => { + let should_block = false; + + const intent = { + from: this.current.url, + to: null, + cancel: () => (should_block = true) + }; + + this.callbacks.before_navigate.forEach((fn) => fn(intent)); + + if (should_block) { + e.preventDefault(); + e.returnValue = ''; + } else { + history.scrollRestoration = 'auto'; + } + }); + + addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + update_scroll_positions(this.current_history_index); + + try { + sessionStorage[SCROLL_KEY] = JSON.stringify(scroll_positions); + } catch { + // do nothing + } + } + }); + + /** @param {Event} event */ + const trigger_prefetch = (event) => { + const a = find_anchor(event); + if (a && a.href && a.hasAttribute('sveltekit:prefetch')) { + this.prefetch(get_href(a)); + } + }; + + /** @type {NodeJS.Timeout} */ + let mousemove_timeout; + + /** @param {MouseEvent|TouchEvent} event */ + const handle_mousemove = (event) => { + clearTimeout(mousemove_timeout); + mousemove_timeout = setTimeout(() => { + // event.composedPath(), which is used in find_anchor, will be empty if the event is read in a timeout + // add a layer of indirection to address that + event.target?.dispatchEvent( + new CustomEvent('sveltekit:trigger_prefetch', { bubbles: true }) + ); + }, 20); + }; + + addEventListener('touchstart', trigger_prefetch); + addEventListener('mousemove', handle_mousemove); + addEventListener('sveltekit:trigger_prefetch', trigger_prefetch); + + /** @param {MouseEvent} event */ + addEventListener('click', (event) => { + if (!this.enabled) return; + + // Adapted from https://github.com/visionmedia/page.js + // MIT license https://github.com/visionmedia/page.js#license + if (event.button || event.which !== 1) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if (event.defaultPrevented) return; + + const a = find_anchor(event); + if (!a) return; + + if (!a.href) return; + + const is_svg_a_element = a instanceof SVGAElement; + const url = get_href(a); + const url_string = url.toString(); + if (url_string === location.href) { + if (!location.hash) event.preventDefault(); + return; + } + + // Ignore if url does not have origin (e.g. `mailto:`, `tel:`.) + // MEMO: Without this condition, firefox will open mailer twice. + // See: https://github.com/sveltejs/kit/issues/4045 + if (!is_svg_a_element && url.origin === 'null') return; + + // Ignore if tag has + // 1. 'download' attribute + // 2. 'rel' attribute includes external + const rel = (a.getAttribute('rel') || '').split(/\s+/); + + if (a.hasAttribute('download') || (rel && rel.includes('external'))) { + return; + } + + // Ignore if has a target + if (is_svg_a_element ? a.target.baseVal : a.target) return; + + // Check if new url only differs by hash and use the browser default behavior in that case + // This will ensure the `hashchange` event is fired + // Removing the hash does a full page navigation in the browser, so make sure a hash is present + const [base, hash] = url.href.split('#'); + if (hash !== undefined && base === location.href.split('#')[0]) { + // set this flag to distinguish between navigations triggered by + // clicking a hash link and those triggered by popstate + this.hash_navigating = true; + + update_scroll_positions(this.current_history_index); + this.update_page_store(new URL(url.href)); + + return; + } + + this._navigate({ + url, + scroll: a.hasAttribute('sveltekit:noscroll') ? scroll_state() : null, + keepfocus: false, + chain: [], + details: { + state: {}, + replaceState: false + }, + accepted: () => event.preventDefault(), + blocked: () => event.preventDefault() + }); + }); + + addEventListener('popstate', (event) => { + if (event.state && this.enabled) { + // if a popstate-driven navigation is cancelled, we need to counteract it + // with history.go, which means we end up back here, hence this check + if (event.state['sveltekit:index'] === this.current_history_index) return; + + this._navigate({ + url: new URL(location.href), + scroll: scroll_positions[event.state['sveltekit:index']], + keepfocus: false, + chain: [], + details: null, + accepted: () => { + this.current_history_index = event.state['sveltekit:index']; + }, + blocked: () => { + const delta = this.current_history_index - event.state['sveltekit:index']; + history.go(delta); + } + }); + } + }); + + addEventListener('hashchange', () => { + // if the hashchange happened as a result of clicking on a link, + // we need to update history, otherwise we have to leave it alone + if (this.hash_navigating) { + this.hash_navigating = false; + history.replaceState( + { ...history.state, 'sveltekit:index': ++this.current_history_index }, + '', + location.href + ); + } + }); + + this.initialized = true; + } + + /** + * Returns true if `url` has the same origin and basepath as the app + * @param {URL} url + */ + owns(url) { + return url.origin === location.origin && url.pathname.startsWith(this.base); + } + + /** + * @param {URL} url + * @returns {import('./types').NavigationInfo | undefined} + */ + parse(url) { + if (this.owns(url)) { + const path = decodeURI(url.pathname.slice(this.base.length) || '/'); + + return { + id: url.pathname + url.search, + routes: this.routes.filter(([pattern]) => pattern.test(path)), + url, + path, + initial: !this.initialized + }; + } + } + + /** + * @typedef {Parameters} GotoParams + * + * @param {GotoParams[0]} href + * @param {GotoParams[1]} opts + * @param {string[]} chain + */ + async goto( + href, + { noscroll = false, replaceState = false, keepfocus = false, state = {} } = {}, + chain + ) { + const url = new URL(href, get_base_uri(document)); + + if (this.enabled) { + return this._navigate({ + url, + scroll: noscroll ? scroll_state() : null, + keepfocus, + chain, + details: { + state, + replaceState + }, + accepted: () => {}, + blocked: () => {} + }); + } + + location.href = url.href; + return new Promise(() => { + /* never resolves */ + }); + } + + enable() { + this.enabled = true; + } + + disable() { + this.enabled = false; + } + + /** + * @param {URL} url + * @returns {Promise} + */ + async prefetch(url) { + const info = this.parse(url); + + if (!info) { + throw new Error('Attempted to prefetch a URL that does not belong to this app'); + } + + return this.load(info); + } + + /** @param {({ from, to }: { from: URL | null, to: URL }) => void} fn */ + after_navigate(fn) { + onMount(() => { + this.callbacks.after_navigate.push(fn); + + return () => { + const i = this.callbacks.after_navigate.indexOf(fn); + this.callbacks.after_navigate.splice(i, 1); + }; + }); + } + + /** + * @param {({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void} fn + */ + before_navigate(fn) { + onMount(() => { + this.callbacks.before_navigate.push(fn); + + return () => { + const i = this.callbacks.before_navigate.indexOf(fn); + this.callbacks.before_navigate.splice(i, 1); + }; + }); + } + + /** + * @param {{ + * url: URL; + * scroll: { x: number, y: number } | null; + * keepfocus: boolean; + * chain: string[]; + * details: { + * replaceState: boolean; + * state: any; + * } | null; + * accepted: () => void; + * blocked: () => void; + * }} opts + */ + async _navigate({ url, scroll, keepfocus, chain, details, accepted, blocked }) { + const from = this.current.url; + let should_block = false; + + const intent = { + from, + to: url, + cancel: () => (should_block = true) + }; + + this.callbacks.before_navigate.forEach((fn) => fn(intent)); + + if (should_block) { + blocked(); + return; + } + + const info = this.parse(url); + if (!info) { + location.href = url.href; + return new Promise(() => { + // never resolves + }); + } + + update_scroll_positions(this.current_history_index); + + accepted(); + + this.navigating++; + + const pathname = normalize_path(url.pathname, this.trailing_slash); + + info.url = new URL(url.origin + pathname + url.search + url.hash); + + const token = (this.navigating_token = {}); + + await this.handle_navigation(info, chain, false, { + scroll, + keepfocus + }); + + this.navigating--; + + // navigation was aborted + if (this.navigating_token !== token) return; + if (!this.navigating) { + const navigation = { from, to: url }; + this.callbacks.after_navigate.forEach((fn) => fn(navigation)); + } + + if (details) { + const change = details.replaceState ? 0 : 1; + details.state['sveltekit:index'] = this.current_history_index += change; + history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', info.url); + } + } } diff --git a/packages/kit/src/runtime/client/router.js b/packages/kit/src/runtime/client/router.js deleted file mode 100644 index 4c581d90bcc5..000000000000 --- a/packages/kit/src/runtime/client/router.js +++ /dev/null @@ -1,453 +0,0 @@ -import { onMount } from 'svelte'; -import { normalize_path } from '../../utils/url'; -import { get_base_uri } from './utils'; - -// We track the scroll position associated with each history entry in sessionStorage, -// rather than on history.state itself, because when navigation is driven by -// popstate it's too late to update the scroll position associated with the -// state we're navigating from -const SCROLL_KEY = 'sveltekit:scroll'; - -/** @typedef {{ x: number, y: number }} ScrollPosition */ -/** @type {Record} */ -let scroll_positions = {}; -try { - scroll_positions = JSON.parse(sessionStorage[SCROLL_KEY]); -} catch { - // do nothing -} - -/** @param {number} index */ -function update_scroll_positions(index) { - scroll_positions[index] = scroll_state(); -} - -function scroll_state() { - return { - x: pageXOffset, - y: pageYOffset - }; -} - -/** - * @param {Event} event - * @returns {HTMLAnchorElement | SVGAElement | undefined} - */ -function find_anchor(event) { - const node = event - .composedPath() - .find((e) => e instanceof Node && e.nodeName.toUpperCase() === 'A'); // SVG elements have a lowercase name - return /** @type {HTMLAnchorElement | SVGAElement | undefined} */ (node); -} - -/** - * @param {HTMLAnchorElement | SVGAElement} node - * @returns {URL} - */ -function get_href(node) { - return node instanceof SVGAElement - ? new URL(node.href.baseVal, document.baseURI) - : new URL(node.href); -} - -export class Router { - /** - * @param {{ - * base: string; - * routes: import('types').CSRRoute[]; - * trailing_slash: import('types').TrailingSlash; - * renderer: import('./renderer').Renderer - * }} opts - */ - constructor({ base, routes, trailing_slash, renderer }) { - this.base = base; - this.routes = routes; - this.trailing_slash = trailing_slash; - /** Keeps tracks of multiple navigations caused by redirects during rendering */ - this.navigating = 0; - - /** @type {import('./renderer').Renderer} */ - this.renderer = renderer; - renderer.router = this; - - this.enabled = true; - this.initialized = false; - - // keeping track of the history index in order to prevent popstate navigation events if needed - this.current_history_index = history.state?.['sveltekit:index'] ?? 0; - - if (this.current_history_index === 0) { - // create initial history entry, so we can return here - history.replaceState({ ...history.state, 'sveltekit:index': 0 }, '', location.href); - } - - // if we reload the page, or Cmd-Shift-T back to it, - // recover scroll position - const scroll = scroll_positions[this.current_history_index]; - if (scroll) scrollTo(scroll.x, scroll.y); - - this.hash_navigating = false; - - this.callbacks = { - /** @type {Array<({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void>} */ - before_navigate: [], - - /** @type {Array<({ from, to }: { from: URL | null, to: URL }) => void>} */ - after_navigate: [] - }; - } - - init_listeners() { - 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', (e) => { - let should_block = false; - - const intent = { - from: this.renderer.current.url, - to: null, - cancel: () => (should_block = true) - }; - - this.callbacks.before_navigate.forEach((fn) => fn(intent)); - - if (should_block) { - e.preventDefault(); - e.returnValue = ''; - } else { - history.scrollRestoration = 'auto'; - } - }); - - addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - update_scroll_positions(this.current_history_index); - - try { - sessionStorage[SCROLL_KEY] = JSON.stringify(scroll_positions); - } catch { - // do nothing - } - } - }); - - /** @param {Event} event */ - const trigger_prefetch = (event) => { - const a = find_anchor(event); - if (a && a.href && a.hasAttribute('sveltekit:prefetch')) { - this.prefetch(get_href(a)); - } - }; - - /** @type {NodeJS.Timeout} */ - let mousemove_timeout; - - /** @param {MouseEvent|TouchEvent} event */ - const handle_mousemove = (event) => { - clearTimeout(mousemove_timeout); - mousemove_timeout = setTimeout(() => { - // event.composedPath(), which is used in find_anchor, will be empty if the event is read in a timeout - // add a layer of indirection to address that - event.target?.dispatchEvent( - new CustomEvent('sveltekit:trigger_prefetch', { bubbles: true }) - ); - }, 20); - }; - - addEventListener('touchstart', trigger_prefetch); - addEventListener('mousemove', handle_mousemove); - addEventListener('sveltekit:trigger_prefetch', trigger_prefetch); - - /** @param {MouseEvent} event */ - addEventListener('click', (event) => { - if (!this.enabled) return; - - // Adapted from https://github.com/visionmedia/page.js - // MIT license https://github.com/visionmedia/page.js#license - if (event.button || event.which !== 1) return; - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - if (event.defaultPrevented) return; - - const a = find_anchor(event); - if (!a) return; - - if (!a.href) return; - - const is_svg_a_element = a instanceof SVGAElement; - const url = get_href(a); - const url_string = url.toString(); - if (url_string === location.href) { - if (!location.hash) event.preventDefault(); - return; - } - - // Ignore if url does not have origin (e.g. `mailto:`, `tel:`.) - // MEMO: Without this condition, firefox will open mailer twice. - // See: https://github.com/sveltejs/kit/issues/4045 - if (!is_svg_a_element && url.origin === 'null') return; - - // Ignore if tag has - // 1. 'download' attribute - // 2. 'rel' attribute includes external - const rel = (a.getAttribute('rel') || '').split(/\s+/); - - if (a.hasAttribute('download') || (rel && rel.includes('external'))) { - return; - } - - // Ignore if has a target - if (is_svg_a_element ? a.target.baseVal : a.target) return; - - // Check if new url only differs by hash and use the browser default behavior in that case - // This will ensure the `hashchange` event is fired - // Removing the hash does a full page navigation in the browser, so make sure a hash is present - const [base, hash] = url.href.split('#'); - if (hash !== undefined && base === location.href.split('#')[0]) { - // set this flag to distinguish between navigations triggered by - // clicking a hash link and those triggered by popstate - this.hash_navigating = true; - - update_scroll_positions(this.current_history_index); - this.renderer.update_page_store(new URL(url.href)); - - return; - } - - this._navigate({ - url, - scroll: a.hasAttribute('sveltekit:noscroll') ? scroll_state() : null, - keepfocus: false, - chain: [], - details: { - state: {}, - replaceState: false - }, - accepted: () => event.preventDefault(), - blocked: () => event.preventDefault() - }); - }); - - addEventListener('popstate', (event) => { - if (event.state && this.enabled) { - // if a popstate-driven navigation is cancelled, we need to counteract it - // with history.go, which means we end up back here, hence this check - if (event.state['sveltekit:index'] === this.current_history_index) return; - - this._navigate({ - url: new URL(location.href), - scroll: scroll_positions[event.state['sveltekit:index']], - keepfocus: false, - chain: [], - details: null, - accepted: () => { - this.current_history_index = event.state['sveltekit:index']; - }, - blocked: () => { - const delta = this.current_history_index - event.state['sveltekit:index']; - history.go(delta); - } - }); - } - }); - - addEventListener('hashchange', () => { - // if the hashchange happened as a result of clicking on a link, - // we need to update history, otherwise we have to leave it alone - if (this.hash_navigating) { - this.hash_navigating = false; - history.replaceState( - { ...history.state, 'sveltekit:index': ++this.current_history_index }, - '', - location.href - ); - } - }); - - this.initialized = true; - } - - /** - * Returns true if `url` has the same origin and basepath as the app - * @param {URL} url - */ - owns(url) { - return url.origin === location.origin && url.pathname.startsWith(this.base); - } - - /** - * @param {URL} url - * @returns {import('./types').NavigationInfo | undefined} - */ - parse(url) { - if (this.owns(url)) { - const path = decodeURI(url.pathname.slice(this.base.length) || '/'); - - return { - id: url.pathname + url.search, - routes: this.routes.filter(([pattern]) => pattern.test(path)), - url, - path, - initial: !this.initialized - }; - } - } - - /** - * @typedef {Parameters} GotoParams - * - * @param {GotoParams[0]} href - * @param {GotoParams[1]} opts - * @param {string[]} chain - */ - async goto( - href, - { noscroll = false, replaceState = false, keepfocus = false, state = {} } = {}, - chain - ) { - const url = new URL(href, get_base_uri(document)); - - if (this.enabled) { - return this._navigate({ - url, - scroll: noscroll ? scroll_state() : null, - keepfocus, - chain, - details: { - state, - replaceState - }, - accepted: () => {}, - blocked: () => {} - }); - } - - location.href = url.href; - return new Promise(() => { - /* never resolves */ - }); - } - - enable() { - this.enabled = true; - } - - disable() { - this.enabled = false; - } - - /** - * @param {URL} url - * @returns {Promise} - */ - async prefetch(url) { - const info = this.parse(url); - - if (!info) { - throw new Error('Attempted to prefetch a URL that does not belong to this app'); - } - - return this.renderer.load(info); - } - - /** @param {({ from, to }: { from: URL | null, to: URL }) => void} fn */ - after_navigate(fn) { - onMount(() => { - this.callbacks.after_navigate.push(fn); - - return () => { - const i = this.callbacks.after_navigate.indexOf(fn); - this.callbacks.after_navigate.splice(i, 1); - }; - }); - } - - /** - * @param {({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void} fn - */ - before_navigate(fn) { - onMount(() => { - this.callbacks.before_navigate.push(fn); - - return () => { - const i = this.callbacks.before_navigate.indexOf(fn); - this.callbacks.before_navigate.splice(i, 1); - }; - }); - } - - /** - * @param {{ - * url: URL; - * scroll: { x: number, y: number } | null; - * keepfocus: boolean; - * chain: string[]; - * details: { - * replaceState: boolean; - * state: any; - * } | null; - * accepted: () => void; - * blocked: () => void; - * }} opts - */ - async _navigate({ url, scroll, keepfocus, chain, details, accepted, blocked }) { - const from = this.renderer.current.url; - let should_block = false; - - const intent = { - from, - to: url, - cancel: () => (should_block = true) - }; - - this.callbacks.before_navigate.forEach((fn) => fn(intent)); - - if (should_block) { - blocked(); - return; - } - - const info = this.parse(url); - if (!info) { - location.href = url.href; - return new Promise(() => { - // never resolves - }); - } - - update_scroll_positions(this.current_history_index); - - accepted(); - - this.navigating++; - - const pathname = normalize_path(url.pathname, this.trailing_slash); - - info.url = new URL(url.origin + pathname + url.search + url.hash); - - const token = (this.navigating_token = {}); - - await this.renderer.handle_navigation(info, chain, false, { - scroll, - keepfocus - }); - - this.navigating--; - - // navigation was aborted - if (this.navigating_token !== token) return; - if (!this.navigating) { - const navigation = { from, to: url }; - this.callbacks.after_navigate.forEach((fn) => fn(navigation)); - } - - if (details) { - const change = details.replaceState ? 0 : 1; - details.state['sveltekit:index'] = this.current_history_index += change; - history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', info.url); - } - } -} diff --git a/packages/kit/src/runtime/client/singletons.js b/packages/kit/src/runtime/client/singletons.js index aa2a6ad40392..7d5cf767aeb7 100644 --- a/packages/kit/src/runtime/client/singletons.js +++ b/packages/kit/src/runtime/client/singletons.js @@ -1,19 +1,11 @@ -/** - * The router is nullable, but not typed that way for ease-of-use - * @type {import('./router').Router} - */ -export let router; - -/** @type {import('./renderer').Renderer} */ -export let renderer; +/** @type {import('./client').Client} */ +export let client; /** * @param {{ - * router: import('./router').Router?; - * renderer: import('./renderer').Renderer; + * client: import('./client').Client; * }} opts */ export function init(opts) { - router = /** @type {import('../client/router').Router} */ (opts.router); - renderer = opts.renderer; + client = opts.client; } diff --git a/packages/kit/src/runtime/client/start.js b/packages/kit/src/runtime/client/start.js index cbebf29fcfdd..bb0aab492d1e 100644 --- a/packages/kit/src/runtime/client/start.js +++ b/packages/kit/src/runtime/client/start.js @@ -1,8 +1,7 @@ import Root from '__GENERATED__/root.svelte'; // @ts-expect-error - doesn't exist yet. generated by Rollup import { routes, fallback } from '__GENERATED__/manifest.js'; -import { Router } from './router.js'; -import { Renderer } from './renderer.js'; +import { Client } from './client.js'; import { init } from './singletons.js'; import { set_paths } from '../paths.js'; @@ -26,29 +25,23 @@ import { set_paths } from '../paths.js'; * }} opts */ export async function start({ paths, target, session, route, spa, trailing_slash, hydrate }) { - const renderer = new Renderer({ + const client = new Client({ Root, fallback, target, - session + session, + base: paths.base, + routes, + trailing_slash }); - const router = route - ? new Router({ - base: paths.base, - routes, - trailing_slash, - renderer - }) - : null; - - init({ router, renderer }); + init({ client }); set_paths(paths); - if (hydrate) await renderer.start(hydrate); - if (router) { - if (spa) router.goto(location.href, { replaceState: true }, []); - router.init_listeners(); + if (hydrate) await client.start(hydrate); + if (route) { + if (spa) client.goto(location.href, { replaceState: true }, []); + client.init_listeners(); } dispatchEvent(new CustomEvent('sveltekit:start')); From 00afff7b60ac48739cb76e9d130e21d53a1910cd Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 00:06:39 -0500 Subject: [PATCH 02/34] shuffle methods around a bit --- packages/kit/src/runtime/client/client.js | 576 +++++++++++----------- 1 file changed, 288 insertions(+), 288 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 3c9452ab2a29..4486713ac3c2 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -223,8 +223,8 @@ export class Client { if (!ready) return; this.session_id += 1; - const info = this.parse(new URL(location.href)); - if (info) this.update(info, [], true); + const info = this._parse(new URL(location.href)); + if (info) this._update(info, [], true); }); ready = true; @@ -263,6 +263,32 @@ export class Client { }; } + /** @param {({ from, to }: { from: URL | null, to: URL }) => void} fn */ + after_navigate(fn) { + onMount(() => { + this.callbacks.after_navigate.push(fn); + + return () => { + const i = this.callbacks.after_navigate.indexOf(fn); + this.callbacks.after_navigate.splice(i, 1); + }; + }); + } + + /** + * @param {({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void} fn + */ + before_navigate(fn) { + onMount(() => { + this.callbacks.before_navigate.push(fn); + + return () => { + const i = this.callbacks.before_navigate.indexOf(fn); + this.callbacks.before_navigate.splice(i, 1); + }; + }); + } + disable_scroll_handling() { if (import.meta.env.DEV && this.started && !this.updating) { throw new Error('Can only disable scroll handling during navigation'); @@ -273,6 +299,244 @@ export class Client { } } + /** + * @typedef {Parameters} GotoParams + * + * @param {GotoParams[0]} href + * @param {GotoParams[1]} opts + * @param {string[]} chain + */ + async goto( + href, + { noscroll = false, replaceState = false, keepfocus = false, state = {} } = {}, + chain + ) { + const url = new URL(href, get_base_uri(document)); + + if (this.enabled) { + return this._navigate({ + url, + scroll: noscroll ? scroll_state() : null, + keepfocus, + chain, + details: { + state, + replaceState + }, + accepted: () => {}, + blocked: () => {} + }); + } + + location.href = url.href; + return new Promise(() => { + /* never resolves */ + }); + } + + init_listeners() { + 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', (e) => { + let should_block = false; + + const intent = { + from: this.current.url, + to: null, + cancel: () => (should_block = true) + }; + + this.callbacks.before_navigate.forEach((fn) => fn(intent)); + + if (should_block) { + e.preventDefault(); + e.returnValue = ''; + } else { + history.scrollRestoration = 'auto'; + } + }); + + addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + update_scroll_positions(this.current_history_index); + + try { + sessionStorage[SCROLL_KEY] = JSON.stringify(scroll_positions); + } catch { + // do nothing + } + } + }); + + /** @param {Event} event */ + const trigger_prefetch = (event) => { + const a = find_anchor(event); + if (a && a.href && a.hasAttribute('sveltekit:prefetch')) { + this.prefetch(get_href(a)); + } + }; + + /** @type {NodeJS.Timeout} */ + let mousemove_timeout; + + /** @param {MouseEvent|TouchEvent} event */ + const handle_mousemove = (event) => { + clearTimeout(mousemove_timeout); + mousemove_timeout = setTimeout(() => { + // event.composedPath(), which is used in find_anchor, will be empty if the event is read in a timeout + // add a layer of indirection to address that + event.target?.dispatchEvent( + new CustomEvent('sveltekit:trigger_prefetch', { bubbles: true }) + ); + }, 20); + }; + + addEventListener('touchstart', trigger_prefetch); + addEventListener('mousemove', handle_mousemove); + addEventListener('sveltekit:trigger_prefetch', trigger_prefetch); + + /** @param {MouseEvent} event */ + addEventListener('click', (event) => { + if (!this.enabled) return; + + // Adapted from https://github.com/visionmedia/page.js + // MIT license https://github.com/visionmedia/page.js#license + if (event.button || event.which !== 1) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if (event.defaultPrevented) return; + + const a = find_anchor(event); + if (!a) return; + + if (!a.href) return; + + const is_svg_a_element = a instanceof SVGAElement; + const url = get_href(a); + const url_string = url.toString(); + if (url_string === location.href) { + if (!location.hash) event.preventDefault(); + return; + } + + // Ignore if url does not have origin (e.g. `mailto:`, `tel:`.) + // MEMO: Without this condition, firefox will open mailer twice. + // See: https://github.com/sveltejs/kit/issues/4045 + if (!is_svg_a_element && url.origin === 'null') return; + + // Ignore if tag has + // 1. 'download' attribute + // 2. 'rel' attribute includes external + const rel = (a.getAttribute('rel') || '').split(/\s+/); + + if (a.hasAttribute('download') || (rel && rel.includes('external'))) { + return; + } + + // Ignore if has a target + if (is_svg_a_element ? a.target.baseVal : a.target) return; + + // Check if new url only differs by hash and use the browser default behavior in that case + // This will ensure the `hashchange` event is fired + // Removing the hash does a full page navigation in the browser, so make sure a hash is present + const [base, hash] = url.href.split('#'); + if (hash !== undefined && base === location.href.split('#')[0]) { + // set this flag to distinguish between navigations triggered by + // clicking a hash link and those triggered by popstate + this.hash_navigating = true; + + update_scroll_positions(this.current_history_index); + this._update_page_store(new URL(url.href)); + + return; + } + + this._navigate({ + url, + scroll: a.hasAttribute('sveltekit:noscroll') ? scroll_state() : null, + keepfocus: false, + chain: [], + details: { + state: {}, + replaceState: false + }, + accepted: () => event.preventDefault(), + blocked: () => event.preventDefault() + }); + }); + + addEventListener('popstate', (event) => { + if (event.state && this.enabled) { + // if a popstate-driven navigation is cancelled, we need to counteract it + // with history.go, which means we end up back here, hence this check + if (event.state['sveltekit:index'] === this.current_history_index) return; + + this._navigate({ + url: new URL(location.href), + scroll: scroll_positions[event.state['sveltekit:index']], + keepfocus: false, + chain: [], + details: null, + accepted: () => { + this.current_history_index = event.state['sveltekit:index']; + }, + blocked: () => { + const delta = this.current_history_index - event.state['sveltekit:index']; + history.go(delta); + } + }); + } + }); + + addEventListener('hashchange', () => { + // if the hashchange happened as a result of clicking on a link, + // we need to update history, otherwise we have to leave it alone + if (this.hash_navigating) { + this.hash_navigating = false; + history.replaceState( + { ...history.state, 'sveltekit:index': ++this.current_history_index }, + '', + location.href + ); + } + }); + + this.initialized = true; + } + + /** @param {string} href */ + invalidate(href) { + this.invalid.add(href); + + if (!this.invalidating) { + this.invalidating = Promise.resolve().then(async () => { + const info = this._parse(new URL(location.href)); + if (info) await this._update(info, [], true); + + this.invalidating = null; + }); + } + + return this.invalidating; + } + + /** + * @param {URL} url + * @returns {Promise} + */ + async prefetch(url) { + const info = this._parse(url); + + if (!info) { + throw new Error('Attempted to prefetch a URL that does not belong to this app'); + } + + return this.load(info); + } + /** * @param {{ * status: number; @@ -378,7 +642,7 @@ export class Client { * @param {boolean} no_cache * @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean}} [opts] */ - async handle_navigation(info, chain, no_cache, opts) { + async _handle_navigation(info, chain, no_cache, opts) { if (this.started) { this.stores.navigating.set({ from: this.current.url, @@ -386,7 +650,7 @@ export class Client { }); } - await this.update(info, chain, no_cache, opts); + await this._update(info, chain, no_cache, opts); } /** @@ -395,7 +659,7 @@ export class Client { * @param {boolean} no_cache * @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean}} [opts] */ - async update(info, chain, no_cache, opts) { + async _update(info, chain, no_cache, opts) { const token = (this.token = {}); let navigation_result = await this._get_navigation_result(info, no_cache); @@ -504,41 +768,25 @@ export class Client { const leaf_node = navigation_result.state.branch[navigation_result.state.branch.length - 1]; if (leaf_node && leaf_node.module.router === false) { - this.disable(); + this._disable(); } else { - this.enable(); + this._enable(); } } - /** - * @param {import('./types').NavigationInfo} info - * @returns {Promise} - */ - load(info) { - this.loading.promise = this._get_navigation_result(info, false); - this.loading.id = info.id; - - return this.loading.promise; - } - - /** @param {string} href */ - invalidate(href) { - this.invalid.add(href); - - if (!this.invalidating) { - this.invalidating = Promise.resolve().then(async () => { - const info = this.parse(new URL(location.href)); - if (info) await this.update(info, [], true); - - this.invalidating = null; - }); - } - - return this.invalidating; + /** + * @param {import('./types').NavigationInfo} info + * @returns {Promise} + */ + load(info) { + this.loading.promise = this._get_navigation_result(info, false); + this.loading.id = info.id; + + return this.loading.promise; } /** @param {URL} url */ - update_page_store(url) { + _update_page_store(url) { this.stores.page.set({ ...this.page, url }); this.stores.page.notify(); } @@ -1048,184 +1296,11 @@ export class Client { }); } - init_listeners() { - 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', (e) => { - let should_block = false; - - const intent = { - from: this.current.url, - to: null, - cancel: () => (should_block = true) - }; - - this.callbacks.before_navigate.forEach((fn) => fn(intent)); - - if (should_block) { - e.preventDefault(); - e.returnValue = ''; - } else { - history.scrollRestoration = 'auto'; - } - }); - - addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - update_scroll_positions(this.current_history_index); - - try { - sessionStorage[SCROLL_KEY] = JSON.stringify(scroll_positions); - } catch { - // do nothing - } - } - }); - - /** @param {Event} event */ - const trigger_prefetch = (event) => { - const a = find_anchor(event); - if (a && a.href && a.hasAttribute('sveltekit:prefetch')) { - this.prefetch(get_href(a)); - } - }; - - /** @type {NodeJS.Timeout} */ - let mousemove_timeout; - - /** @param {MouseEvent|TouchEvent} event */ - const handle_mousemove = (event) => { - clearTimeout(mousemove_timeout); - mousemove_timeout = setTimeout(() => { - // event.composedPath(), which is used in find_anchor, will be empty if the event is read in a timeout - // add a layer of indirection to address that - event.target?.dispatchEvent( - new CustomEvent('sveltekit:trigger_prefetch', { bubbles: true }) - ); - }, 20); - }; - - addEventListener('touchstart', trigger_prefetch); - addEventListener('mousemove', handle_mousemove); - addEventListener('sveltekit:trigger_prefetch', trigger_prefetch); - - /** @param {MouseEvent} event */ - addEventListener('click', (event) => { - if (!this.enabled) return; - - // Adapted from https://github.com/visionmedia/page.js - // MIT license https://github.com/visionmedia/page.js#license - if (event.button || event.which !== 1) return; - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - if (event.defaultPrevented) return; - - const a = find_anchor(event); - if (!a) return; - - if (!a.href) return; - - const is_svg_a_element = a instanceof SVGAElement; - const url = get_href(a); - const url_string = url.toString(); - if (url_string === location.href) { - if (!location.hash) event.preventDefault(); - return; - } - - // Ignore if url does not have origin (e.g. `mailto:`, `tel:`.) - // MEMO: Without this condition, firefox will open mailer twice. - // See: https://github.com/sveltejs/kit/issues/4045 - if (!is_svg_a_element && url.origin === 'null') return; - - // Ignore if tag has - // 1. 'download' attribute - // 2. 'rel' attribute includes external - const rel = (a.getAttribute('rel') || '').split(/\s+/); - - if (a.hasAttribute('download') || (rel && rel.includes('external'))) { - return; - } - - // Ignore if has a target - if (is_svg_a_element ? a.target.baseVal : a.target) return; - - // Check if new url only differs by hash and use the browser default behavior in that case - // This will ensure the `hashchange` event is fired - // Removing the hash does a full page navigation in the browser, so make sure a hash is present - const [base, hash] = url.href.split('#'); - if (hash !== undefined && base === location.href.split('#')[0]) { - // set this flag to distinguish between navigations triggered by - // clicking a hash link and those triggered by popstate - this.hash_navigating = true; - - update_scroll_positions(this.current_history_index); - this.update_page_store(new URL(url.href)); - - return; - } - - this._navigate({ - url, - scroll: a.hasAttribute('sveltekit:noscroll') ? scroll_state() : null, - keepfocus: false, - chain: [], - details: { - state: {}, - replaceState: false - }, - accepted: () => event.preventDefault(), - blocked: () => event.preventDefault() - }); - }); - - addEventListener('popstate', (event) => { - if (event.state && this.enabled) { - // if a popstate-driven navigation is cancelled, we need to counteract it - // with history.go, which means we end up back here, hence this check - if (event.state['sveltekit:index'] === this.current_history_index) return; - - this._navigate({ - url: new URL(location.href), - scroll: scroll_positions[event.state['sveltekit:index']], - keepfocus: false, - chain: [], - details: null, - accepted: () => { - this.current_history_index = event.state['sveltekit:index']; - }, - blocked: () => { - const delta = this.current_history_index - event.state['sveltekit:index']; - history.go(delta); - } - }); - } - }); - - addEventListener('hashchange', () => { - // if the hashchange happened as a result of clicking on a link, - // we need to update history, otherwise we have to leave it alone - if (this.hash_navigating) { - this.hash_navigating = false; - history.replaceState( - { ...history.state, 'sveltekit:index': ++this.current_history_index }, - '', - location.href - ); - } - }); - - this.initialized = true; - } - /** * Returns true if `url` has the same origin and basepath as the app * @param {URL} url */ - owns(url) { + _owns(url) { return url.origin === location.origin && url.pathname.startsWith(this.base); } @@ -1233,8 +1308,8 @@ export class Client { * @param {URL} url * @returns {import('./types').NavigationInfo | undefined} */ - parse(url) { - if (this.owns(url)) { + _parse(url) { + if (this._owns(url)) { const path = decodeURI(url.pathname.slice(this.base.length) || '/'); return { @@ -1247,89 +1322,14 @@ export class Client { } } - /** - * @typedef {Parameters} GotoParams - * - * @param {GotoParams[0]} href - * @param {GotoParams[1]} opts - * @param {string[]} chain - */ - async goto( - href, - { noscroll = false, replaceState = false, keepfocus = false, state = {} } = {}, - chain - ) { - const url = new URL(href, get_base_uri(document)); - - if (this.enabled) { - return this._navigate({ - url, - scroll: noscroll ? scroll_state() : null, - keepfocus, - chain, - details: { - state, - replaceState - }, - accepted: () => {}, - blocked: () => {} - }); - } - - location.href = url.href; - return new Promise(() => { - /* never resolves */ - }); - } - - enable() { + _enable() { this.enabled = true; } - disable() { + _disable() { this.enabled = false; } - /** - * @param {URL} url - * @returns {Promise} - */ - async prefetch(url) { - const info = this.parse(url); - - if (!info) { - throw new Error('Attempted to prefetch a URL that does not belong to this app'); - } - - return this.load(info); - } - - /** @param {({ from, to }: { from: URL | null, to: URL }) => void} fn */ - after_navigate(fn) { - onMount(() => { - this.callbacks.after_navigate.push(fn); - - return () => { - const i = this.callbacks.after_navigate.indexOf(fn); - this.callbacks.after_navigate.splice(i, 1); - }; - }); - } - - /** - * @param {({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void} fn - */ - before_navigate(fn) { - onMount(() => { - this.callbacks.before_navigate.push(fn); - - return () => { - const i = this.callbacks.before_navigate.indexOf(fn); - this.callbacks.before_navigate.splice(i, 1); - }; - }); - } - /** * @param {{ * url: URL; @@ -1361,7 +1361,7 @@ export class Client { return; } - const info = this.parse(url); + const info = this._parse(url); if (!info) { location.href = url.href; return new Promise(() => { @@ -1381,7 +1381,7 @@ export class Client { const token = (this.navigating_token = {}); - await this.handle_navigation(info, chain, false, { + await this._handle_navigation(info, chain, false, { scroll, keepfocus }); From 3dd4c992f755cf3182a1a6e95fd93fe27d1602a6 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 10:37:19 -0500 Subject: [PATCH 03/34] shuffle things around --- packages/kit/src/runtime/app/navigation.js | 75 +- packages/kit/src/runtime/client/client.js | 1099 ++++++++--------- packages/kit/src/runtime/client/singletons.js | 4 +- packages/kit/src/runtime/client/start.js | 6 +- packages/kit/src/runtime/client/types.d.ts | 31 + 5 files changed, 582 insertions(+), 633 deletions(-) diff --git a/packages/kit/src/runtime/app/navigation.js b/packages/kit/src/runtime/app/navigation.js index 944c1b77c9f2..d97e6dea6629 100644 --- a/packages/kit/src/runtime/app/navigation.js +++ b/packages/kit/src/runtime/app/navigation.js @@ -1,5 +1,4 @@ import { client } from '../client/singletons.js'; -import { get_base_uri } from '../client/utils.js'; /** * @param {string} name @@ -10,68 +9,14 @@ function guard(name) { }; } -export const disableScrollHandling = import.meta.env.SSR - ? guard('disableScrollHandling') - : disableScrollHandling_; -export const goto = import.meta.env.SSR ? guard('goto') : goto_; -export const invalidate = import.meta.env.SSR ? guard('invalidate') : invalidate_; -export const prefetch = import.meta.env.SSR ? guard('prefetch') : prefetch_; -export const prefetchRoutes = import.meta.env.SSR ? guard('prefetchRoutes') : prefetchRoutes_; -export const beforeNavigate = import.meta.env.SSR ? () => {} : beforeNavigate_; -export const afterNavigate = import.meta.env.SSR ? () => {} : afterNavigate_; - -/** - * @type {import('$app/navigation').goto} - */ -async function disableScrollHandling_() { - client.disable_scroll_handling(); -} - -/** - * @type {import('$app/navigation').goto} - */ -async function goto_(href, opts) { - return client.goto(href, opts, []); -} - -/** - * @type {import('$app/navigation').invalidate} - */ -async function invalidate_(resource) { - const { href } = new URL(resource, location.href); - return client.invalidate(href); -} +const ssr = import.meta.env.SSR; -/** - * @type {import('$app/navigation').prefetch} - */ -async function prefetch_(href) { - await client.prefetch(new URL(href, get_base_uri(document))); -} - -/** - * @type {import('$app/navigation').prefetchRoutes} - */ -async function prefetchRoutes_(pathnames) { - const matching = pathnames - ? client.routes.filter((route) => pathnames.some((pathname) => route[0].test(pathname))) - : client.routes; - - const promises = matching.map((r) => Promise.all(r[1].map((load) => load()))); - - await Promise.all(promises); -} - -/** - * @type {import('$app/navigation').beforeNavigate} - */ -function beforeNavigate_(fn) { - client.before_navigate(fn); -} - -/** - * @type {import('$app/navigation').afterNavigate} - */ -function afterNavigate_(fn) { - client.after_navigate(fn); -} +export const disableScrollHandling = ssr + ? guard('disableScrollHandling') + : client.disable_scroll_handling; +export const goto = ssr ? guard('goto') : client.goto; +export const invalidate = ssr ? guard('invalidate') : client.invalidate; +export const prefetch = ssr ? guard('prefetch') : client.prefetch; +export const prefetchRoutes = ssr ? guard('prefetchRoutes') : client.prefetch_routes; +export const beforeNavigate = ssr ? () => {} : client.before_navigate; +export const afterNavigate = ssr ? () => {} : client.after_navigate; diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 4486713ac3c2..46cc55a2a286 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1,10 +1,9 @@ -import { tick } from 'svelte'; +import { onMount, tick } from 'svelte'; import { writable } from 'svelte/store'; import { coalesce_to_error } from '../../utils/error.js'; import { hash } from '../hash.js'; import { normalize } from '../load.js'; import { base } from '../paths.js'; -import { onMount } from 'svelte'; import { normalize_path } from '../../utils/url'; import { get_base_uri } from './utils'; @@ -39,10 +38,7 @@ function scroll_state() { }; } -/** - * @param {Event} event - * @returns {HTMLAnchorElement | SVGAElement | undefined} - */ +/** @param {Event} event */ function find_anchor(event) { const node = event .composedPath() @@ -50,10 +46,7 @@ function find_anchor(event) { return /** @type {HTMLAnchorElement | SVGAElement | undefined} */ (node); } -/** - * @param {HTMLAnchorElement | SVGAElement} node - * @returns {URL} - */ +/** @param {HTMLAnchorElement | SVGAElement} node */ function get_href(node) { return node instanceof SVGAElement ? new URL(node.href.baseVal, document.baseURI) @@ -162,159 +155,116 @@ function initial_fetch(resource, opts) { return fetch(resource, opts); } -export class Client { - /** - * @param {{ - * Root: CSRComponent; - * fallback: [CSRComponent, CSRComponent]; - * target: Node; - * session: any; - * base: string; - * routes: import('types').CSRRoute[]; - * trailing_slash: import('types').TrailingSlash; - * }} opts - */ - constructor({ Root, fallback, target, session, base, routes, trailing_slash }) { - this.Root = Root; - this.fallback = fallback; - this.target = target; - - this.started = false; - - this.session_id = 1; - this.invalid = new Set(); - this.invalidating = null; - this.autoscroll = true; - this.updating = false; - - /** @type {import('./types').NavigationState} */ - this.current = { - // @ts-ignore - we need the initial value to be null - url: null, - session_id: 0, - branch: [] - }; - - /** @type {Map} */ - this.cache = new Map(); - - /** @type {{id: string | null, promise: Promise | null}} */ - this.loading = { - id: null, - promise: null - }; - - this.stores = { - url: notifiable_store({}), - page: notifiable_store({}), - navigating: writable(/** @type {import('types').Navigation | null} */ (null)), - session: writable(session), - updated: create_updated_store() - }; - - this.$session = null; - - this.root = null; - - let ready = false; - this.stores.session.subscribe(async (value) => { - this.$session = value; +/** + * @param {{ + * Root: CSRComponent; + * fallback: [CSRComponent, CSRComponent]; + * target: Node; + * session: App.Session; + * base: string; + * routes: import('types').CSRRoute[]; + * trailing_slash: import('types').TrailingSlash; + * }} opts + * @returns {import('./types').Client} + */ +export function create_client({ Root, fallback, target, session, base, routes, trailing_slash }) { + /** @type {Map} */ + const cache = new Map(); + + /** @type {Set} */ + const invalid = new Set(); + + const stores = { + url: notifiable_store({}), + page: notifiable_store({}), + navigating: writable(/** @type {import('types').Navigation | null} */ (null)), + session: writable(session), + updated: create_updated_store() + }; - if (!ready) return; - this.session_id += 1; + /** @type {{id: string | null, promise: Promise | null}} */ + const loading = { + id: null, + promise: null + }; - const info = this._parse(new URL(location.href)); - if (info) this._update(info, [], true); - }); - ready = true; + /** @type {import('./types').NavigationState} */ + let current = { + // @ts-ignore - we need the initial value to be null + url: null, + session_id: 0, + branch: [] + }; - // -------- + let started = false; + let autoscroll = true; + let updating = false; + let session_id = 1; - this.base = base; - this.routes = routes; - this.trailing_slash = trailing_slash; - /** Keeps tracks of multiple navigations caused by redirects during rendering */ - this.navigating = 0; + /** @type {Promise | null} */ + let invalidating = null; - this.enabled = true; - this.initialized = false; + /** @type {import('svelte').SvelteComponent} */ + let root; - // keeping track of the history index in order to prevent popstate navigation events if needed - this.current_history_index = history.state?.['sveltekit:index'] ?? 0; + /** @type {App.Session} */ + let $session; - if (this.current_history_index === 0) { - // create initial history entry, so we can return here - history.replaceState({ ...history.state, 'sveltekit:index': 0 }, '', location.href); - } + let ready = false; + stores.session.subscribe(async (value) => { + $session = value; - // if we reload the page, or Cmd-Shift-T back to it, - // recover scroll position - const scroll = scroll_positions[this.current_history_index]; - if (scroll) scrollTo(scroll.x, scroll.y); + if (!ready) return; + session_id += 1; - this.hash_navigating = false; + const info = _parse(new URL(location.href)); + if (info) _update(info, [], true); + }); + ready = true; - this.callbacks = { - /** @type {Array<({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void>} */ - before_navigate: [], + /** Keeps tracks of multiple navigations caused by redirects during rendering */ + let navigating = 0; - /** @type {Array<({ from, to }: { from: URL | null, to: URL }) => void>} */ - after_navigate: [] - }; - } + let enabled = true; + let initialized = false; - /** @param {({ from, to }: { from: URL | null, to: URL }) => void} fn */ - after_navigate(fn) { - onMount(() => { - this.callbacks.after_navigate.push(fn); + // keeping track of the history index in order to prevent popstate navigation events if needed + let current_history_index = history.state?.['sveltekit:index'] ?? 0; - return () => { - const i = this.callbacks.after_navigate.indexOf(fn); - this.callbacks.after_navigate.splice(i, 1); - }; - }); + if (current_history_index === 0) { + // create initial history entry, so we can return here + history.replaceState({ ...history.state, 'sveltekit:index': 0 }, '', location.href); } - /** - * @param {({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void} fn - */ - before_navigate(fn) { - onMount(() => { - this.callbacks.before_navigate.push(fn); + // if we reload the page, or Cmd-Shift-T back to it, + // recover scroll position + const scroll = scroll_positions[current_history_index]; + if (scroll) scrollTo(scroll.x, scroll.y); - return () => { - const i = this.callbacks.before_navigate.indexOf(fn); - this.callbacks.before_navigate.splice(i, 1); - }; - }); - } + let hash_navigating = false; - disable_scroll_handling() { - if (import.meta.env.DEV && this.started && !this.updating) { - throw new Error('Can only disable scroll handling during navigation'); - } + const callbacks = { + /** @type {Array<({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void>} */ + before_navigate: [], - if (this.updating || !this.started) { - this.autoscroll = false; - } - } + /** @type {Array<({ from, to }: { from: URL | null, to: URL }) => void>} */ + after_navigate: [] + }; /** - * @typedef {Parameters} GotoParams - * - * @param {GotoParams[0]} href - * @param {GotoParams[1]} opts + * @param {string} href + * @param {{ noscroll?: boolean; replaceState?: boolean; keepfocus?: boolean; state?: any }} opts * @param {string[]} chain */ - async goto( + async function goto( href, - { noscroll = false, replaceState = false, keepfocus = false, state = {} } = {}, + { noscroll = false, replaceState = false, keepfocus = false, state = {} }, chain ) { const url = new URL(href, get_base_uri(document)); - if (this.enabled) { - return this._navigate({ + if (enabled) { + return _navigate({ url, scroll: noscroll ? scroll_state() : null, keepfocus, @@ -334,306 +284,15 @@ export class Client { }); } - init_listeners() { - 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', (e) => { - let should_block = false; - - const intent = { - from: this.current.url, - to: null, - cancel: () => (should_block = true) - }; - - this.callbacks.before_navigate.forEach((fn) => fn(intent)); - - if (should_block) { - e.preventDefault(); - e.returnValue = ''; - } else { - history.scrollRestoration = 'auto'; - } - }); - - addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - update_scroll_positions(this.current_history_index); - - try { - sessionStorage[SCROLL_KEY] = JSON.stringify(scroll_positions); - } catch { - // do nothing - } - } - }); - - /** @param {Event} event */ - const trigger_prefetch = (event) => { - const a = find_anchor(event); - if (a && a.href && a.hasAttribute('sveltekit:prefetch')) { - this.prefetch(get_href(a)); - } - }; - - /** @type {NodeJS.Timeout} */ - let mousemove_timeout; - - /** @param {MouseEvent|TouchEvent} event */ - const handle_mousemove = (event) => { - clearTimeout(mousemove_timeout); - mousemove_timeout = setTimeout(() => { - // event.composedPath(), which is used in find_anchor, will be empty if the event is read in a timeout - // add a layer of indirection to address that - event.target?.dispatchEvent( - new CustomEvent('sveltekit:trigger_prefetch', { bubbles: true }) - ); - }, 20); - }; - - addEventListener('touchstart', trigger_prefetch); - addEventListener('mousemove', handle_mousemove); - addEventListener('sveltekit:trigger_prefetch', trigger_prefetch); - - /** @param {MouseEvent} event */ - addEventListener('click', (event) => { - if (!this.enabled) return; - - // Adapted from https://github.com/visionmedia/page.js - // MIT license https://github.com/visionmedia/page.js#license - if (event.button || event.which !== 1) return; - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - if (event.defaultPrevented) return; - - const a = find_anchor(event); - if (!a) return; - - if (!a.href) return; - - const is_svg_a_element = a instanceof SVGAElement; - const url = get_href(a); - const url_string = url.toString(); - if (url_string === location.href) { - if (!location.hash) event.preventDefault(); - return; - } - - // Ignore if url does not have origin (e.g. `mailto:`, `tel:`.) - // MEMO: Without this condition, firefox will open mailer twice. - // See: https://github.com/sveltejs/kit/issues/4045 - if (!is_svg_a_element && url.origin === 'null') return; - - // Ignore if tag has - // 1. 'download' attribute - // 2. 'rel' attribute includes external - const rel = (a.getAttribute('rel') || '').split(/\s+/); - - if (a.hasAttribute('download') || (rel && rel.includes('external'))) { - return; - } - - // Ignore if has a target - if (is_svg_a_element ? a.target.baseVal : a.target) return; - - // Check if new url only differs by hash and use the browser default behavior in that case - // This will ensure the `hashchange` event is fired - // Removing the hash does a full page navigation in the browser, so make sure a hash is present - const [base, hash] = url.href.split('#'); - if (hash !== undefined && base === location.href.split('#')[0]) { - // set this flag to distinguish between navigations triggered by - // clicking a hash link and those triggered by popstate - this.hash_navigating = true; - - update_scroll_positions(this.current_history_index); - this._update_page_store(new URL(url.href)); - - return; - } - - this._navigate({ - url, - scroll: a.hasAttribute('sveltekit:noscroll') ? scroll_state() : null, - keepfocus: false, - chain: [], - details: { - state: {}, - replaceState: false - }, - accepted: () => event.preventDefault(), - blocked: () => event.preventDefault() - }); - }); - - addEventListener('popstate', (event) => { - if (event.state && this.enabled) { - // if a popstate-driven navigation is cancelled, we need to counteract it - // with history.go, which means we end up back here, hence this check - if (event.state['sveltekit:index'] === this.current_history_index) return; - - this._navigate({ - url: new URL(location.href), - scroll: scroll_positions[event.state['sveltekit:index']], - keepfocus: false, - chain: [], - details: null, - accepted: () => { - this.current_history_index = event.state['sveltekit:index']; - }, - blocked: () => { - const delta = this.current_history_index - event.state['sveltekit:index']; - history.go(delta); - } - }); - } - }); - - addEventListener('hashchange', () => { - // if the hashchange happened as a result of clicking on a link, - // we need to update history, otherwise we have to leave it alone - if (this.hash_navigating) { - this.hash_navigating = false; - history.replaceState( - { ...history.state, 'sveltekit:index': ++this.current_history_index }, - '', - location.href - ); - } - }); - - this.initialized = true; - } - - /** @param {string} href */ - invalidate(href) { - this.invalid.add(href); - - if (!this.invalidating) { - this.invalidating = Promise.resolve().then(async () => { - const info = this._parse(new URL(location.href)); - if (info) await this._update(info, [], true); - - this.invalidating = null; - }); - } - - return this.invalidating; - } - - /** - * @param {URL} url - * @returns {Promise} - */ - async prefetch(url) { - const info = this._parse(url); + /** @param {URL} url */ + async function prefetch(url) { + const info = _parse(url); if (!info) { throw new Error('Attempted to prefetch a URL that does not belong to this app'); } - return this.load(info); - } - - /** - * @param {{ - * status: number; - * error: Error; - * nodes: Array>; - * params: Record; - * }} selected - */ - async start({ status, error, nodes, params }) { - const url = new URL(location.href); - - /** @type {Array} */ - const branch = []; - - /** @type {Record} */ - let stuff = {}; - - /** @type {import('./types').NavigationResult | undefined} */ - let result; - - let error_args; - - try { - for (let i = 0; i < nodes.length; i += 1) { - const is_leaf = i === nodes.length - 1; - - let props; - - if (is_leaf) { - const serialized = document.querySelector('script[sveltekit\\:data-type="props"]'); - if (serialized) { - props = JSON.parse(/** @type {string} */ (serialized.textContent)); - } - } - - const node = await this._load_node({ - module: await nodes[i], - url, - params, - stuff, - status: is_leaf ? status : undefined, - error: is_leaf ? error : undefined, - props - }); - - if (props) { - node.uses.dependencies.add(url.href); - node.uses.url = true; - } - - branch.push(node); - - if (node && node.loaded) { - if (node.loaded.error) { - if (error) throw node.loaded.error; - error_args = { - status: node.loaded.status, - error: node.loaded.error, - url - }; - } else if (node.loaded.stuff) { - stuff = { - ...stuff, - ...node.loaded.stuff - }; - } - } - } - - result = error_args - ? await this._load_error(error_args) - : await this._get_navigation_result_from_branch({ - url, - params, - stuff, - branch, - status, - error - }); - } catch (e) { - if (error) throw e; - - result = await this._load_error({ - status: 500, - error: coalesce_to_error(e), - url - }); - } - - if (result.redirect) { - // this is a real edge case — `load` would need to return - // a redirect but only in the browser - location.href = new URL(result.redirect, location.href).href; - return; - } - - this._init(result); + await load(info); } /** @@ -642,26 +301,29 @@ export class Client { * @param {boolean} no_cache * @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean}} [opts] */ - async _handle_navigation(info, chain, no_cache, opts) { - if (this.started) { - this.stores.navigating.set({ - from: this.current.url, + async function _handle_navigation(info, chain, no_cache, opts) { + if (started) { + stores.navigating.set({ + from: current.url, to: info.url }); } - await this._update(info, chain, no_cache, opts); + await _update(info, chain, no_cache, opts); } + /** @type {{}} */ + let token; + /** * @param {import('./types').NavigationInfo} info * @param {string[]} chain * @param {boolean} no_cache * @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean}} [opts] */ - async _update(info, chain, no_cache, opts) { - const token = (this.token = {}); - let navigation_result = await this._get_navigation_result(info, no_cache); + async function _update(info, chain, no_cache, opts) { + const current_token = (token = {}); + let navigation_result = await _get_navigation_result(info, no_cache); if (!navigation_result) { location.href = info.url.href; @@ -669,20 +331,20 @@ export class Client { } // abort if user navigated during update - if (token !== this.token) return; + if (token !== current_token) return; - this.invalid.clear(); + invalid.clear(); if (navigation_result.redirect) { if (chain.length > 10 || chain.includes(info.url.pathname)) { - navigation_result = await this._load_error({ + navigation_result = await _load_error({ status: 500, error: new Error('Redirect loop'), url: info.url }); } else { - if (this.enabled) { - this.goto(new URL(navigation_result.redirect, info.url).href, {}, [ + if (enabled) { + goto(new URL(navigation_result.redirect, info.url).href, {}, [ ...chain, info.url.pathname ]); @@ -693,22 +355,22 @@ export class Client { return; } } else if (navigation_result.props?.page?.status >= 400) { - const updated = await this.stores.updated.check(); + const updated = await stores.updated.check(); if (updated) { location.href = info.url.href; return; } } - this.updating = true; + updating = true; - if (this.started) { - this.current = navigation_result.state; + if (started) { + current = navigation_result.state; - this.root.$set(navigation_result.props); - this.stores.navigating.set(null); + root.$set(navigation_result.props); + stores.navigating.set(null); } else { - this._init(navigation_result); + _init(navigation_result); } // opts must be passed if we're navigating @@ -739,7 +401,7 @@ export class Client { // need to render the DOM before we can scroll to the rendered elements await tick(); - if (this.autoscroll) { + if (autoscroll) { const deep_linked = info.url.hash && document.getElementById(info.url.hash.slice(1)); if (scroll) { scrollTo(scroll.x, scroll.y); @@ -757,74 +419,66 @@ export class Client { await tick(); } - this.loading.promise = null; - this.loading.id = null; - this.autoscroll = true; - this.updating = false; + loading.promise = null; + loading.id = null; + autoscroll = true; + updating = false; if (navigation_result.props.page) { - this.page = navigation_result.props.page; + page = navigation_result.props.page; } const leaf_node = navigation_result.state.branch[navigation_result.state.branch.length - 1]; - if (leaf_node && leaf_node.module.router === false) { - this._disable(); - } else { - this._enable(); - } + enabled = leaf_node?.module.router !== false; } - /** - * @param {import('./types').NavigationInfo} info - * @returns {Promise} - */ - load(info) { - this.loading.promise = this._get_navigation_result(info, false); - this.loading.id = info.id; + /** @param {import('./types').NavigationInfo} info */ + function load(info) { + loading.promise = _get_navigation_result(info, false); + loading.id = info.id; - return this.loading.promise; + return loading.promise; } /** @param {URL} url */ - _update_page_store(url) { - this.stores.page.set({ ...this.page, url }); - this.stores.page.notify(); + function _update_page_store(url) { + stores.page.set({ ...page, url }); + stores.page.notify(); } + /** @type {import('types').Page} */ + let page; + /** @param {import('./types').NavigationResult} result */ - _init(result) { - this.current = result.state; + function _init(result) { + current = result.state; const style = document.querySelector('style[data-svelte]'); if (style) style.remove(); - this.page = result.props.page; + page = result.props.page; - this.root = new this.Root({ - target: this.target, - props: { - stores: this.stores, - ...result.props - }, + root = new Root({ + target, + props: { ...result.props, stores }, hydrate: true }); - this.started = true; + started = true; - if (this.enabled) { + if (enabled) { const navigation = { from: null, to: new URL(location.href) }; - this.callbacks.after_navigate.forEach((fn) => fn(navigation)); + callbacks.after_navigate.forEach((fn) => fn(navigation)); } } /** * @param {import('./types').NavigationInfo} info * @param {boolean} no_cache - * @returns {Promise} */ - async _get_navigation_result(info, no_cache) { - if (this.loading.id === info.id && this.loading.promise) { - return this.loading.promise; + async function _get_navigation_result(info, no_cache) { + if (loading.id === info.id && loading.promise) { + return loading.promise; } for (let i = 0; i < info.routes.length; i += 1) { @@ -843,7 +497,7 @@ export class Client { } } - const result = await this._load( + const result = await _load( { route, info @@ -854,7 +508,7 @@ export class Client { } if (info.initial) { - return await this._load_error({ + return await _load_error({ status: 404, error: new Error(`Not found: ${info.url.pathname}`), url: info.url @@ -873,7 +527,7 @@ export class Client { * error?: Error; * }} opts */ - async _get_navigation_result_from_branch({ url, params, stuff, branch, status, error }) { + async function _get_navigation_result_from_branch({ url, params, stuff, branch, status, error }) { const filtered = /** @type {import('./types').BranchNode[] } */ (branch.filter(Boolean)); const redirect = filtered.find((f) => f.loaded && f.loaded.redirect); @@ -884,7 +538,7 @@ export class Client { url, params, branch, - session_id: this.session_id + session_id }, props: { components: filtered.map((node) => node.module.default) @@ -896,7 +550,7 @@ export class Client { result.props[`props_${i}`] = loaded ? await loaded.props : null; } - if (!this.current.url || url.href !== this.current.url.href) { + if (!current.url || url.href !== current.url.href) { result.props.page = { url, params, status, error, stuff }; // TODO remove this for 1.0 @@ -925,8 +579,8 @@ export class Client { let ready = false; const clear = () => { - if (this.cache.get(key) === result) { - this.cache.delete(key); + if (cache.get(key) === result) { + cache.delete(key); } unsubscribe(); @@ -935,13 +589,13 @@ export class Client { const timeout = setTimeout(clear, maxage * 1000); - const unsubscribe = this.stores.session.subscribe(() => { + const unsubscribe = stores.session.subscribe(() => { if (ready) clear(); }); ready = true; - this.cache.set(key, result); + cache.set(key, result); } return result; @@ -957,9 +611,8 @@ export class Client { * stuff: Record; * props?: Record; * }} options - * @returns */ - async _load_node({ status, error, module, url, params, stuff, props }) { + async function _load_node({ status, error, module, url, params, stuff, props }) { /** @type {import('./types').BranchNode} */ const node = { module, @@ -991,11 +644,9 @@ export class Client { }); } - const session = this.$session; + const session = $session; if (module.load) { - const { started } = this; - /** @type {import('types').LoadInput | import('types').ErrorLoadInput} */ const load_input = { params: uses_params, @@ -1053,13 +704,12 @@ export class Client { /** * @param {import('./types').NavigationCandidate} selected * @param {boolean} no_cache - * @returns {Promise} undefined if fallthrough */ - async _load({ route, info: { url, path } }, no_cache) { + async function _load({ route, info: { url, path } }, no_cache) { const key = url.pathname + url.search; if (!no_cache) { - const cached = this.cache.get(key); + const cached = cache.get(key); if (cached) return cached; } @@ -1069,10 +719,10 @@ export class Client { get_params(/** @type {RegExpExecArray} */ (pattern.exec(path))) : {}; - const changed = this.current.url && { - url: key !== this.current.url.pathname + this.current.url.search, - params: Object.keys(params).filter((key) => this.current.params[key] !== params[key]), - session: this.session_id !== this.current.session_id + const changed = current.url && { + url: key !== current.url.pathname + current.url.search, + params: Object.keys(params).filter((key) => current.params[key] !== params[key]), + session: session_id !== current.session_id }; /** @type {Array} */ @@ -1099,7 +749,7 @@ export class Client { if (!a[i]) continue; const module = await a[i](); - const previous = this.current.branch[i]; + const previous = current.branch[i]; const changed_since_last_render = !previous || @@ -1107,7 +757,7 @@ export class Client { (changed.url && previous.uses.url) || changed.params.some((param) => previous.uses.params.has(param)) || (changed.session && previous.uses.session) || - Array.from(previous.uses.dependencies).some((dep) => this.invalid.has(dep)) || + Array.from(previous.uses.dependencies).some((dep) => invalid.has(dep)) || (stuff_changed && previous.uses.stuff); if (changed_since_last_render) { @@ -1133,7 +783,7 @@ export class Client { return { redirect, props: {}, - state: this.current + state: current }; } @@ -1145,7 +795,7 @@ export class Client { } if (!error) { - node = await this._load_node({ + node = await _load_node({ module, url, params, @@ -1172,7 +822,7 @@ export class Client { return { redirect: node.loaded.redirect, props: {}, - state: this.current + state: current }; } @@ -1202,7 +852,7 @@ export class Client { } try { - error_loaded = await this._load_node({ + error_loaded = await _load_node({ status, error, module: await b[i](), @@ -1230,7 +880,7 @@ export class Client { } } - return await this._load_error({ + return await _load_error({ status, error, url @@ -1247,7 +897,7 @@ export class Client { } } - return await this._get_navigation_result_from_branch({ + return await _get_navigation_result_from_branch({ url, params, stuff, @@ -1264,20 +914,20 @@ export class Client { * url: URL; * }} opts */ - async _load_error({ status, error, url }) { + async function _load_error({ status, error, url }) { /** @type {Record} */ const params = {}; // error page does not have params - const node = await this._load_node({ - module: await this.fallback[0], + const node = await _load_node({ + module: await fallback[0], url, params, stuff: {} }); - const error_node = await this._load_node({ + const error_node = await _load_node({ status, error, - module: await this.fallback[1], + module: await fallback[1], url, params, stuff: (node && node.loaded && node.loaded.stuff) || {} @@ -1286,7 +936,7 @@ export class Client { const branch = [node, error_node]; const stuff = { ...node?.loaded?.stuff, ...error_node?.loaded?.stuff }; - return await this._get_navigation_result_from_branch({ + return await _get_navigation_result_from_branch({ url, params, stuff, @@ -1296,39 +946,26 @@ export class Client { }); } - /** - * Returns true if `url` has the same origin and basepath as the app - * @param {URL} url - */ - _owns(url) { - return url.origin === location.origin && url.pathname.startsWith(this.base); - } - - /** - * @param {URL} url - * @returns {import('./types').NavigationInfo | undefined} - */ - _parse(url) { - if (this._owns(url)) { - const path = decodeURI(url.pathname.slice(this.base.length) || '/'); + /** @param {URL} url */ + function _parse(url) { + if (url.origin === location.origin && url.pathname.startsWith(base)) { + const path = decodeURI(url.pathname.slice(base.length) || '/'); - return { + /** @type {import('./types').NavigationInfo} */ + const info = { id: url.pathname + url.search, - routes: this.routes.filter(([pattern]) => pattern.test(path)), + routes: routes.filter(([pattern]) => pattern.test(path)), url, path, - initial: !this.initialized + initial: !initialized }; - } - } - _enable() { - this.enabled = true; + return info; + } } - _disable() { - this.enabled = false; - } + /** @type {{}} */ + let navigating_token; /** * @param {{ @@ -1344,8 +981,8 @@ export class Client { * blocked: () => void; * }} opts */ - async _navigate({ url, scroll, keepfocus, chain, details, accepted, blocked }) { - const from = this.current.url; + async function _navigate({ url, scroll, keepfocus, chain, details, accepted, blocked }) { + const from = current.url; let should_block = false; const intent = { @@ -1354,14 +991,14 @@ export class Client { cancel: () => (should_block = true) }; - this.callbacks.before_navigate.forEach((fn) => fn(intent)); + callbacks.before_navigate.forEach((fn) => fn(intent)); if (should_block) { blocked(); return; } - const info = this._parse(url); + const info = _parse(url); if (!info) { location.href = url.href; return new Promise(() => { @@ -1369,36 +1006,372 @@ export class Client { }); } - update_scroll_positions(this.current_history_index); + update_scroll_positions(current_history_index); accepted(); - this.navigating++; + navigating++; - const pathname = normalize_path(url.pathname, this.trailing_slash); + const pathname = normalize_path(url.pathname, trailing_slash); info.url = new URL(url.origin + pathname + url.search + url.hash); - const token = (this.navigating_token = {}); + const current_navigating_token = (navigating_token = {}); - await this._handle_navigation(info, chain, false, { + await _handle_navigation(info, chain, false, { scroll, keepfocus }); - this.navigating--; + navigating--; // navigation was aborted - if (this.navigating_token !== token) return; - if (!this.navigating) { + if (navigating_token !== current_navigating_token) return; + if (!navigating) { const navigation = { from, to: url }; - this.callbacks.after_navigate.forEach((fn) => fn(navigation)); + callbacks.after_navigate.forEach((fn) => fn(navigation)); } if (details) { const change = details.replaceState ? 0 : 1; - details.state['sveltekit:index'] = this.current_history_index += change; + details.state['sveltekit:index'] = current_history_index += change; history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', info.url); } } + + return { + after_navigate: (fn) => { + onMount(() => { + callbacks.after_navigate.push(fn); + + return () => { + const i = callbacks.after_navigate.indexOf(fn); + callbacks.after_navigate.splice(i, 1); + }; + }); + }, + + before_navigate: (fn) => { + onMount(() => { + callbacks.before_navigate.push(fn); + + return () => { + const i = callbacks.before_navigate.indexOf(fn); + callbacks.before_navigate.splice(i, 1); + }; + }); + }, + + disable_scroll_handling: () => { + if (import.meta.env.DEV && started && !updating) { + throw new Error('Can only disable scroll handling during navigation'); + } + + if (updating || !started) { + autoscroll = false; + } + }, + + goto: (href, opts = {}) => goto(href, opts, []), + + init_listeners: () => { + 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', (e) => { + let should_block = false; + + const intent = { + from: current.url, + to: null, + cancel: () => (should_block = true) + }; + + callbacks.before_navigate.forEach((fn) => fn(intent)); + + if (should_block) { + e.preventDefault(); + e.returnValue = ''; + } else { + history.scrollRestoration = 'auto'; + } + }); + + addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + update_scroll_positions(current_history_index); + + try { + sessionStorage[SCROLL_KEY] = JSON.stringify(scroll_positions); + } catch { + // do nothing + } + } + }); + + /** @param {Event} event */ + const trigger_prefetch = (event) => { + const a = find_anchor(event); + if (a && a.href && a.hasAttribute('sveltekit:prefetch')) { + prefetch(get_href(a)); + } + }; + + /** @type {NodeJS.Timeout} */ + let mousemove_timeout; + + /** @param {MouseEvent|TouchEvent} event */ + const handle_mousemove = (event) => { + clearTimeout(mousemove_timeout); + mousemove_timeout = setTimeout(() => { + // event.composedPath(), which is used in find_anchor, will be empty if the event is read in a timeout + // add a layer of indirection to address that + event.target?.dispatchEvent( + new CustomEvent('sveltekit:trigger_prefetch', { bubbles: true }) + ); + }, 20); + }; + + addEventListener('touchstart', trigger_prefetch); + addEventListener('mousemove', handle_mousemove); + addEventListener('sveltekit:trigger_prefetch', trigger_prefetch); + + /** @param {MouseEvent} event */ + addEventListener('click', (event) => { + if (!enabled) return; + + // Adapted from https://github.com/visionmedia/page.js + // MIT license https://github.com/visionmedia/page.js#license + if (event.button || event.which !== 1) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + if (event.defaultPrevented) return; + + const a = find_anchor(event); + if (!a) return; + + if (!a.href) return; + + const is_svg_a_element = a instanceof SVGAElement; + const url = get_href(a); + const url_string = url.toString(); + if (url_string === location.href) { + if (!location.hash) event.preventDefault(); + return; + } + + // Ignore if url does not have origin (e.g. `mailto:`, `tel:`.) + // MEMO: Without this condition, firefox will open mailer twice. + // See: https://github.com/sveltejs/kit/issues/4045 + if (!is_svg_a_element && url.origin === 'null') return; + + // Ignore if tag has + // 1. 'download' attribute + // 2. 'rel' attribute includes external + const rel = (a.getAttribute('rel') || '').split(/\s+/); + + if (a.hasAttribute('download') || (rel && rel.includes('external'))) { + return; + } + + // Ignore if has a target + if (is_svg_a_element ? a.target.baseVal : a.target) return; + + // Check if new url only differs by hash and use the browser default behavior in that case + // This will ensure the `hashchange` event is fired + // Removing the hash does a full page navigation in the browser, so make sure a hash is present + const [base, hash] = url.href.split('#'); + if (hash !== undefined && base === location.href.split('#')[0]) { + // set this flag to distinguish between navigations triggered by + // clicking a hash link and those triggered by popstate + hash_navigating = true; + + update_scroll_positions(current_history_index); + _update_page_store(new URL(url.href)); + + return; + } + + _navigate({ + url, + scroll: a.hasAttribute('sveltekit:noscroll') ? scroll_state() : null, + keepfocus: false, + chain: [], + details: { + state: {}, + replaceState: false + }, + accepted: () => event.preventDefault(), + blocked: () => event.preventDefault() + }); + }); + + addEventListener('popstate', (event) => { + if (event.state && enabled) { + // if a popstate-driven navigation is cancelled, we need to counteract it + // with history.go, which means we end up back here, hence this check + if (event.state['sveltekit:index'] === current_history_index) return; + + _navigate({ + url: new URL(location.href), + scroll: scroll_positions[event.state['sveltekit:index']], + keepfocus: false, + chain: [], + details: null, + accepted: () => { + current_history_index = event.state['sveltekit:index']; + }, + blocked: () => { + const delta = current_history_index - event.state['sveltekit:index']; + history.go(delta); + } + }); + } + }); + + addEventListener('hashchange', () => { + // if the hashchange happened as a result of clicking on a link, + // we need to update history, otherwise we have to leave it alone + if (hash_navigating) { + hash_navigating = false; + history.replaceState( + { ...history.state, 'sveltekit:index': ++current_history_index }, + '', + location.href + ); + } + }); + + initialized = true; + }, + + invalidate: (resource) => { + const { href } = new URL(resource, location.href); + + invalid.add(href); + + if (!invalidating) { + invalidating = Promise.resolve().then(async () => { + const info = _parse(new URL(location.href)); + if (info) await _update(info, [], true); + + invalidating = null; + }); + } + + return invalidating; + }, + + prefetch: (href) => { + const url = new URL(href, get_base_uri(document)); + return prefetch(url); + }, + + // TODO rethink this API + prefetch_routes: async (pathnames) => { + const matching = pathnames + ? routes.filter((route) => pathnames.some((pathname) => route[0].test(pathname))) + : routes; + + const promises = matching.map((r) => Promise.all(r[1].map((load) => load()))); + + await Promise.all(promises); + }, + + start: async ({ status, error, nodes, params }) => { + const url = new URL(location.href); + + /** @type {Array} */ + const branch = []; + + /** @type {Record} */ + let stuff = {}; + + /** @type {import('./types').NavigationResult | undefined} */ + let result; + + let error_args; + + try { + for (let i = 0; i < nodes.length; i += 1) { + const is_leaf = i === nodes.length - 1; + + let props; + + if (is_leaf) { + const serialized = document.querySelector('script[sveltekit\\:data-type="props"]'); + if (serialized) { + props = JSON.parse(/** @type {string} */ (serialized.textContent)); + } + } + + const node = await _load_node({ + module: await nodes[i], + url, + params, + stuff, + status: is_leaf ? status : undefined, + error: is_leaf ? error : undefined, + props + }); + + if (props) { + node.uses.dependencies.add(url.href); + node.uses.url = true; + } + + branch.push(node); + + if (node && node.loaded) { + if (node.loaded.error) { + if (error) throw node.loaded.error; + error_args = { + status: node.loaded.status, + error: node.loaded.error, + url + }; + } else if (node.loaded.stuff) { + stuff = { + ...stuff, + ...node.loaded.stuff + }; + } + } + } + + result = error_args + ? await _load_error(error_args) + : await _get_navigation_result_from_branch({ + url, + params, + stuff, + branch, + status, + error + }); + } catch (e) { + if (error) throw e; + + result = await _load_error({ + status: 500, + error: coalesce_to_error(e), + url + }); + } + + if (result.redirect) { + // this is a real edge case — `load` would need to return + // a redirect but only in the browser + location.href = new URL(result.redirect, location.href).href; + return; + } + + _init(result); + }, + + // TODO don't expose this + routes + }; } diff --git a/packages/kit/src/runtime/client/singletons.js b/packages/kit/src/runtime/client/singletons.js index 7d5cf767aeb7..6cf3e8417bd6 100644 --- a/packages/kit/src/runtime/client/singletons.js +++ b/packages/kit/src/runtime/client/singletons.js @@ -1,9 +1,9 @@ -/** @type {import('./client').Client} */ +/** @type {import('./types').Client} */ export let client; /** * @param {{ - * client: import('./client').Client; + * client: import('./types').Client; * }} opts */ export function init(opts) { diff --git a/packages/kit/src/runtime/client/start.js b/packages/kit/src/runtime/client/start.js index bb0aab492d1e..c7d8d5308764 100644 --- a/packages/kit/src/runtime/client/start.js +++ b/packages/kit/src/runtime/client/start.js @@ -1,7 +1,7 @@ import Root from '__GENERATED__/root.svelte'; // @ts-expect-error - doesn't exist yet. generated by Rollup import { routes, fallback } from '__GENERATED__/manifest.js'; -import { Client } from './client.js'; +import { create_client } from './client.js'; import { init } from './singletons.js'; import { set_paths } from '../paths.js'; @@ -25,7 +25,7 @@ import { set_paths } from '../paths.js'; * }} opts */ export async function start({ paths, target, session, route, spa, trailing_slash, hydrate }) { - const client = new Client({ + const client = create_client({ Root, fallback, target, @@ -40,7 +40,7 @@ export async function start({ paths, target, session, route, spa, trailing_slash if (hydrate) await client.start(hydrate); if (route) { - if (spa) client.goto(location.href, { replaceState: true }, []); + if (spa) client.goto(location.href, { replaceState: true }); client.init_listeners(); } diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 7bd3e14e5595..7bc5049a6291 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -1,5 +1,36 @@ +import { + afterNavigate, + beforeNavigate, + goto, + invalidate, + prefetch, + prefetchRoutes +} from '$app/navigation'; import { CSRComponent, CSRRoute, NormalizedLoadOutput } from 'types'; +export interface Client { + // public API, exposed via $app/navigation + after_navigate: typeof afterNavigate; + before_navigate: typeof beforeNavigate; + goto: typeof goto; + invalidate: typeof invalidate; + prefetch: typeof prefetch; + prefetch_routes: typeof prefetchRoutes; + + // private API + disable_scroll_handling: () => void; + init_listeners: () => void; + start: (opts: { + status: number; + error: Error; + nodes: Array>; + params: Record; + }) => Promise; + + // TODO don't expose this + routes: CSRRoute[]; +} + export type NavigationInfo = { id: string; routes: CSRRoute[]; From d0f2bb331bde83d1676f4e1a06a54bee0f5f905a Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 10:58:58 -0500 Subject: [PATCH 04/34] no need to expose routes --- packages/kit/src/runtime/client/client.js | 5 +---- packages/kit/src/runtime/client/types.d.ts | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 46cc55a2a286..bfcee9ebb40e 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1369,9 +1369,6 @@ export function create_client({ Root, fallback, target, session, base, routes, t } _init(result); - }, - - // TODO don't expose this - routes + } }; } diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 7bc5049a6291..e111c7508031 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -26,9 +26,6 @@ export interface Client { nodes: Array>; params: Record; }) => Promise; - - // TODO don't expose this - routes: CSRRoute[]; } export type NavigationInfo = { From 60c74534c9dccf942f99e77813b346e2f82a1760 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 11:04:52 -0500 Subject: [PATCH 05/34] extract utils --- packages/kit/src/runtime/client/client.js | 156 +++------------------- packages/kit/src/runtime/client/utils.js | 128 ++++++++++++++++++ 2 files changed, 148 insertions(+), 136 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index bfcee9ebb40e..b9c70d547d18 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1,21 +1,29 @@ import { onMount, tick } from 'svelte'; import { writable } from 'svelte/store'; import { coalesce_to_error } from '../../utils/error.js'; -import { hash } from '../hash.js'; import { normalize } from '../load.js'; -import { base } from '../paths.js'; import { normalize_path } from '../../utils/url'; -import { get_base_uri } from './utils'; +import { + create_updated_store, + find_anchor, + get_base_uri, + get_href, + initial_fetch, + notifiable_store, + scroll_state +} from './utils'; /** * @typedef {import('types').CSRComponent} CSRComponent */ +const SCROLL_KEY = 'sveltekit:scroll'; +const INDEX_KEY = 'sveltekit:index'; + // We track the scroll position associated with each history entry in sessionStorage, // rather than on history.state itself, because when navigation is driven by // popstate it's too late to update the scroll position associated with the // state we're navigating from -const SCROLL_KEY = 'sveltekit:scroll'; /** @typedef {{ x: number, y: number }} ScrollPosition */ /** @type {Record} */ @@ -31,130 +39,6 @@ function update_scroll_positions(index) { scroll_positions[index] = scroll_state(); } -function scroll_state() { - return { - x: pageXOffset, - y: pageYOffset - }; -} - -/** @param {Event} event */ -function find_anchor(event) { - const node = event - .composedPath() - .find((e) => e instanceof Node && e.nodeName.toUpperCase() === 'A'); // SVG elements have a lowercase name - return /** @type {HTMLAnchorElement | SVGAElement | undefined} */ (node); -} - -/** @param {HTMLAnchorElement | SVGAElement} node */ -function get_href(node) { - return node instanceof SVGAElement - ? new URL(node.href.baseVal, document.baseURI) - : new URL(node.href); -} - -/** @param {any} value */ -function notifiable_store(value) { - const store = writable(value); - let ready = true; - - function notify() { - ready = true; - store.update((val) => val); - } - - /** @param {any} new_value */ - function set(new_value) { - ready = false; - store.set(new_value); - } - - /** @param {(value: any) => void} run */ - function subscribe(run) { - /** @type {any} */ - 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 }; -} - -function create_updated_store() { - const { set, subscribe } = writable(false); - - const interval = +( - /** @type {string} */ (import.meta.env.VITE_SVELTEKIT_APP_VERSION_POLL_INTERVAL) - ); - const initial = import.meta.env.VITE_SVELTEKIT_APP_VERSION; - - /** @type {NodeJS.Timeout} */ - let timeout; - - async function check() { - if (import.meta.env.DEV || import.meta.env.SSR) return false; - - clearTimeout(timeout); - - if (interval) timeout = setTimeout(check, interval); - - const file = import.meta.env.VITE_SVELTEKIT_APP_VERSION_FILE; - - const res = await fetch(`${base}/${file}`, { - headers: { - pragma: 'no-cache', - 'cache-control': 'no-cache' - } - }); - - if (res.ok) { - const { version } = await res.json(); - const updated = version !== initial; - - if (updated) { - set(true); - clearTimeout(timeout); - } - - return updated; - } else { - throw new Error(`Version check failed: ${res.status}`); - } - } - - if (interval) timeout = setTimeout(check, interval); - - return { - subscribe, - check - }; -} - -/** - * @param {RequestInfo} resource - * @param {RequestInit} [opts] - */ -function initial_fetch(resource, opts) { - const url = JSON.stringify(typeof resource === 'string' ? resource : resource.url); - - let selector = `script[sveltekit\\:data-type="data"][sveltekit\\:data-url=${url}]`; - - if (opts && typeof opts.body === 'string') { - selector += `[sveltekit\\:data-body="${hash(opts.body)}"]`; - } - - const script = document.querySelector(selector); - if (script && script.textContent) { - const { body, ...init } = JSON.parse(script.textContent); - return Promise.resolve(new Response(body, init)); - } - - return fetch(resource, opts); -} - /** * @param {{ * Root: CSRComponent; @@ -229,11 +113,11 @@ export function create_client({ Root, fallback, target, session, base, routes, t let initialized = false; // keeping track of the history index in order to prevent popstate navigation events if needed - let current_history_index = history.state?.['sveltekit:index'] ?? 0; + let current_history_index = history.state?.[INDEX_KEY] ?? 0; if (current_history_index === 0) { // create initial history entry, so we can return here - history.replaceState({ ...history.state, 'sveltekit:index': 0 }, '', location.href); + history.replaceState({ ...history.state, [INDEX_KEY]: 0 }, '', location.href); } // if we reload the page, or Cmd-Shift-T back to it, @@ -1034,7 +918,7 @@ export function create_client({ Root, fallback, target, session, base, routes, t if (details) { const change = details.replaceState ? 0 : 1; - details.state['sveltekit:index'] = current_history_index += change; + details.state[INDEX_KEY] = current_history_index += change; history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', info.url); } } @@ -1212,19 +1096,19 @@ export function create_client({ Root, fallback, target, session, base, routes, t if (event.state && enabled) { // if a popstate-driven navigation is cancelled, we need to counteract it // with history.go, which means we end up back here, hence this check - if (event.state['sveltekit:index'] === current_history_index) return; + if (event.state[INDEX_KEY] === current_history_index) return; _navigate({ url: new URL(location.href), - scroll: scroll_positions[event.state['sveltekit:index']], + scroll: scroll_positions[event.state[INDEX_KEY]], keepfocus: false, chain: [], details: null, accepted: () => { - current_history_index = event.state['sveltekit:index']; + current_history_index = event.state[INDEX_KEY]; }, blocked: () => { - const delta = current_history_index - event.state['sveltekit:index']; + const delta = current_history_index - event.state[INDEX_KEY]; history.go(delta); } }); @@ -1237,7 +1121,7 @@ export function create_client({ Root, fallback, target, session, base, routes, t if (hash_navigating) { hash_navigating = false; history.replaceState( - { ...history.state, 'sveltekit:index': ++current_history_index }, + { ...history.state, [INDEX_KEY]: ++current_history_index }, '', location.href ); diff --git a/packages/kit/src/runtime/client/utils.js b/packages/kit/src/runtime/client/utils.js index 1353f5e39582..adad18224e69 100644 --- a/packages/kit/src/runtime/client/utils.js +++ b/packages/kit/src/runtime/client/utils.js @@ -1,3 +1,7 @@ +import { writable } from 'svelte/store'; +import { hash } from '../hash.js'; +import { base } from '../paths.js'; + /** @param {HTMLDocument} doc */ export function get_base_uri(doc) { let baseURI = doc.baseURI; @@ -9,3 +13,127 @@ export function get_base_uri(doc) { return baseURI; } + +export function scroll_state() { + return { + x: pageXOffset, + y: pageYOffset + }; +} + +/** @param {Event} event */ +export function find_anchor(event) { + const node = event + .composedPath() + .find((e) => e instanceof Node && e.nodeName.toUpperCase() === 'A'); // SVG elements have a lowercase name + return /** @type {HTMLAnchorElement | SVGAElement | undefined} */ (node); +} + +/** @param {HTMLAnchorElement | SVGAElement} node */ +export function get_href(node) { + return node instanceof SVGAElement + ? new URL(node.href.baseVal, document.baseURI) + : new URL(node.href); +} + +/** @param {any} value */ +export function notifiable_store(value) { + const store = writable(value); + let ready = true; + + function notify() { + ready = true; + store.update((val) => val); + } + + /** @param {any} new_value */ + function set(new_value) { + ready = false; + store.set(new_value); + } + + /** @param {(value: any) => void} run */ + function subscribe(run) { + /** @type {any} */ + 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 }; +} + +export function create_updated_store() { + const { set, subscribe } = writable(false); + + const interval = +( + /** @type {string} */ (import.meta.env.VITE_SVELTEKIT_APP_VERSION_POLL_INTERVAL) + ); + const initial = import.meta.env.VITE_SVELTEKIT_APP_VERSION; + + /** @type {NodeJS.Timeout} */ + let timeout; + + async function check() { + if (import.meta.env.DEV || import.meta.env.SSR) return false; + + clearTimeout(timeout); + + if (interval) timeout = setTimeout(check, interval); + + const file = import.meta.env.VITE_SVELTEKIT_APP_VERSION_FILE; + + const res = await fetch(`${base}/${file}`, { + headers: { + pragma: 'no-cache', + 'cache-control': 'no-cache' + } + }); + + if (res.ok) { + const { version } = await res.json(); + const updated = version !== initial; + + if (updated) { + set(true); + clearTimeout(timeout); + } + + return updated; + } else { + throw new Error(`Version check failed: ${res.status}`); + } + } + + if (interval) timeout = setTimeout(check, interval); + + return { + subscribe, + check + }; +} + +/** + * @param {RequestInfo} resource + * @param {RequestInit} [opts] + */ +export function initial_fetch(resource, opts) { + const url = JSON.stringify(typeof resource === 'string' ? resource : resource.url); + + let selector = `script[sveltekit\\:data-type="data"][sveltekit\\:data-url=${url}]`; + + if (opts && typeof opts.body === 'string') { + selector += `[sveltekit\\:data-body="${hash(opts.body)}"]`; + } + + const script = document.querySelector(selector); + if (script && script.textContent) { + const { body, ...init } = JSON.parse(script.textContent); + return Promise.resolve(new Response(body, init)); + } + + return fetch(resource, opts); +} From aa41de32c93489d7ebfdf3ddf68070b043b71d35 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 11:12:53 -0500 Subject: [PATCH 06/34] lint --- packages/kit/src/runtime/client/client.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 661fc0b1143d..c42b533f271d 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -110,7 +110,6 @@ export function create_client({ Root, fallback, target, session, base, routes, t let navigating = 0; let enabled = true; - let initialized = false; // keeping track of the history index in order to prevent popstate navigation events if needed let current_history_index = history.state?.[INDEX_KEY] ?? 0; @@ -1126,8 +1125,6 @@ export function create_client({ Root, fallback, target, session, base, routes, t ); } }); - - initialized = true; }, invalidate: (resource) => { From e5074d4feba7b9a83c900fbbde2ef2cdb7dfa8f7 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 11:23:38 -0500 Subject: [PATCH 07/34] rename some methods --- packages/kit/src/runtime/client/client.js | 83 ++++++++++------------ packages/kit/src/runtime/client/start.js | 7 +- packages/kit/src/runtime/client/types.d.ts | 6 +- 3 files changed, 47 insertions(+), 49 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index c42b533f271d..47e8c9a74129 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -175,7 +175,10 @@ export function create_client({ Root, fallback, target, session, base, routes, t throw new Error('Attempted to prefetch a URL that does not belong to this app'); } - await load(info); + loading.promise = _get_navigation_result(info, false); + loading.id = info.id; + + return loading.promise; } /** @@ -323,14 +326,6 @@ export function create_client({ Root, fallback, target, session, base, routes, t enabled = leaf_node?.module.router !== false; } - /** @param {import('./types').NavigationInfo} info */ - function load(info) { - loading.promise = _get_navigation_result(info, false); - loading.id = info.id; - - return loading.promise; - } - /** @param {URL} url */ function _update_page_store(url) { stores.page.set({ ...page, url }); @@ -956,7 +951,40 @@ export function create_client({ Root, fallback, target, session, base, routes, t goto: (href, opts = {}) => goto(href, opts, []), - init_listeners: () => { + invalidate: (resource) => { + const { href } = new URL(resource, location.href); + + invalid.add(href); + + if (!invalidating) { + invalidating = Promise.resolve().then(async () => { + const info = _parse(new URL(location.href)); + if (info) await _update(info, [], true); + + invalidating = null; + }); + } + + return invalidating; + }, + + prefetch: async (href) => { + const url = new URL(href, get_base_uri(document)); + await prefetch(url); + }, + + // TODO rethink this API + prefetch_routes: async (pathnames) => { + const matching = pathnames + ? routes.filter((route) => pathnames.some((pathname) => route[0].test(pathname))) + : routes; + + const promises = matching.map((r) => Promise.all(r[1].map((load) => load()))); + + await Promise.all(promises); + }, + + _start_router: () => { history.scrollRestoration = 'manual'; // Adopted from Nuxt.js @@ -1127,40 +1155,7 @@ export function create_client({ Root, fallback, target, session, base, routes, t }); }, - invalidate: (resource) => { - const { href } = new URL(resource, location.href); - - invalid.add(href); - - if (!invalidating) { - invalidating = Promise.resolve().then(async () => { - const info = _parse(new URL(location.href)); - if (info) await _update(info, [], true); - - invalidating = null; - }); - } - - return invalidating; - }, - - prefetch: (href) => { - const url = new URL(href, get_base_uri(document)); - return prefetch(url); - }, - - // TODO rethink this API - prefetch_routes: async (pathnames) => { - const matching = pathnames - ? routes.filter((route) => pathnames.some((pathname) => route[0].test(pathname))) - : routes; - - const promises = matching.map((r) => Promise.all(r[1].map((load) => load()))); - - await Promise.all(promises); - }, - - start: async ({ status, error, nodes, params }) => { + _hydrate: async ({ status, error, nodes, params }) => { const url = new URL(location.href); /** @type {Array} */ diff --git a/packages/kit/src/runtime/client/start.js b/packages/kit/src/runtime/client/start.js index c7d8d5308764..c7dee913c0fd 100644 --- a/packages/kit/src/runtime/client/start.js +++ b/packages/kit/src/runtime/client/start.js @@ -38,10 +38,13 @@ export async function start({ paths, target, session, route, spa, trailing_slash init({ client }); set_paths(paths); - if (hydrate) await client.start(hydrate); + if (hydrate) { + await client._hydrate(hydrate); + } + if (route) { if (spa) client.goto(location.href, { replaceState: true }); - client.init_listeners(); + client._start_router(); } dispatchEvent(new CustomEvent('sveltekit:start')); diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 1d46c483be37..677ad09a011d 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -12,20 +12,20 @@ export interface Client { // public API, exposed via $app/navigation after_navigate: typeof afterNavigate; before_navigate: typeof beforeNavigate; + disable_scroll_handling: () => void; goto: typeof goto; invalidate: typeof invalidate; prefetch: typeof prefetch; prefetch_routes: typeof prefetchRoutes; // private API - disable_scroll_handling: () => void; - init_listeners: () => void; - start: (opts: { + _hydrate: (opts: { status: number; error: Error; nodes: Array>; params: Record; }) => Promise; + _start_router: () => void; } export type NavigationInfo = { From ca3777a4acd2d260d01ab3e4305fe517f383cf2b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 11:32:56 -0500 Subject: [PATCH 08/34] simplify --- packages/kit/src/runtime/client/ambient.d.ts | 6 ++++++ packages/kit/src/runtime/client/client.js | 14 +++++--------- packages/kit/src/runtime/client/start.js | 8 +------- 3 files changed, 12 insertions(+), 16 deletions(-) create mode 100644 packages/kit/src/runtime/client/ambient.d.ts diff --git a/packages/kit/src/runtime/client/ambient.d.ts b/packages/kit/src/runtime/client/ambient.d.ts new file mode 100644 index 000000000000..f0ef42b50aab --- /dev/null +++ b/packages/kit/src/runtime/client/ambient.d.ts @@ -0,0 +1,6 @@ +declare module '__GENERATED__/manifest.js' { + import { CSRComponent, CSRRoute } from 'types'; + + export const fallback: [CSRComponent, CSRComponent]; + export const routes: CSRRoute[]; +} diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 47e8c9a74129..75c7c54838de 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -13,9 +13,8 @@ import { scroll_state } from './utils'; -/** - * @typedef {import('types').CSRComponent} CSRComponent - */ +import Root from '__GENERATED__/root.svelte'; +import { routes, fallback } from '__GENERATED__/manifest.js'; const SCROLL_KEY = 'sveltekit:scroll'; const INDEX_KEY = 'sveltekit:index'; @@ -41,17 +40,14 @@ function update_scroll_positions(index) { /** * @param {{ - * Root: CSRComponent; - * fallback: [CSRComponent, CSRComponent]; - * target: Node; + * target: Element; * session: App.Session; * base: string; - * routes: import('types').CSRRoute[]; * trailing_slash: import('types').TrailingSlash; * }} opts * @returns {import('./types').Client} */ -export function create_client({ Root, fallback, target, session, base, routes, trailing_slash }) { +export function create_client({ target, session, base, trailing_slash }) { /** @type {Map} */ const cache = new Map(); @@ -483,7 +479,7 @@ export function create_client({ Root, fallback, target, session, base, routes, t * @param {{ * status?: number; * error?: Error; - * module: CSRComponent; + * module: import('types').CSRComponent; * url: URL; * params: Record; * stuff: Record; diff --git a/packages/kit/src/runtime/client/start.js b/packages/kit/src/runtime/client/start.js index c7dee913c0fd..fef58da5cde1 100644 --- a/packages/kit/src/runtime/client/start.js +++ b/packages/kit/src/runtime/client/start.js @@ -1,6 +1,3 @@ -import Root from '__GENERATED__/root.svelte'; -// @ts-expect-error - doesn't exist yet. generated by Rollup -import { routes, fallback } from '__GENERATED__/manifest.js'; import { create_client } from './client.js'; import { init } from './singletons.js'; import { set_paths } from '../paths.js'; @@ -11,7 +8,7 @@ import { set_paths } from '../paths.js'; * assets: string; * base: string; * }, - * target: Node; + * target: Element; * session: any; * route: boolean; * spa: boolean; @@ -26,12 +23,9 @@ import { set_paths } from '../paths.js'; */ export async function start({ paths, target, session, route, spa, trailing_slash, hydrate }) { const client = create_client({ - Root, - fallback, target, session, base: paths.base, - routes, trailing_slash }); From 26757bd62b9b4f37987f65dbbe728b8722234363 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 11:35:43 -0500 Subject: [PATCH 09/34] reorder --- packages/kit/src/runtime/client/client.js | 30 +++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 75c7c54838de..67fde82994cf 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -68,6 +68,14 @@ export function create_client({ target, session, base, trailing_slash }) { promise: null }; + const callbacks = { + /** @type {Array<({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void>} */ + before_navigate: [], + + /** @type {Array<({ from, to }: { from: URL | null, to: URL }) => void>} */ + after_navigate: [] + }; + /** @type {import('./types').NavigationState} */ let current = { // @ts-ignore - we need the initial value to be null @@ -122,13 +130,14 @@ export function create_client({ target, session, base, trailing_slash }) { let hash_navigating = false; - const callbacks = { - /** @type {Array<({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void>} */ - before_navigate: [], + /** @type {import('types').Page} */ + let page; - /** @type {Array<({ from, to }: { from: URL | null, to: URL }) => void>} */ - after_navigate: [] - }; + /** @type {{}} */ + let token; + + /** @type {{}} */ + let navigating_token; /** * @param {string} href @@ -194,9 +203,6 @@ export function create_client({ target, session, base, trailing_slash }) { await _update(info, chain, no_cache, opts); } - /** @type {{}} */ - let token; - /** * @param {import('./types').NavigationInfo} info * @param {string[]} chain @@ -328,9 +334,6 @@ export function create_client({ target, session, base, trailing_slash }) { stores.page.notify(); } - /** @type {import('types').Page} */ - let page; - /** @param {import('./types').NavigationResult} result */ function _init(result) { current = result.state; @@ -837,9 +840,6 @@ export function create_client({ target, session, base, trailing_slash }) { } } - /** @type {{}} */ - let navigating_token; - /** * @param {{ * url: URL; From 29b00374a530754d756f15a5f90361b0ea7756ce Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 11:38:30 -0500 Subject: [PATCH 10/34] reduce indirection --- packages/kit/src/runtime/client/client.js | 29 +++++++++-------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 67fde82994cf..20421d58f558 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -186,23 +186,6 @@ export function create_client({ target, session, base, trailing_slash }) { return loading.promise; } - /** - * @param {import('./types').NavigationInfo} info - * @param {string[]} chain - * @param {boolean} no_cache - * @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean}} [opts] - */ - async function _handle_navigation(info, chain, no_cache, opts) { - if (started) { - stores.navigating.set({ - from: current.url, - to: info.url - }); - } - - await _update(info, chain, no_cache, opts); - } - /** * @param {import('./types').NavigationInfo} info * @param {string[]} chain @@ -891,7 +874,14 @@ export function create_client({ target, session, base, trailing_slash }) { const current_navigating_token = (navigating_token = {}); - await _handle_navigation(info, chain, false, { + if (started) { + stores.navigating.set({ + from: current.url, + to: info.url + }); + } + + await _update(info, chain, false, { scroll, keepfocus }); @@ -900,9 +890,12 @@ export function create_client({ target, session, base, trailing_slash }) { // navigation was aborted if (navigating_token !== current_navigating_token) return; + if (!navigating) { const navigation = { from, to: url }; callbacks.after_navigate.forEach((fn) => fn(navigation)); + + stores.navigating.set(null); } if (details) { From d58dc4d2d3b81ac0fb8b08cfd6859abff0e80bd5 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 11:43:14 -0500 Subject: [PATCH 11/34] reduce indirection --- packages/kit/src/runtime/client/client.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 20421d58f558..ab46078a391c 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -311,12 +311,6 @@ export function create_client({ target, session, base, trailing_slash }) { enabled = leaf_node?.module.router !== false; } - /** @param {URL} url */ - function _update_page_store(url) { - stores.page.set({ ...page, url }); - stores.page.notify(); - } - /** @param {import('./types').NavigationResult} result */ function _init(result) { current = result.state; @@ -1088,7 +1082,9 @@ export function create_client({ target, session, base, trailing_slash }) { hash_navigating = true; update_scroll_positions(current_history_index); - _update_page_store(new URL(url.href)); + + stores.page.set({ ...page, url }); + stores.page.notify(); return; } From bc1e835860f8f6ff272ec2c0e99e5ae22237ea1b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 11:58:33 -0500 Subject: [PATCH 12/34] rename NavigationInfo to NavigationIntent --- packages/kit/src/runtime/client/client.js | 94 +++++++++++----------- packages/kit/src/runtime/client/types.d.ts | 18 ++++- 2 files changed, 61 insertions(+), 51 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index ab46078a391c..7ba139ec415e 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -105,8 +105,8 @@ export function create_client({ target, session, base, trailing_slash }) { if (!ready) return; session_id += 1; - const info = _parse(new URL(location.href)); - if (info) _update(info, [], true); + const intent = get_navigation_intent(new URL(location.href)); + if (intent) _update(intent, [], true); }); ready = true; @@ -174,38 +174,38 @@ export function create_client({ target, session, base, trailing_slash }) { /** @param {URL} url */ async function prefetch(url) { - const info = _parse(url); + const intent = get_navigation_intent(url); - if (!info) { + if (!intent) { throw new Error('Attempted to prefetch a URL that does not belong to this app'); } - loading.promise = _get_navigation_result(info, false); - loading.id = info.id; + loading.promise = _get_navigation_result(intent, false); + loading.id = intent.id; return loading.promise; } /** - * @param {import('./types').NavigationInfo} info + * @param {import('./types').NavigationIntent} intent * @param {string[]} chain * @param {boolean} no_cache * @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean}} [opts] */ - async function _update(info, chain, no_cache, opts) { + async function _update(intent, chain, no_cache, opts) { const current_token = (token = {}); - let navigation_result = await _get_navigation_result(info, no_cache); + let navigation_result = await _get_navigation_result(intent, no_cache); - if (!navigation_result && info.url.pathname === location.pathname) { + if (!navigation_result && intent.url.pathname === location.pathname) { navigation_result = await _load_error({ status: 404, - error: new Error(`Not found: ${info.url.pathname}`), - url: info.url + error: new Error(`Not found: ${intent.url.pathname}`), + url: intent.url }); } if (!navigation_result) { - location.href = info.url.href; + location.href = intent.url.href; return; } @@ -215,17 +215,17 @@ export function create_client({ target, session, base, trailing_slash }) { invalid.clear(); if (navigation_result.redirect) { - if (chain.length > 10 || chain.includes(info.url.pathname)) { + if (chain.length > 10 || chain.includes(intent.url.pathname)) { navigation_result = await _load_error({ status: 500, error: new Error('Redirect loop'), - url: info.url + url: intent.url }); } else { if (enabled) { - goto(new URL(navigation_result.redirect, info.url).href, {}, [ + goto(new URL(navigation_result.redirect, intent.url).href, {}, [ ...chain, - info.url.pathname + intent.url.pathname ]); } else { location.href = new URL(navigation_result.redirect, location.href).href; @@ -236,7 +236,7 @@ export function create_client({ target, session, base, trailing_slash }) { } else if (navigation_result.props?.page?.status >= 400) { const updated = await stores.updated.check(); if (updated) { - location.href = info.url.href; + location.href = intent.url.href; return; } } @@ -281,7 +281,7 @@ export function create_client({ target, session, base, trailing_slash }) { await tick(); if (autoscroll) { - const deep_linked = info.url.hash && document.getElementById(info.url.hash.slice(1)); + const deep_linked = intent.url.hash && document.getElementById(intent.url.hash.slice(1)); if (scroll) { scrollTo(scroll.x, scroll.y); } else if (deep_linked) { @@ -335,22 +335,22 @@ export function create_client({ target, session, base, trailing_slash }) { } /** - * @param {import('./types').NavigationInfo} info + * @param {import('./types').NavigationIntent} intent * @param {boolean} no_cache */ - async function _get_navigation_result(info, no_cache) { - if (loading.id === info.id && loading.promise) { + async function _get_navigation_result(intent, no_cache) { + if (loading.id === intent.id && loading.promise) { return loading.promise; } - for (let i = 0; i < info.routes.length; i += 1) { - const route = info.routes[i]; + for (let i = 0; i < intent.routes.length; i += 1) { + const route = intent.routes[i]; // load code for subsequent routes immediately, if they are as // likely to match the current path/query as the current one let j = i + 1; - while (j < info.routes.length) { - const next = info.routes[j]; + while (j < intent.routes.length) { + const next = intent.routes[j]; if (next[0].toString() === route[0].toString()) { next[1].forEach((loader) => loader()); j += 1; @@ -362,7 +362,7 @@ export function create_client({ target, session, base, trailing_slash }) { const result = await _load( { route, - info + intent }, no_cache ); @@ -559,11 +559,9 @@ export function create_client({ target, session, base, trailing_slash }) { * @param {import('./types').NavigationCandidate} selected * @param {boolean} no_cache */ - async function _load({ route, info: { url, path } }, no_cache) { - const key = url.pathname + url.search; - + async function _load({ route, intent: { id, url, path } }, no_cache) { if (!no_cache) { - const cached = cache.get(key); + const cached = cache.get(id); if (cached) return cached; } @@ -574,7 +572,7 @@ export function create_client({ target, session, base, trailing_slash }) { : {}; const changed = current.url && { - url: key !== current.url.pathname + current.url.search, + url: id !== current.url.pathname + current.url.search, params: Object.keys(params).filter((key) => current.params[key] !== params[key]), session: session_id !== current.session_id }; @@ -801,19 +799,19 @@ export function create_client({ target, session, base, trailing_slash }) { } /** @param {URL} url */ - function _parse(url) { + function get_navigation_intent(url) { if (url.origin === location.origin && url.pathname.startsWith(base)) { const path = decodeURI(url.pathname.slice(base.length) || '/'); - /** @type {import('./types').NavigationInfo} */ - const info = { + /** @type {import('./types').NavigationIntent} */ + const intent = { id: url.pathname + url.search, routes: routes.filter(([pattern]) => pattern.test(path)), url, path }; - return info; + return intent; } } @@ -835,21 +833,21 @@ export function create_client({ target, session, base, trailing_slash }) { const from = current.url; let should_block = false; - const intent = { + const navigation = { from, to: url, cancel: () => (should_block = true) }; - callbacks.before_navigate.forEach((fn) => fn(intent)); + callbacks.before_navigate.forEach((fn) => fn(navigation)); if (should_block) { blocked(); return; } - const info = _parse(url); - if (!info) { + const intent = get_navigation_intent(url); + if (!intent) { location.href = url.href; return new Promise(() => { // never resolves @@ -864,18 +862,18 @@ export function create_client({ target, session, base, trailing_slash }) { const pathname = normalize_path(url.pathname, trailing_slash); - info.url = new URL(url.origin + pathname + url.search + url.hash); + intent.url = new URL(url.origin + pathname + url.search + url.hash); const current_navigating_token = (navigating_token = {}); if (started) { stores.navigating.set({ from: current.url, - to: info.url + to: intent.url }); } - await _update(info, chain, false, { + await _update(intent, chain, false, { scroll, keepfocus }); @@ -895,7 +893,7 @@ export function create_client({ target, session, base, trailing_slash }) { if (details) { const change = details.replaceState ? 0 : 1; details.state[INDEX_KEY] = current_history_index += change; - history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', info.url); + history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', intent.url); } } @@ -941,8 +939,8 @@ export function create_client({ target, session, base, trailing_slash }) { if (!invalidating) { invalidating = Promise.resolve().then(async () => { - const info = _parse(new URL(location.href)); - if (info) await _update(info, [], true); + const intent = get_navigation_intent(new URL(location.href)); + if (intent) await _update(intent, [], true); invalidating = null; }); @@ -977,13 +975,13 @@ export function create_client({ target, session, base, trailing_slash }) { addEventListener('beforeunload', (e) => { let should_block = false; - const intent = { + const navigation = { from: current.url, to: null, cancel: () => (should_block = true) }; - callbacks.before_navigate.forEach((fn) => fn(intent)); + callbacks.before_navigate.forEach((fn) => fn(navigation)); if (should_block) { e.preventDefault(); diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 677ad09a011d..62bae55ed766 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -28,16 +28,28 @@ export interface Client { _start_router: () => void; } -export type NavigationInfo = { +export type NavigationIntent = { + /** + * `url.pathname + url.search` + */ id: string; + /** + * `url.pathname`, minus any `paths.base` prefix + */ + path: string; + /** + * The routes that could satisfy this navigation intent + */ routes: CSRRoute[]; + /** + * The destination URL + */ url: URL; - path: string; }; export type NavigationCandidate = { route: CSRRoute; - info: NavigationInfo; + intent: NavigationIntent; }; export type NavigationResult = { From fbdcab04aec0516c3c515208abd67126e55d7f9f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 12:04:29 -0500 Subject: [PATCH 13/34] rename _load_error to load_root_error_page --- packages/kit/src/runtime/client/client.js | 29 ++++++++++++----------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 7ba139ec415e..f1a7fa216d23 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -197,7 +197,7 @@ export function create_client({ target, session, base, trailing_slash }) { let navigation_result = await _get_navigation_result(intent, no_cache); if (!navigation_result && intent.url.pathname === location.pathname) { - navigation_result = await _load_error({ + navigation_result = await load_root_error_page({ status: 404, error: new Error(`Not found: ${intent.url.pathname}`), url: intent.url @@ -216,7 +216,7 @@ export function create_client({ target, session, base, trailing_slash }) { if (navigation_result.redirect) { if (chain.length > 10 || chain.includes(intent.url.pathname)) { - navigation_result = await _load_error({ + navigation_result = await load_root_error_page({ status: 500, error: new Error('Redirect loop'), url: intent.url @@ -732,7 +732,7 @@ export function create_client({ target, session, base, trailing_slash }) { } } - return await _load_error({ + return await load_root_error_page({ status, error, url @@ -766,33 +766,34 @@ export function create_client({ target, session, base, trailing_slash }) { * url: URL; * }} opts */ - async function _load_error({ status, error, url }) { + async function load_root_error_page({ status, error, url }) { /** @type {Record} */ const params = {}; // error page does not have params - const node = await _load_node({ + const root_layout = await _load_node({ module: await fallback[0], url, params, stuff: {} }); - const error_node = await _load_node({ + + const root_error = await _load_node({ status, error, module: await fallback[1], url, params, - stuff: (node && node.loaded && node.loaded.stuff) || {} + stuff: (root_layout && root_layout.loaded && root_layout.loaded.stuff) || {} }); - const branch = [node, error_node]; - const stuff = { ...node?.loaded?.stuff, ...error_node?.loaded?.stuff }; - return await _get_navigation_result_from_branch({ url, params, - stuff, - branch, + stuff: { + ...root_layout?.loaded?.stuff, + ...root_error?.loaded?.stuff + }, + branch: [root_layout, root_error], status, error }); @@ -1200,7 +1201,7 @@ export function create_client({ target, session, base, trailing_slash }) { } result = error_args - ? await _load_error(error_args) + ? await load_root_error_page(error_args) : await _get_navigation_result_from_branch({ url, params, @@ -1212,7 +1213,7 @@ export function create_client({ target, session, base, trailing_slash }) { } catch (e) { if (error) throw e; - result = await _load_error({ + result = await load_root_error_page({ status: 500, error: coalesce_to_error(e), url From 8c2ad26ac1ff36b3d05398c5dade83ac53927406 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 12:14:01 -0500 Subject: [PATCH 14/34] simplify --- packages/kit/src/runtime/client/client.js | 47 ++++++++++++----------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index f1a7fa216d23..07ed5ac8e8d5 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -106,7 +106,7 @@ export function create_client({ target, session, base, trailing_slash }) { session_id += 1; const intent = get_navigation_intent(new URL(location.href)); - if (intent) _update(intent, [], true); + _update(intent, [], true); }); ready = true; @@ -174,12 +174,12 @@ export function create_client({ target, session, base, trailing_slash }) { /** @param {URL} url */ async function prefetch(url) { - const intent = get_navigation_intent(url); - - if (!intent) { + if (!owns(url)) { throw new Error('Attempted to prefetch a URL that does not belong to this app'); } + const intent = get_navigation_intent(url); + loading.promise = _get_navigation_result(intent, false); loading.id = intent.id; @@ -799,21 +799,24 @@ export function create_client({ target, session, base, trailing_slash }) { }); } + /** @param {URL} url */ + function owns(url) { + return url.origin === location.origin && url.pathname.startsWith(base); + } + /** @param {URL} url */ function get_navigation_intent(url) { - if (url.origin === location.origin && url.pathname.startsWith(base)) { - const path = decodeURI(url.pathname.slice(base.length) || '/'); + const path = decodeURI(url.pathname.slice(base.length) || '/'); - /** @type {import('./types').NavigationIntent} */ - const intent = { - id: url.pathname + url.search, - routes: routes.filter(([pattern]) => pattern.test(path)), - url, - path - }; + /** @type {import('./types').NavigationIntent} */ + const intent = { + id: url.pathname + url.search, + routes: routes.filter(([pattern]) => pattern.test(path)), + url, + path + }; - return intent; - } + return intent; } /** @@ -847,24 +850,24 @@ export function create_client({ target, session, base, trailing_slash }) { return; } - const intent = get_navigation_intent(url); - if (!intent) { + if (!owns(url)) { location.href = url.href; return new Promise(() => { // never resolves }); } + const pathname = normalize_path(url.pathname, trailing_slash); + url = new URL(url.origin + pathname + url.search + url.hash); + + const intent = get_navigation_intent(url); + update_scroll_positions(current_history_index); accepted(); navigating++; - const pathname = normalize_path(url.pathname, trailing_slash); - - intent.url = new URL(url.origin + pathname + url.search + url.hash); - const current_navigating_token = (navigating_token = {}); if (started) { @@ -941,7 +944,7 @@ export function create_client({ target, session, base, trailing_slash }) { if (!invalidating) { invalidating = Promise.resolve().then(async () => { const intent = get_navigation_intent(new URL(location.href)); - if (intent) await _update(intent, [], true); + await _update(intent, [], true); invalidating = null; }); From 7f9cc40d53000994fb80d038a3a0481531a50628 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 12:18:22 -0500 Subject: [PATCH 15/34] simplify --- packages/kit/src/runtime/client/client.js | 13 ++++--------- packages/kit/src/runtime/client/types.d.ts | 5 ----- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 07ed5ac8e8d5..13fa9e01dc96 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -359,13 +359,7 @@ export function create_client({ target, session, base, trailing_slash }) { } } - const result = await _load( - { - route, - intent - }, - no_cache - ); + const result = await _load(route, intent, no_cache); if (result) return result; } } @@ -556,10 +550,11 @@ export function create_client({ target, session, base, trailing_slash }) { } /** - * @param {import('./types').NavigationCandidate} selected + * @param {import('types').CSRRoute} route + * @param {import('./types').NavigationIntent} intent * @param {boolean} no_cache */ - async function _load({ route, intent: { id, url, path } }, no_cache) { + async function _load(route, { id, url, path }, no_cache) { if (!no_cache) { const cached = cache.get(id); if (cached) return cached; diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 62bae55ed766..7709e5e4f302 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -47,11 +47,6 @@ export type NavigationIntent = { url: URL; }; -export type NavigationCandidate = { - route: CSRRoute; - intent: NavigationIntent; -}; - export type NavigationResult = { redirect?: string; state: NavigationState; From c7872686d96a6aaa8e6b1af777f22a0e60cd9d3d Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 12:20:35 -0500 Subject: [PATCH 16/34] rename functions --- packages/kit/src/runtime/client/client.js | 44 +++++++++++------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 13fa9e01dc96..94645b902d74 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -106,7 +106,7 @@ export function create_client({ target, session, base, trailing_slash }) { session_id += 1; const intent = get_navigation_intent(new URL(location.href)); - _update(intent, [], true); + update(intent, [], true); }); ready = true; @@ -180,7 +180,7 @@ export function create_client({ target, session, base, trailing_slash }) { const intent = get_navigation_intent(url); - loading.promise = _get_navigation_result(intent, false); + loading.promise = get_navigation_result(intent, false); loading.id = intent.id; return loading.promise; @@ -192,9 +192,9 @@ export function create_client({ target, session, base, trailing_slash }) { * @param {boolean} no_cache * @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean}} [opts] */ - async function _update(intent, chain, no_cache, opts) { + async function update(intent, chain, no_cache, opts) { const current_token = (token = {}); - let navigation_result = await _get_navigation_result(intent, no_cache); + let navigation_result = await get_navigation_result(intent, no_cache); if (!navigation_result && intent.url.pathname === location.pathname) { navigation_result = await load_root_error_page({ @@ -249,7 +249,7 @@ export function create_client({ target, session, base, trailing_slash }) { root.$set(navigation_result.props); stores.navigating.set(null); } else { - _init(navigation_result); + initialize(navigation_result); } // opts must be passed if we're navigating @@ -312,7 +312,7 @@ export function create_client({ target, session, base, trailing_slash }) { } /** @param {import('./types').NavigationResult} result */ - function _init(result) { + function initialize(result) { current = result.state; const style = document.querySelector('style[data-svelte]'); @@ -338,7 +338,7 @@ export function create_client({ target, session, base, trailing_slash }) { * @param {import('./types').NavigationIntent} intent * @param {boolean} no_cache */ - async function _get_navigation_result(intent, no_cache) { + async function get_navigation_result(intent, no_cache) { if (loading.id === intent.id && loading.promise) { return loading.promise; } @@ -359,7 +359,7 @@ export function create_client({ target, session, base, trailing_slash }) { } } - const result = await _load(route, intent, no_cache); + const result = await load_route(route, intent, no_cache); if (result) return result; } } @@ -375,7 +375,7 @@ export function create_client({ target, session, base, trailing_slash }) { * error?: Error; * }} opts */ - async function _get_navigation_result_from_branch({ url, params, stuff, branch, status, error }) { + async function get_navigation_result_from_branch({ url, params, stuff, branch, status, error }) { const filtered = /** @type {import('./types').BranchNode[] } */ (branch.filter(Boolean)); const redirect = filtered.find((f) => f.loaded && f.loaded.redirect); @@ -460,7 +460,7 @@ export function create_client({ target, session, base, trailing_slash }) { * props?: Record; * }} options */ - async function _load_node({ status, error, module, url, params, stuff, props }) { + async function load_node({ status, error, module, url, params, stuff, props }) { /** @type {import('./types').BranchNode} */ const node = { module, @@ -554,7 +554,7 @@ export function create_client({ target, session, base, trailing_slash }) { * @param {import('./types').NavigationIntent} intent * @param {boolean} no_cache */ - async function _load(route, { id, url, path }, no_cache) { + async function load_route(route, { id, url, path }, no_cache) { if (!no_cache) { const cached = cache.get(id); if (cached) return cached; @@ -642,7 +642,7 @@ export function create_client({ target, session, base, trailing_slash }) { } if (!error) { - node = await _load_node({ + node = await load_node({ module, url, params, @@ -699,7 +699,7 @@ export function create_client({ target, session, base, trailing_slash }) { } try { - error_loaded = await _load_node({ + error_loaded = await load_node({ status, error, module: await b[i](), @@ -744,7 +744,7 @@ export function create_client({ target, session, base, trailing_slash }) { } } - return await _get_navigation_result_from_branch({ + return await get_navigation_result_from_branch({ url, params, stuff, @@ -765,14 +765,14 @@ export function create_client({ target, session, base, trailing_slash }) { /** @type {Record} */ const params = {}; // error page does not have params - const root_layout = await _load_node({ + const root_layout = await load_node({ module: await fallback[0], url, params, stuff: {} }); - const root_error = await _load_node({ + const root_error = await load_node({ status, error, module: await fallback[1], @@ -781,7 +781,7 @@ export function create_client({ target, session, base, trailing_slash }) { stuff: (root_layout && root_layout.loaded && root_layout.loaded.stuff) || {} }); - return await _get_navigation_result_from_branch({ + return await get_navigation_result_from_branch({ url, params, stuff: { @@ -872,7 +872,7 @@ export function create_client({ target, session, base, trailing_slash }) { }); } - await _update(intent, chain, false, { + await update(intent, chain, false, { scroll, keepfocus }); @@ -939,7 +939,7 @@ export function create_client({ target, session, base, trailing_slash }) { if (!invalidating) { invalidating = Promise.resolve().then(async () => { const intent = get_navigation_intent(new URL(location.href)); - await _update(intent, [], true); + await update(intent, [], true); invalidating = null; }); @@ -1164,7 +1164,7 @@ export function create_client({ target, session, base, trailing_slash }) { } } - const node = await _load_node({ + const node = await load_node({ module: await nodes[i], url, params, @@ -1200,7 +1200,7 @@ export function create_client({ target, session, base, trailing_slash }) { result = error_args ? await load_root_error_page(error_args) - : await _get_navigation_result_from_branch({ + : await get_navigation_result_from_branch({ url, params, stuff, @@ -1225,7 +1225,7 @@ export function create_client({ target, session, base, trailing_slash }) { return; } - _init(result); + initialize(result); } }; } From 73cd1a399d938a329a5156f088d148d811279439 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 12:21:41 -0500 Subject: [PATCH 17/34] chain -> redirect_chain --- packages/kit/src/runtime/client/client.js | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 94645b902d74..6e4f2e8591db 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -142,12 +142,12 @@ export function create_client({ target, session, base, trailing_slash }) { /** * @param {string} href * @param {{ noscroll?: boolean; replaceState?: boolean; keepfocus?: boolean; state?: any }} opts - * @param {string[]} chain + * @param {string[]} redirect_chain */ async function goto( href, { noscroll = false, replaceState = false, keepfocus = false, state = {} }, - chain + redirect_chain ) { const url = new URL(href, get_base_uri(document)); @@ -156,7 +156,7 @@ export function create_client({ target, session, base, trailing_slash }) { url, scroll: noscroll ? scroll_state() : null, keepfocus, - chain, + redirect_chain, details: { state, replaceState @@ -188,11 +188,11 @@ export function create_client({ target, session, base, trailing_slash }) { /** * @param {import('./types').NavigationIntent} intent - * @param {string[]} chain + * @param {string[]} redirect_chain * @param {boolean} no_cache * @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean}} [opts] */ - async function update(intent, chain, no_cache, opts) { + async function update(intent, redirect_chain, no_cache, opts) { const current_token = (token = {}); let navigation_result = await get_navigation_result(intent, no_cache); @@ -215,7 +215,7 @@ export function create_client({ target, session, base, trailing_slash }) { invalid.clear(); if (navigation_result.redirect) { - if (chain.length > 10 || chain.includes(intent.url.pathname)) { + if (redirect_chain.length > 10 || redirect_chain.includes(intent.url.pathname)) { navigation_result = await load_root_error_page({ status: 500, error: new Error('Redirect loop'), @@ -224,7 +224,7 @@ export function create_client({ target, session, base, trailing_slash }) { } else { if (enabled) { goto(new URL(navigation_result.redirect, intent.url).href, {}, [ - ...chain, + ...redirect_chain, intent.url.pathname ]); } else { @@ -819,7 +819,7 @@ export function create_client({ target, session, base, trailing_slash }) { * url: URL; * scroll: { x: number, y: number } | null; * keepfocus: boolean; - * chain: string[]; + * redirect_chain: string[]; * details: { * replaceState: boolean; * state: any; @@ -828,7 +828,7 @@ export function create_client({ target, session, base, trailing_slash }) { * blocked: () => void; * }} opts */ - async function _navigate({ url, scroll, keepfocus, chain, details, accepted, blocked }) { + async function _navigate({ url, scroll, keepfocus, redirect_chain, details, accepted, blocked }) { const from = current.url; let should_block = false; @@ -872,7 +872,7 @@ export function create_client({ target, session, base, trailing_slash }) { }); } - await update(intent, chain, false, { + await update(intent, redirect_chain, false, { scroll, keepfocus }); @@ -1090,7 +1090,7 @@ export function create_client({ target, session, base, trailing_slash }) { url, scroll: a.hasAttribute('sveltekit:noscroll') ? scroll_state() : null, keepfocus: false, - chain: [], + redirect_chain: [], details: { state: {}, replaceState: false @@ -1110,7 +1110,7 @@ export function create_client({ target, session, base, trailing_slash }) { url: new URL(location.href), scroll: scroll_positions[event.state[INDEX_KEY]], keepfocus: false, - chain: [], + redirect_chain: [], details: null, accepted: () => { current_history_index = event.state[INDEX_KEY]; From 72cd8c11105c49bb3ded348ae9d067b837ccfcb1 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 12:23:03 -0500 Subject: [PATCH 18/34] enabled -> router_enabled --- packages/kit/src/runtime/client/client.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 6e4f2e8591db..855b8b475d44 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -113,7 +113,7 @@ export function create_client({ target, session, base, trailing_slash }) { /** Keeps tracks of multiple navigations caused by redirects during rendering */ let navigating = 0; - let enabled = true; + let router_enabled = true; // keeping track of the history index in order to prevent popstate navigation events if needed let current_history_index = history.state?.[INDEX_KEY] ?? 0; @@ -151,7 +151,7 @@ export function create_client({ target, session, base, trailing_slash }) { ) { const url = new URL(href, get_base_uri(document)); - if (enabled) { + if (router_enabled) { return _navigate({ url, scroll: noscroll ? scroll_state() : null, @@ -222,7 +222,7 @@ export function create_client({ target, session, base, trailing_slash }) { url: intent.url }); } else { - if (enabled) { + if (router_enabled) { goto(new URL(navigation_result.redirect, intent.url).href, {}, [ ...redirect_chain, intent.url.pathname @@ -308,7 +308,7 @@ export function create_client({ target, session, base, trailing_slash }) { } const leaf_node = navigation_result.state.branch[navigation_result.state.branch.length - 1]; - enabled = leaf_node?.module.router !== false; + router_enabled = leaf_node?.module.router !== false; } /** @param {import('./types').NavigationResult} result */ @@ -328,7 +328,7 @@ export function create_client({ target, session, base, trailing_slash }) { started = true; - if (enabled) { + if (router_enabled) { const navigation = { from: null, to: new URL(location.href) }; callbacks.after_navigate.forEach((fn) => fn(navigation)); } @@ -1031,7 +1031,7 @@ export function create_client({ target, session, base, trailing_slash }) { /** @param {MouseEvent} event */ addEventListener('click', (event) => { - if (!enabled) return; + if (!router_enabled) return; // Adapted from https://github.com/visionmedia/page.js // MIT license https://github.com/visionmedia/page.js#license @@ -1101,7 +1101,7 @@ export function create_client({ target, session, base, trailing_slash }) { }); addEventListener('popstate', (event) => { - if (event.state && enabled) { + if (event.state && router_enabled) { // if a popstate-driven navigation is cancelled, we need to counteract it // with history.go, which means we end up back here, hence this check if (event.state[INDEX_KEY] === current_history_index) return; From f850a0b0710713470e2f2a0f18d535d7f345d728 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 12:31:34 -0500 Subject: [PATCH 19/34] _navigate -> navigate --- packages/kit/src/runtime/client/client.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 855b8b475d44..855bb4230746 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -152,7 +152,7 @@ export function create_client({ target, session, base, trailing_slash }) { const url = new URL(href, get_base_uri(document)); if (router_enabled) { - return _navigate({ + return navigate({ url, scroll: noscroll ? scroll_state() : null, keepfocus, @@ -828,7 +828,7 @@ export function create_client({ target, session, base, trailing_slash }) { * blocked: () => void; * }} opts */ - async function _navigate({ url, scroll, keepfocus, redirect_chain, details, accepted, blocked }) { + async function navigate({ url, scroll, keepfocus, redirect_chain, details, accepted, blocked }) { const from = current.url; let should_block = false; @@ -1086,7 +1086,7 @@ export function create_client({ target, session, base, trailing_slash }) { return; } - _navigate({ + navigate({ url, scroll: a.hasAttribute('sveltekit:noscroll') ? scroll_state() : null, keepfocus: false, @@ -1106,7 +1106,7 @@ export function create_client({ target, session, base, trailing_slash }) { // with history.go, which means we end up back here, hence this check if (event.state[INDEX_KEY] === current_history_index) return; - _navigate({ + navigate({ url: new URL(location.href), scroll: scroll_positions[event.state[INDEX_KEY]], keepfocus: false, From b431f69c1c966655332da0b1cbde1f3f986660ee Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 18:07:45 -0500 Subject: [PATCH 20/34] Update packages/kit/src/runtime/client/client.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Maurício Kishi --- packages/kit/src/runtime/client/client.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 855bb4230746..2e18108d26a9 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -69,10 +69,10 @@ export function create_client({ target, session, base, trailing_slash }) { }; const callbacks = { - /** @type {Array<({ from, to, cancel }: { from: URL, to: URL | null, cancel: () => void }) => void>} */ + /** @type {Array<(opts: { from: URL, to: URL | null, cancel: () => void }) => void>} */ before_navigate: [], - /** @type {Array<({ from, to }: { from: URL | null, to: URL }) => void>} */ + /** @type {Array<(opts: { from: URL | null, to: URL }) => void>} */ after_navigate: [] }; From 97587d787633ec9d16ec47cf6057120258b94b11 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 18:07:52 -0500 Subject: [PATCH 21/34] Update packages/kit/src/runtime/client/client.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Maurício Kishi --- packages/kit/src/runtime/client/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 2e18108d26a9..4426befc55ff 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -1062,7 +1062,7 @@ export function create_client({ target, session, base, trailing_slash }) { // 2. 'rel' attribute includes external const rel = (a.getAttribute('rel') || '').split(/\s+/); - if (a.hasAttribute('download') || (rel && rel.includes('external'))) { + if (a.hasAttribute('download') || rel.includes('external')) { return; } From c98a66b8d0a9b2eb1c08b0cd7c9343dd49424608 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 18:08:12 -0500 Subject: [PATCH 22/34] Update packages/kit/src/runtime/client/client.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Maurício Kishi --- packages/kit/src/runtime/client/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 4426befc55ff..d4115820439a 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -733,7 +733,7 @@ export function create_client({ target, session, base, trailing_slash }) { url }); } else { - if (node && node.loaded && node.loaded.stuff) { + if (node?.loaded?.stuff) { stuff = { ...stuff, ...node.loaded.stuff From 6485096be109eeb9edc647ca47c6d981bc2e781c Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 18:08:17 -0500 Subject: [PATCH 23/34] Update packages/kit/src/runtime/client/client.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Maurício Kishi --- packages/kit/src/runtime/client/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index d4115820439a..b860a92c5641 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -708,7 +708,7 @@ export function create_client({ target, session, base, trailing_slash }) { stuff: node_loaded.stuff }); - if (error_loaded && error_loaded.loaded && error_loaded.loaded.error) { + if (error_loaded?.loaded?.error) { continue; } From 1d0434d0c95e5ab20706abaef4498384dcee4d95 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 18:08:26 -0500 Subject: [PATCH 24/34] Update packages/kit/src/runtime/client/client.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Maurício Kishi --- packages/kit/src/runtime/client/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index b860a92c5641..560ab2482dd9 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -712,7 +712,7 @@ export function create_client({ target, session, base, trailing_slash }) { continue; } - if (error_loaded && error_loaded.loaded && error_loaded.loaded.stuff) { + if (error_loaded?.loaded?.loaded.stuff) { stuff = { ...stuff, ...error_loaded.loaded.stuff From 61f1221bc5a1a9682c20454dd6c32b61d55e0f9b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 18:08:31 -0500 Subject: [PATCH 25/34] Update packages/kit/src/runtime/client/client.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Maurício Kishi --- packages/kit/src/runtime/client/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 560ab2482dd9..dae88cb00de9 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -377,7 +377,7 @@ export function create_client({ target, session, base, trailing_slash }) { */ async function get_navigation_result_from_branch({ url, params, stuff, branch, status, error }) { const filtered = /** @type {import('./types').BranchNode[] } */ (branch.filter(Boolean)); - const redirect = filtered.find((f) => f.loaded && f.loaded.redirect); + const redirect = filtered.find((f) => f.loaded?.redirect); /** @type {import('./types').NavigationResult} */ const result = { From e1af2d7ae1b00434919d7c4ddec69f26d481fd2e Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 18:08:39 -0500 Subject: [PATCH 26/34] Update packages/kit/src/runtime/client/client.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Maurício Kishi --- packages/kit/src/runtime/client/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index dae88cb00de9..ee6528d2551f 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -381,7 +381,7 @@ export function create_client({ target, session, base, trailing_slash }) { /** @type {import('./types').NavigationResult} */ const result = { - redirect: redirect && redirect.loaded ? redirect.loaded.redirect : undefined, + redirect: redirect?.loaded.redirect, state: { url, params, From 0a4d5e1cdaa65eb7c8e592a5837fa378acf6485b Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 1 Mar 2022 18:43:00 -0500 Subject: [PATCH 27/34] Update packages/kit/src/runtime/client/client.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Maurício Kishi --- packages/kit/src/runtime/client/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index ee6528d2551f..ebce0ad2cf94 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -712,7 +712,7 @@ export function create_client({ target, session, base, trailing_slash }) { continue; } - if (error_loaded?.loaded?.loaded.stuff) { + if (error_loaded?.loaded?.stuff) { stuff = { ...stuff, ...error_loaded.loaded.stuff From a1fe0a18450e9b2fcf2088deabd0edbb23cdcf4f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 2 Mar 2022 13:44:19 -0500 Subject: [PATCH 28/34] Update packages/kit/src/runtime/client/client.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Maurício Kishi --- packages/kit/src/runtime/client/client.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index ebce0ad2cf94..e0d2edefa767 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -247,7 +247,6 @@ export function create_client({ target, session, base, trailing_slash }) { current = navigation_result.state; root.$set(navigation_result.props); - stores.navigating.set(null); } else { initialize(navigation_result); } From 8ccdf322329e3dd60d430711895643b257523742 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 2 Mar 2022 13:54:07 -0500 Subject: [PATCH 29/34] add traditional_navigation helper --- packages/kit/src/runtime/client/client.js | 43 ++++++++++++----------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index ebce0ad2cf94..e75e20643c10 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -166,10 +166,7 @@ export function create_client({ target, session, base, trailing_slash }) { }); } - location.href = url.href; - return new Promise(() => { - /* never resolves */ - }); + await traditional_navigation(url); } /** @param {URL} url */ @@ -205,8 +202,8 @@ export function create_client({ target, session, base, trailing_slash }) { } if (!navigation_result) { - location.href = intent.url.href; - return; + await traditional_navigation(intent.url); + return; // unnecessary, but TypeScript prefers it this way } // abort if user navigated during update @@ -228,7 +225,7 @@ export function create_client({ target, session, base, trailing_slash }) { intent.url.pathname ]); } else { - location.href = new URL(navigation_result.redirect, location.href).href; + await traditional_navigation(new URL(navigation_result.redirect, location.href)); } return; @@ -236,8 +233,7 @@ export function create_client({ target, session, base, trailing_slash }) { } else if (navigation_result.props?.page?.status >= 400) { const updated = await stores.updated.check(); if (updated) { - location.href = intent.url.href; - return; + await traditional_navigation(intent.url); } } @@ -846,10 +842,7 @@ export function create_client({ target, session, base, trailing_slash }) { } if (!owns(url)) { - location.href = url.href; - return new Promise(() => { - // never resolves - }); + await traditional_navigation(url); } const pathname = normalize_path(url.pathname, trailing_slash); @@ -896,6 +889,17 @@ export function create_client({ target, session, base, trailing_slash }) { } } + /** + * Loads `href` the old-fashioned way, with a full page reload. + * Returns a `Promise` that never resolves (to prevent any + * subsequent work, e.g. history manipulation, from happening) + * @param {URL} url + */ + function traditional_navigation(url) { + location.href = url.href; + return new Promise(() => {}); + } + return { after_navigate: (fn) => { onMount(() => { @@ -1046,11 +1050,6 @@ export function create_client({ target, session, base, trailing_slash }) { const is_svg_a_element = a instanceof SVGAElement; const url = get_href(a); - const url_string = url.toString(); - if (url_string === location.href) { - if (!location.hash) event.preventDefault(); - return; - } // Ignore if url does not have origin (e.g. `mailto:`, `tel:`.) // MEMO: Without this condition, firefox will open mailer twice. @@ -1069,6 +1068,11 @@ export function create_client({ target, session, base, trailing_slash }) { // Ignore if has a target if (is_svg_a_element ? a.target.baseVal : a.target) return; + if (url.href === location.href) { + if (!location.hash) event.preventDefault(); + return; + } + // Check if new url only differs by hash and use the browser default behavior in that case // This will ensure the `hashchange` event is fired // Removing the hash does a full page navigation in the browser, so make sure a hash is present @@ -1221,8 +1225,7 @@ export function create_client({ target, session, base, trailing_slash }) { if (result.redirect) { // this is a real edge case — `load` would need to return // a redirect but only in the browser - location.href = new URL(result.redirect, location.href).href; - return; + await traditional_navigation(new URL(result.redirect, location.href)); } initialize(result); From 7373dd5396d6998749ac1766e359cfa28b523888 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 2 Mar 2022 13:55:49 -0500 Subject: [PATCH 30/34] appease typescript --- packages/kit/src/runtime/client/client.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index fd03899559b6..9fd4d31fc46e 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -376,7 +376,7 @@ export function create_client({ target, session, base, trailing_slash }) { /** @type {import('./types').NavigationResult} */ const result = { - redirect: redirect?.loaded.redirect, + redirect: redirect?.loaded?.redirect, state: { url, params, From 0be3a5fc6cbbf9e48af4689b005c09bc91cbe1ef Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 2 Mar 2022 14:59:53 -0500 Subject: [PATCH 31/34] loading -> load_cache --- packages/kit/src/runtime/client/client.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 9fd4d31fc46e..c19e16dbb3e2 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -63,7 +63,7 @@ export function create_client({ target, session, base, trailing_slash }) { }; /** @type {{id: string | null, promise: Promise | null}} */ - const loading = { + const load_cache = { id: null, promise: null }; @@ -177,10 +177,10 @@ export function create_client({ target, session, base, trailing_slash }) { const intent = get_navigation_intent(url); - loading.promise = get_navigation_result(intent, false); - loading.id = intent.id; + load_cache.promise = get_navigation_result(intent, false); + load_cache.id = intent.id; - return loading.promise; + return load_cache.promise; } /** @@ -293,8 +293,8 @@ export function create_client({ target, session, base, trailing_slash }) { await tick(); } - loading.promise = null; - loading.id = null; + load_cache.promise = null; + load_cache.id = null; autoscroll = true; updating = false; @@ -334,8 +334,8 @@ export function create_client({ target, session, base, trailing_slash }) { * @param {boolean} no_cache */ async function get_navigation_result(intent, no_cache) { - if (loading.id === intent.id && loading.promise) { - return loading.promise; + if (load_cache.id === intent.id && load_cache.promise) { + return load_cache.promise; } for (let i = 0; i < intent.routes.length; i += 1) { From 98ae319bd38fa9255df604608ee537038c70d064 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 2 Mar 2022 15:00:18 -0500 Subject: [PATCH 32/34] invalid -> invalidated --- packages/kit/src/runtime/client/client.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index c19e16dbb3e2..6185d057372e 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -52,7 +52,7 @@ export function create_client({ target, session, base, trailing_slash }) { const cache = new Map(); /** @type {Set} */ - const invalid = new Set(); + const invalidated = new Set(); const stores = { url: notifiable_store({}), @@ -209,7 +209,7 @@ export function create_client({ target, session, base, trailing_slash }) { // abort if user navigated during update if (token !== current_token) return; - invalid.clear(); + invalidated.clear(); if (navigation_result.redirect) { if (redirect_chain.length > 10 || redirect_chain.includes(intent.url.pathname)) { @@ -599,7 +599,7 @@ export function create_client({ target, session, base, trailing_slash }) { (changed.url && previous.uses.url) || changed.params.some((param) => previous.uses.params.has(param)) || (changed.session && previous.uses.session) || - Array.from(previous.uses.dependencies).some((dep) => invalid.has(dep)) || + Array.from(previous.uses.dependencies).some((dep) => invalidated.has(dep)) || (stuff_changed && previous.uses.stuff); if (changed_since_last_render) { @@ -937,7 +937,7 @@ export function create_client({ target, session, base, trailing_slash }) { invalidate: (resource) => { const { href } = new URL(resource, location.href); - invalid.add(href); + invalidated.add(href); if (!invalidating) { invalidating = Promise.resolve().then(async () => { From 3f8758ce6f33ba9f3118130acd4bf78683875dea Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 2 Mar 2022 15:06:16 -0500 Subject: [PATCH 33/34] explanatory comment --- packages/kit/src/runtime/client/client.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 6185d057372e..7fd5e5c5b6de 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -194,6 +194,11 @@ export function create_client({ target, session, base, trailing_slash }) { let navigation_result = await get_navigation_result(intent, no_cache); if (!navigation_result && intent.url.pathname === location.pathname) { + // this could happen in SPA fallback mode if the user navigated to + // `/non-existent-page`. if we fall back to reloading the page, it + // will create an infinite loop. so whereas we normally handle + // unknown routes by going to the server, in this special case + // we render a client-side error page instead navigation_result = await load_root_error_page({ status: 404, error: new Error(`Not found: ${intent.url.pathname}`), From 9c00c58f9e5df634aaad9bbdca3f896f7723c947 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Wed, 2 Mar 2022 15:51:29 -0500 Subject: [PATCH 34/34] traditional_navigation -> native_navigation --- packages/kit/src/runtime/client/client.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 7fd5e5c5b6de..b0aeb102e0be 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -166,7 +166,7 @@ export function create_client({ target, session, base, trailing_slash }) { }); } - await traditional_navigation(url); + await native_navigation(url); } /** @param {URL} url */ @@ -207,7 +207,7 @@ export function create_client({ target, session, base, trailing_slash }) { } if (!navigation_result) { - await traditional_navigation(intent.url); + await native_navigation(intent.url); return; // unnecessary, but TypeScript prefers it this way } @@ -230,7 +230,7 @@ export function create_client({ target, session, base, trailing_slash }) { intent.url.pathname ]); } else { - await traditional_navigation(new URL(navigation_result.redirect, location.href)); + await native_navigation(new URL(navigation_result.redirect, location.href)); } return; @@ -238,7 +238,7 @@ export function create_client({ target, session, base, trailing_slash }) { } else if (navigation_result.props?.page?.status >= 400) { const updated = await stores.updated.check(); if (updated) { - await traditional_navigation(intent.url); + await native_navigation(intent.url); } } @@ -846,7 +846,7 @@ export function create_client({ target, session, base, trailing_slash }) { } if (!owns(url)) { - await traditional_navigation(url); + await native_navigation(url); } const pathname = normalize_path(url.pathname, trailing_slash); @@ -899,7 +899,7 @@ export function create_client({ target, session, base, trailing_slash }) { * subsequent work, e.g. history manipulation, from happening) * @param {URL} url */ - function traditional_navigation(url) { + function native_navigation(url) { location.href = url.href; return new Promise(() => {}); } @@ -1229,7 +1229,7 @@ export function create_client({ target, session, base, trailing_slash }) { if (result.redirect) { // this is a real edge case — `load` would need to return // a redirect but only in the browser - await traditional_navigation(new URL(result.redirect, location.href)); + await native_navigation(new URL(result.redirect, location.href)); } initialize(result);