UNPKG

@sveltejs/kit

Version:

SvelteKit is the fastest way to build Svelte apps

1,841 lines (1,570 loc) 85.6 kB
import { BROWSER, DEV } from 'esm-env'; import { onMount, tick } from 'svelte'; import { decode_params, decode_pathname, strip_hash, make_trackable, normalize_path } from '../../utils/url.js'; import { dev_fetch, initial_fetch, lock_fetch, subsequent_fetch, unlock_fetch } from './fetcher.js'; import { parse, parse_server_route } from './parse.js'; import * as storage from './session-storage.js'; import { find_anchor, resolve_url, get_link_info, get_router_options, is_external_url, origin, scroll_state, notifiable_store, create_updated_store, load_css } from './utils.js'; import { base } from '__sveltekit/paths'; import * as devalue from 'devalue'; import { HISTORY_INDEX, NAVIGATION_INDEX, PRELOAD_PRIORITIES, SCROLL_KEY, STATES_KEY, SNAPSHOT_KEY, PAGE_URL_KEY } from './constants.js'; import { validate_page_exports } from '../../utils/exports.js'; import { compact } from '../../utils/array.js'; import { HttpError, Redirect, SvelteKitError } from '../control.js'; import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM, validate_depends } from '../shared.js'; import { get_message, get_status } from '../../utils/error.js'; import { writable } from 'svelte/store'; import { page, update, navigating } from './state.svelte.js'; import { add_data_suffix, add_resolution_suffix } from '../pathname.js'; export { load_css }; const ICON_REL_ATTRIBUTES = new Set(['icon', 'shortcut icon', 'apple-touch-icon']); let errored = false; // 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 /** * history index -> { x, y } * @type {Record<number, { x: number; y: number }>} */ const scroll_positions = storage.get(SCROLL_KEY) ?? {}; /** * navigation index -> any * @type {Record<string, any[]>} */ const snapshots = storage.get(SNAPSHOT_KEY) ?? {}; if (DEV && BROWSER) { let warned = false; const current_module_url = import.meta.url.split('?')[0]; // remove query params that vite adds to the URL when it is loaded from node_modules const warn = () => { if (warned) return; // Rather than saving a pointer to the original history methods, which would prevent monkeypatching by other libs, // inspect the stack trace to see if we're being called from within SvelteKit. let stack = new Error().stack?.split('\n'); if (!stack) return; if (!stack[0].includes('https:') && !stack[0].includes('http:')) stack = stack.slice(1); // Chrome includes the error message in the stack stack = stack.slice(2); // remove `warn` and the place where `warn` was called // Can be falsy if was called directly from an anonymous function if (stack[0]?.includes(current_module_url)) return; warned = true; console.warn( "Avoid using `history.pushState(...)` and `history.replaceState(...)` as these will conflict with SvelteKit's router. Use the `pushState` and `replaceState` imports from `$app/navigation` instead." ); }; const push_state = history.pushState; history.pushState = (...args) => { warn(); return push_state.apply(history, args); }; const replace_state = history.replaceState; history.replaceState = (...args) => { warn(); return replace_state.apply(history, args); }; } export const stores = { url: /* @__PURE__ */ notifiable_store({}), page: /* @__PURE__ */ notifiable_store({}), navigating: /* @__PURE__ */ writable( /** @type {import('@sveltejs/kit').Navigation | null} */ (null) ), updated: /* @__PURE__ */ create_updated_store() }; /** @param {number} index */ function update_scroll_positions(index) { scroll_positions[index] = scroll_state(); } /** * @param {number} current_history_index * @param {number} current_navigation_index */ function clear_onward_history(current_history_index, current_navigation_index) { // if we navigated back, then pushed a new state, we can // release memory by pruning the scroll/snapshot lookup let i = current_history_index + 1; while (scroll_positions[i]) { delete scroll_positions[i]; i += 1; } i = current_navigation_index + 1; while (snapshots[i]) { delete snapshots[i]; i += 1; } } /** * 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 native_navigation(url) { location.href = url.href; return new Promise(() => {}); } /** * Checks whether a service worker is registered, and if it is, * tries to update it. */ async function update_service_worker() { if ('serviceWorker' in navigator) { const registration = await navigator.serviceWorker.getRegistration(base || '/'); if (registration) { await registration.update(); } } } function noop() {} /** @type {import('types').CSRRoute[]} All routes of the app. Only available when kit.router.resolution=client */ let routes; /** @type {import('types').CSRPageNodeLoader} */ let default_layout_loader; /** @type {import('types').CSRPageNodeLoader} */ let default_error_loader; /** @type {HTMLElement} */ let container; /** @type {HTMLElement} */ let target; /** @type {import('./types.js').SvelteKitApp} */ export let app; /** @type {Array<((url: URL) => boolean)>} */ const invalidated = []; /** * An array of the `+layout.svelte` and `+page.svelte` component instances * that currently live on the page — used for capturing and restoring snapshots. * It's updated/manipulated through `bind:this` in `Root.svelte`. * @type {import('svelte').SvelteComponent[]} */ const components = []; /** @type {{id: string, token: {}, promise: Promise<import('./types.js').NavigationResult>} | null} */ let load_cache = null; /** * @type {Map<string, Promise<URL>>} * Cache for client-side rerouting, since it could contain async calls which we want to * avoid running multiple times which would slow down navigations (e.g. else preloading * wouldn't help because on navigation it would be called again). Since `reroute` should be * a pure function (i.e. always return the same) value it's safe to cache across navigations. * The server reroute calls don't need to be cached because they are called using `import(...)` * which is cached per the JS spec. */ const reroute_cache = new Map(); /** * Note on before_navigate_callbacks, on_navigate_callbacks and after_navigate_callbacks: * do not re-assign as some closures keep references to these Sets */ /** @type {Set<(navigation: import('@sveltejs/kit').BeforeNavigate) => void>} */ const before_navigate_callbacks = new Set(); /** @type {Set<(navigation: import('@sveltejs/kit').OnNavigate) => import('types').MaybePromise<(() => void) | void>>} */ const on_navigate_callbacks = new Set(); /** @type {Set<(navigation: import('@sveltejs/kit').AfterNavigate) => void>} */ const after_navigate_callbacks = new Set(); /** @type {import('./types.js').NavigationState} */ let current = { branch: [], error: null, // @ts-ignore - we need the initial value to be null url: null }; /** this being true means we SSR'd */ let hydrated = false; let started = false; let autoscroll = true; let updating = false; let is_navigating = false; let hash_navigating = false; /** True as soon as there happened one client-side navigation (excluding the SvelteKit-initialized initial one when in SPA mode) */ let has_navigated = false; let force_invalidation = false; /** @type {import('svelte').SvelteComponent} */ let root; /** @type {number} keeping track of the history index in order to prevent popstate navigation events if needed */ let current_history_index; /** @type {number} */ let current_navigation_index; /** @type {{}} */ let token; /** * A set of tokens which are associated to current preloads. * If a preload becomes a real navigation, it's removed from the set. * If a preload token is in the set and the preload errors, the error * handling logic (for example reloading) is skipped. */ const preload_tokens = new Set(); /** @type {Promise<void> | null} */ let pending_invalidate; /** * @param {import('./types.js').SvelteKitApp} _app * @param {HTMLElement} _target * @param {Parameters<typeof _hydrate>[1]} [hydrate] */ export async function start(_app, _target, hydrate) { if (DEV && _target === document.body) { console.warn( 'Placing %sveltekit.body% directly inside <body> is not recommended, as your app may break for users who have certain browser extensions installed.\n\nConsider wrapping it in an element:\n\n<div style="display: contents">\n %sveltekit.body%\n</div>' ); } // detect basic auth credentials in the current URL // https://github.com/sveltejs/kit/pull/11179 // if so, refresh the page without credentials if (document.URL !== location.href) { // eslint-disable-next-line no-self-assign location.href = location.href; } app = _app; await _app.hooks.init?.(); routes = __SVELTEKIT_CLIENT_ROUTING__ ? parse(_app) : []; container = __SVELTEKIT_EMBEDDED__ ? _target : document.documentElement; target = _target; // we import the root layout/error nodes eagerly, so that // connectivity errors after initialisation don't nuke the app default_layout_loader = _app.nodes[0]; default_error_loader = _app.nodes[1]; void default_layout_loader(); void default_error_loader(); current_history_index = history.state?.[HISTORY_INDEX]; current_navigation_index = history.state?.[NAVIGATION_INDEX]; if (!current_history_index) { // we use Date.now() as an offset so that cross-document navigations // within the app don't result in data loss current_history_index = current_navigation_index = Date.now(); // create initial history entry, so we can return here history.replaceState( { ...history.state, [HISTORY_INDEX]: current_history_index, [NAVIGATION_INDEX]: current_navigation_index }, '' ); } // if we reload the page, or Cmd-Shift-T back to it, // recover scroll position const scroll = scroll_positions[current_history_index]; if (scroll) { history.scrollRestoration = 'manual'; scrollTo(scroll.x, scroll.y); } if (hydrate) { await _hydrate(target, hydrate); } else { await navigate({ type: 'enter', url: resolve_url(app.hash ? decode_hash(new URL(location.href)) : location.href), replace_state: true }); } _start_router(); } async function _invalidate() { // Accept all invalidations as they come, don't swallow any while another invalidation // is running because subsequent invalidations may make earlier ones outdated, // but batch multiple synchronous invalidations. await (pending_invalidate ||= Promise.resolve()); if (!pending_invalidate) return; pending_invalidate = null; const nav_token = (token = {}); const intent = await get_navigation_intent(current.url, true); // Clear preload, it might be affected by the invalidation. // Also solves an edge case where a preload is triggered, the navigation for it // was then triggered and is still running while the invalidation kicks in, // at which point the invalidation should take over and "win". load_cache = null; const navigation_result = intent && (await load_route(intent)); if (!navigation_result || nav_token !== token) return; if (navigation_result.type === 'redirect') { return _goto(new URL(navigation_result.location, current.url).href, {}, 1, nav_token); } if (navigation_result.props.page) { Object.assign(page, navigation_result.props.page); } current = navigation_result.state; reset_invalidation(); root.$set(navigation_result.props); update(navigation_result.props.page); } function reset_invalidation() { invalidated.length = 0; force_invalidation = false; } /** @param {number} index */ function capture_snapshot(index) { if (components.some((c) => c?.snapshot)) { snapshots[index] = components.map((c) => c?.snapshot?.capture()); } } /** @param {number} index */ function restore_snapshot(index) { snapshots[index]?.forEach((value, i) => { components[i]?.snapshot?.restore(value); }); } function persist_state() { update_scroll_positions(current_history_index); storage.set(SCROLL_KEY, scroll_positions); capture_snapshot(current_navigation_index); storage.set(SNAPSHOT_KEY, snapshots); } /** * @param {string | URL} url * @param {{ replaceState?: boolean; noScroll?: boolean; keepFocus?: boolean; invalidateAll?: boolean; invalidate?: Array<string | URL | ((url: URL) => boolean)>; state?: Record<string, any> }} options * @param {number} redirect_count * @param {{}} [nav_token] */ async function _goto(url, options, redirect_count, nav_token) { return navigate({ type: 'goto', url: resolve_url(url), keepfocus: options.keepFocus, noscroll: options.noScroll, replace_state: options.replaceState, state: options.state, redirect_count, nav_token, accept: () => { if (options.invalidateAll) { force_invalidation = true; } if (options.invalidate) { options.invalidate.forEach(push_invalidated); } } }); } /** @param {import('./types.js').NavigationIntent} intent */ async function _preload_data(intent) { // Reuse the existing pending preload if it's for the same navigation. // Prevents an edge case where same preload is triggered multiple times, // then a later one is becoming the real navigation and the preload tokens // get out of sync. if (intent.id !== load_cache?.id) { const preload = {}; preload_tokens.add(preload); load_cache = { id: intent.id, token: preload, promise: load_route({ ...intent, preload }).then((result) => { preload_tokens.delete(preload); if (result.type === 'loaded' && result.state.error) { // Don't cache errors, because they might be transient load_cache = null; } return result; }) }; } return load_cache.promise; } /** * @param {URL} url * @returns {Promise<void>} */ async function _preload_code(url) { const route = (await get_navigation_intent(url, false))?.route; if (route) { await Promise.all([...route.layouts, route.leaf].map((load) => load?.[1]())); } } /** * @param {import('./types.js').NavigationFinished} result * @param {HTMLElement} target * @param {boolean} hydrate */ function initialize(result, target, hydrate) { if (DEV && result.state.error && document.querySelector('vite-error-overlay')) return; current = result.state; const style = document.querySelector('style[data-sveltekit]'); if (style) style.remove(); Object.assign(page, /** @type {import('@sveltejs/kit').Page} */ (result.props.page)); root = new app.root({ target, props: { ...result.props, stores, components }, hydrate, // @ts-ignore Svelte 5 specific: asynchronously instantiate the component, i.e. don't call flushSync sync: false }); restore_snapshot(current_navigation_index); if (hydrate) { /** @type {import('@sveltejs/kit').AfterNavigate} */ const navigation = { from: null, to: { params: current.params, route: { id: current.route?.id ?? null }, url: new URL(location.href) }, willUnload: false, type: 'enter', complete: Promise.resolve() }; after_navigate_callbacks.forEach((fn) => fn(navigation)); } started = true; } /** * * @param {{ * url: URL; * params: Record<string, string>; * branch: Array<import('./types.js').BranchNode | undefined>; * status: number; * error: App.Error | null; * route: import('types').CSRRoute | null; * form?: Record<string, any> | null; * }} opts */ function get_navigation_result_from_branch({ url, params, branch, status, error, route, form }) { /** @type {import('types').TrailingSlash} */ let slash = 'never'; // if `paths.base === '/a/b/c`, then the root route is always `/a/b/c/`, regardless of // the `trailingSlash` route option, so that relative paths to JS and CSS work if (base && (url.pathname === base || url.pathname === base + '/')) { slash = 'always'; } else { for (const node of branch) { if (node?.slash !== undefined) slash = node.slash; } } url.pathname = normalize_path(url.pathname, slash); // eslint-disable-next-line url.search = url.search; // turn `/?` into `/` /** @type {import('./types.js').NavigationFinished} */ const result = { type: 'loaded', state: { url, params, branch, error, route }, props: { // @ts-ignore Somehow it's getting SvelteComponent and SvelteComponentDev mixed up constructors: compact(branch).map((branch_node) => branch_node.node.component), page: clone_page(page) } }; if (form !== undefined) { result.props.form = form; } let data = {}; let data_changed = !page; let p = 0; for (let i = 0; i < Math.max(branch.length, current.branch.length); i += 1) { const node = branch[i]; const prev = current.branch[i]; if (node?.data !== prev?.data) data_changed = true; if (!node) continue; data = { ...data, ...node.data }; // Only set props if the node actually updated. This prevents needless rerenders. if (data_changed) { result.props[`data_${p}`] = data; } p += 1; } const page_changed = !current.url || url.href !== current.url.href || current.error !== error || (form !== undefined && form !== page.form) || data_changed; if (page_changed) { result.props.page = { error, params, route: { id: route?.id ?? null }, state: {}, status, url: new URL(url), form: form ?? null, // The whole page store is updated, but this way the object reference stays the same data: data_changed ? data : page.data }; } return result; } /** * Call the universal load function of the given node, if it exists. * * @param {{ * loader: import('types').CSRPageNodeLoader; * parent: () => Promise<Record<string, any>>; * url: URL; * params: Record<string, string>; * route: { id: string | null }; * server_data_node: import('./types.js').DataNode | null; * }} options * @returns {Promise<import('./types.js').BranchNode>} */ async function load_node({ loader, parent, url, params, route, server_data_node }) { /** @type {Record<string, any> | null} */ let data = null; let is_tracking = true; /** @type {import('types').Uses} */ const uses = { dependencies: new Set(), params: new Set(), parent: false, route: false, url: false, search_params: new Set() }; const node = await loader(); if (DEV) { validate_page_exports(node.universal); if (node.universal && app.hash) { const options = Object.keys(node.universal).filter((o) => o !== 'load'); if (options.length > 0) { throw new Error( `Page options are ignored when \`router.type === 'hash'\` (${route.id} has ${options .filter((o) => o !== 'load') .map((o) => `'${o}'`) .join(', ')})` ); } } } if (node.universal?.load) { /** @param {string[]} deps */ function depends(...deps) { for (const dep of deps) { if (DEV) validate_depends(/** @type {string} */ (route.id), dep); const { href } = new URL(dep, url); uses.dependencies.add(href); } } /** @type {import('@sveltejs/kit').LoadEvent} */ const load_input = { route: new Proxy(route, { get: (target, key) => { if (is_tracking) { uses.route = true; } return target[/** @type {'id'} */ (key)]; } }), params: new Proxy(params, { get: (target, key) => { if (is_tracking) { uses.params.add(/** @type {string} */ (key)); } return target[/** @type {string} */ (key)]; } }), data: server_data_node?.data ?? null, url: make_trackable( url, () => { if (is_tracking) { uses.url = true; } }, (param) => { if (is_tracking) { uses.search_params.add(param); } }, app.hash ), async fetch(resource, init) { if (resource instanceof Request) { // we're not allowed to modify the received `Request` object, so in order // to fixup relative urls we create a new equivalent `init` object instead init = { // the request body must be consumed in memory until browsers // implement streaming request bodies and/or the body getter body: resource.method === 'GET' || resource.method === 'HEAD' ? undefined : await resource.blob(), cache: resource.cache, credentials: resource.credentials, // the headers are undefined on the server if the Headers object is empty // so we need to make sure they are also undefined here if there are no headers headers: [...resource.headers].length ? resource.headers : undefined, integrity: resource.integrity, keepalive: resource.keepalive, method: resource.method, mode: resource.mode, redirect: resource.redirect, referrer: resource.referrer, referrerPolicy: resource.referrerPolicy, signal: resource.signal, ...init }; } const { resolved, promise } = resolve_fetch_url(resource, init, url); if (is_tracking) { depends(resolved.href); } return promise; }, setHeaders: () => {}, // noop depends, parent() { if (is_tracking) { uses.parent = true; } return parent(); }, untrack(fn) { is_tracking = false; try { return fn(); } finally { is_tracking = true; } } }; if (DEV) { try { lock_fetch(); data = (await node.universal.load.call(null, load_input)) ?? null; if (data != null && Object.getPrototypeOf(data) !== Object.prototype) { throw new Error( `a load function related to route '${route.id}' returned ${ typeof data !== 'object' ? `a ${typeof data}` : data instanceof Response ? 'a Response object' : Array.isArray(data) ? 'an array' : 'a non-plain object' }, but must return a plain object at the top level (i.e. \`return {...}\`)` ); } } finally { unlock_fetch(); } } else { data = (await node.universal.load.call(null, load_input)) ?? null; } } return { node, loader, server: server_data_node, universal: node.universal?.load ? { type: 'data', data, uses } : null, data: data ?? server_data_node?.data ?? null, slash: node.universal?.trailingSlash ?? server_data_node?.slash }; } /** * @param {Request | string | URL} input * @param {RequestInit | undefined} init * @param {URL} url */ function resolve_fetch_url(input, init, url) { let requested = input instanceof Request ? input.url : input; // we must fixup relative urls so they are resolved from the target page const resolved = new URL(requested, url); // match ssr serialized data url, which is important to find cached responses if (resolved.origin === url.origin) { requested = resolved.href.slice(url.origin.length); } // prerendered pages may be served from any origin, so `initial_fetch` urls shouldn't be resolved const promise = started ? subsequent_fetch(requested, resolved.href, init) : initial_fetch(requested, init); return { resolved, promise }; } /** * @param {boolean} parent_changed * @param {boolean} route_changed * @param {boolean} url_changed * @param {Set<string>} search_params_changed * @param {import('types').Uses | undefined} uses * @param {Record<string, string>} params */ function has_changed( parent_changed, route_changed, url_changed, search_params_changed, uses, params ) { if (force_invalidation) return true; if (!uses) return false; if (uses.parent && parent_changed) return true; if (uses.route && route_changed) return true; if (uses.url && url_changed) return true; for (const tracked_params of uses.search_params) { if (search_params_changed.has(tracked_params)) return true; } for (const param of uses.params) { if (params[param] !== current.params[param]) return true; } for (const href of uses.dependencies) { if (invalidated.some((fn) => fn(new URL(href)))) return true; } return false; } /** * @param {import('types').ServerDataNode | import('types').ServerDataSkippedNode | null} node * @param {import('./types.js').DataNode | null} [previous] * @returns {import('./types.js').DataNode | null} */ function create_data_node(node, previous) { if (node?.type === 'data') return node; if (node?.type === 'skip') return previous ?? null; return null; } /** * @param {URL | null} old_url * @param {URL} new_url */ function diff_search_params(old_url, new_url) { if (!old_url) return new Set(new_url.searchParams.keys()); const changed = new Set([...old_url.searchParams.keys(), ...new_url.searchParams.keys()]); for (const key of changed) { const old_values = old_url.searchParams.getAll(key); const new_values = new_url.searchParams.getAll(key); if ( old_values.every((value) => new_values.includes(value)) && new_values.every((value) => old_values.includes(value)) ) { changed.delete(key); } } return changed; } /** * @param {Omit<import('./types.js').NavigationFinished['state'], 'branch'> & { error: App.Error }} opts * @returns {import('./types.js').NavigationFinished} */ function preload_error({ error, url, route, params }) { return { type: 'loaded', state: { error, url, route, params, branch: [] }, props: { page: clone_page(page), constructors: [] } }; } /** * @param {import('./types.js').NavigationIntent & { preload?: {} }} intent * @returns {Promise<import('./types.js').NavigationResult>} */ async function load_route({ id, invalidating, url, params, route, preload }) { if (load_cache?.id === id) { // the preload becomes the real navigation preload_tokens.delete(load_cache.token); return load_cache.promise; } const { errors, layouts, leaf } = route; const loaders = [...layouts, leaf]; // preload modules to avoid waterfall, but handle rejections // so they don't get reported to Sentry et al (we don't need // to act on the failures at this point) errors.forEach((loader) => loader?.().catch(() => {})); loaders.forEach((loader) => loader?.[1]().catch(() => {})); /** @type {import('types').ServerNodesResponse | import('types').ServerRedirectNode | null} */ let server_data = null; const url_changed = current.url ? id !== get_page_key(current.url) : false; const route_changed = current.route ? route.id !== current.route.id : false; const search_params_changed = diff_search_params(current.url, url); let parent_invalid = false; const invalid_server_nodes = loaders.map((loader, i) => { const previous = current.branch[i]; const invalid = !!loader?.[0] && (previous?.loader !== loader[1] || has_changed( parent_invalid, route_changed, url_changed, search_params_changed, previous.server?.uses, params )); if (invalid) { // For the next one parent_invalid = true; } return invalid; }); if (invalid_server_nodes.some(Boolean)) { try { server_data = await load_data(url, invalid_server_nodes); } catch (error) { const handled_error = await handle_error(error, { url, params, route: { id } }); if (preload_tokens.has(preload)) { return preload_error({ error: handled_error, url, params, route }); } return load_root_error_page({ status: get_status(error), error: handled_error, url, route }); } if (server_data.type === 'redirect') { return server_data; } } const server_data_nodes = server_data?.nodes; let parent_changed = false; const branch_promises = loaders.map(async (loader, i) => { if (!loader) return; /** @type {import('./types.js').BranchNode | undefined} */ const previous = current.branch[i]; const server_data_node = server_data_nodes?.[i]; // re-use data from previous load if it's still valid const valid = (!server_data_node || server_data_node.type === 'skip') && loader[1] === previous?.loader && !has_changed( parent_changed, route_changed, url_changed, search_params_changed, previous.universal?.uses, params ); if (valid) return previous; parent_changed = true; if (server_data_node?.type === 'error') { // rethrow and catch below throw server_data_node; } return load_node({ loader: loader[1], url, params, route, parent: async () => { const data = {}; for (let j = 0; j < i; j += 1) { Object.assign(data, (await branch_promises[j])?.data); } return data; }, server_data_node: create_data_node( // server_data_node is undefined if it wasn't reloaded from the server; // and if current loader uses server data, we want to reuse previous data. server_data_node === undefined && loader[0] ? { type: 'skip' } : (server_data_node ?? null), loader[0] ? previous?.server : undefined ) }); }); // if we don't do this, rejections will be unhandled for (const p of branch_promises) p.catch(() => {}); /** @type {Array<import('./types.js').BranchNode | undefined>} */ const branch = []; for (let i = 0; i < loaders.length; i += 1) { if (loaders[i]) { try { branch.push(await branch_promises[i]); } catch (err) { if (err instanceof Redirect) { return { type: 'redirect', location: err.location }; } if (preload_tokens.has(preload)) { return preload_error({ error: await handle_error(err, { params, url, route: { id: route.id } }), url, params, route }); } let status = get_status(err); /** @type {App.Error} */ let error; if (server_data_nodes?.includes(/** @type {import('types').ServerErrorNode} */ (err))) { // this is the server error rethrown above, reconstruct but don't invoke // the client error handler; it should've already been handled on the server status = /** @type {import('types').ServerErrorNode} */ (err).status ?? status; error = /** @type {import('types').ServerErrorNode} */ (err).error; } else if (err instanceof HttpError) { error = err.body; } else { // Referenced node could have been removed due to redeploy, check const updated = await stores.updated.check(); if (updated) { // Before reloading, try to update the service worker if it exists await update_service_worker(); return await native_navigation(url); } error = await handle_error(err, { params, url, route: { id: route.id } }); } const error_load = await load_nearest_error_page(i, branch, errors); if (error_load) { return get_navigation_result_from_branch({ url, params, branch: branch.slice(0, error_load.idx).concat(error_load.node), status, error, route }); } else { return await server_fallback(url, { id: route.id }, error, status); } } } else { // push an empty slot so we can rewind past gaps to the // layout that corresponds with an +error.svelte page branch.push(undefined); } } return get_navigation_result_from_branch({ url, params, branch, status: 200, error: null, route, // Reset `form` on navigation, but not invalidation form: invalidating ? undefined : null }); } /** * @param {number} i Start index to backtrack from * @param {Array<import('./types.js').BranchNode | undefined>} branch Branch to backtrack * @param {Array<import('types').CSRPageNodeLoader | undefined>} errors All error pages for this branch * @returns {Promise<{idx: number; node: import('./types.js').BranchNode} | undefined>} */ async function load_nearest_error_page(i, branch, errors) { while (i--) { if (errors[i]) { let j = i; while (!branch[j]) j -= 1; try { return { idx: j + 1, node: { node: await /** @type {import('types').CSRPageNodeLoader } */ (errors[i])(), loader: /** @type {import('types').CSRPageNodeLoader } */ (errors[i]), data: {}, server: null, universal: null } }; } catch { continue; } } } } /** * @param {{ * status: number; * error: App.Error; * url: URL; * route: { id: string | null } * }} opts * @returns {Promise<import('./types.js').NavigationFinished>} */ async function load_root_error_page({ status, error, url, route }) { /** @type {Record<string, string>} */ const params = {}; // error page does not have params /** @type {import('types').ServerDataNode | null} */ let server_data_node = null; const default_layout_has_server_load = app.server_loads[0] === 0; if (default_layout_has_server_load) { // TODO post-https://github.com/sveltejs/kit/discussions/6124 we can use // existing root layout data try { const server_data = await load_data(url, [true]); if ( server_data.type !== 'data' || (server_data.nodes[0] && server_data.nodes[0].type !== 'data') ) { throw 0; } server_data_node = server_data.nodes[0] ?? null; } catch { // at this point we have no choice but to fall back to the server, if it wouldn't // bring us right back here, turning this into an endless loop if (url.origin !== origin || url.pathname !== location.pathname || hydrated) { await native_navigation(url); } } } try { const root_layout = await load_node({ loader: default_layout_loader, url, params, route, parent: () => Promise.resolve({}), server_data_node: create_data_node(server_data_node) }); /** @type {import('./types.js').BranchNode} */ const root_error = { node: await default_error_loader(), loader: default_error_loader, universal: null, server: null, data: null }; return get_navigation_result_from_branch({ url, params, branch: [root_layout, root_error], status, error, route: null }); } catch (error) { if (error instanceof Redirect) { return _goto(new URL(error.location, location.href), {}, 0); } // TODO: this falls back to the server when a server exists, but what about SPA mode? throw error; } } /** * Resolve the relative rerouted URL for a client-side navigation * @param {URL} url * @returns {Promise<URL | undefined>} */ async function get_rerouted_url(url) { const href = url.href; if (reroute_cache.has(href)) { return reroute_cache.get(href); } let rerouted; try { const promise = (async () => { // reroute could alter the given URL, so we pass a copy let rerouted = (await app.hooks.reroute({ url: new URL(url), fetch: async (input, init) => { return resolve_fetch_url(input, init, url).promise; } })) ?? url; if (typeof rerouted === 'string') { const tmp = new URL(url); // do not mutate the incoming URL if (app.hash) { tmp.hash = rerouted; } else { tmp.pathname = rerouted; } rerouted = tmp; } return rerouted; })(); reroute_cache.set(href, promise); rerouted = await promise; } catch (e) { reroute_cache.delete(href); if (DEV) { // in development, print the error... console.error(e); // ...and pause execution, since otherwise we will immediately reload the page debugger; // eslint-disable-line } // fall back to native navigation return; } return rerouted; } /** * Resolve the full info (which route, params, etc.) for a client-side navigation from the URL, * taking the reroute hook into account. If this isn't a client-side-navigation (or the URL is undefined), * returns undefined. * @param {URL | undefined} url * @param {boolean} invalidating * @returns {Promise<import('./types.js').NavigationIntent | undefined>} */ async function get_navigation_intent(url, invalidating) { if (!url) return; if (is_external_url(url, base, app.hash)) return; if (__SVELTEKIT_CLIENT_ROUTING__) { const rerouted = await get_rerouted_url(url); if (!rerouted) return; const path = get_url_path(rerouted); for (const route of routes) { const params = route.exec(path); if (params) { return { id: get_page_key(url), invalidating, route, params: decode_params(params), url }; } } } else { /** @type {{ route?: import('types').CSRRouteServer, params: Record<string, string>}} */ const { route, params } = await import( /* @vite-ignore */ add_resolution_suffix(url.pathname) ); if (!route) return; return { id: get_page_key(url), invalidating, route: parse_server_route(route, app.nodes), params, url }; } } /** @param {URL} url */ function get_url_path(url) { return ( decode_pathname( app.hash ? url.hash.replace(/^#/, '').replace(/[?#].+/, '') : url.pathname.slice(base.length) ) || '/' ); } /** @param {URL} url */ function get_page_key(url) { return (app.hash ? url.hash.replace(/^#/, '') : url.pathname) + url.search; } /** * @param {{ * url: URL; * type: import('@sveltejs/kit').Navigation["type"]; * intent?: import('./types.js').NavigationIntent; * delta?: number; * }} opts */ function _before_navigate({ url, type, intent, delta }) { let should_block = false; const nav = create_navigation(current, intent, url, type); if (delta !== undefined) { nav.navigation.delta = delta; } const cancellable = { ...nav.navigation, cancel: () => { should_block = true; nav.reject(new Error('navigation cancelled')); } }; if (!is_navigating) { // Don't run the event during redirects before_navigate_callbacks.forEach((fn) => fn(cancellable)); } return should_block ? null : nav; } /** * @param {{ * type: import('@sveltejs/kit').NavigationType; * url: URL; * popped?: { * state: Record<string, any>; * scroll: { x: number, y: number }; * delta: number; * }; * keepfocus?: boolean; * noscroll?: boolean; * replace_state?: boolean; * state?: Record<string, any>; * redirect_count?: number; * nav_token?: {}; * accept?: () => void; * block?: () => void; * }} opts */ async function navigate({ type, url, popped, keepfocus, noscroll, replace_state, state = {}, redirect_count = 0, nav_token = {}, accept = noop, block = noop }) { const prev_token = token; token = nav_token; const intent = await get_navigation_intent(url, false); const nav = type === 'enter' ? create_navigation(current, intent, url, type) : _before_navigate({ url, type, delta: popped?.delta, intent }); if (!nav) { block(); if (token === nav_token) token = prev_token; return; } // store this before calling `accept()`, which may change the index const previous_history_index = current_history_index; const previous_navigation_index = current_navigation_index; accept(); is_navigating = true; if (started && nav.navigation.type !== 'enter') { stores.navigating.set((navigating.current = nav.navigation)); } let navigation_result = intent && (await load_route(intent)); if (!navigation_result) { if (is_external_url(url, base, app.hash)) { if (DEV && app.hash) { // Special case for hash mode during DEV: If someone accidentally forgets to use a hash for the link, // they would end up here in an endless loop. Fall back to error page in that case navigation_result = await server_fallback( url, { id: null }, await handle_error( new SvelteKitError( 404, 'Not Found', `Not found: ${url.pathname} (did you forget the hash?)` ), { url, params: {}, route: { id: null } } ), 404 ); } else { return await native_navigation(url); } } else { navigation_result = await server_fallback( url, { id: null }, await handle_error(new SvelteKitError(404, 'Not Found', `Not found: ${url.pathname}`), { url, params: {}, route: { id: null } }), 404 ); } } // if this is an internal navigation intent, use the normalized // URL for the rest of the function url = intent?.url || url; // abort if user navigated during update if (token !== nav_token) { nav.reject(new Error('navigation aborted')); return false; } if (navigation_result.type === 'redirect') { // whatwg fetch spec https://fetch.spec.whatwg.org/#http-redirect-fetch says to error after 20 redirects if (redirect_count >= 20) { navigation_result = await load_root_error_page({ status: 500, error: await handle_error(new Error('Redirect loop'), { url, params: {}, route: { id: null } }), url, route: { id: null } }); } else { await _goto(new URL(navigation_result.location, url).href, {}, redirect_count + 1, nav_token); return false; } } else if (/** @type {number} */ (navigation_result.props.page.status) >= 400) { const updated = await stores.updated.check(); if (updated) { // Before reloading, try to update the service worker if it exists await update_service_worker(); await native_navigation(url); } } // reset invalidation only after a finished navigation. If there are redirects or // additional invalidations, they should get the same invalidation treatment reset_invalidation(); updating = true; update_scroll_positions(previous_history_index); capture_snapshot(previous_navigation_index); // ensure the url pathname matches the page's trailing slash option if (navigation_result.props.page.url.pathname !== url.pathname) { url.pathname = navigation_result.props.page.url.pathname; } state = popped ? popped.state : state; if (!popped) { // this is a new navigation, rather than a popstate const change = replace_state ? 0 : 1; const entry = { [HISTORY_INDEX]: (current_history_index += change), [NAVIGATION_INDEX]: (current_navigation_index += change), [STATES_KEY]: state }; const fn = replace_state ? history.replaceState : history.pushState; fn.call(history, entry, '', url); if (!replace_state) { clear_onward_history(current_history_index, current_navigation_index); } } // reset preload synchronously after the history state has been set to avoid race conditions load_cache = null; navigation_result.props.page.state = state; if (started) { current = navigation_result.state; // reset url before updating page store if (navigation_result.props.page) { navigation_result.props.page.url = url; } const after_navigate = ( await Promise.all( Array.from(on_navigate_callbacks, (fn) => fn(/** @type {import('@sveltejs/kit').OnNavigate} */ (nav.navigation)) ) ) ).filter(/** @returns {value is () => void} */ (value) => typeof value === 'function'); if (after_navigate.length > 0) { function cleanup() { after_navigate.forEach((fn) => { after_navigate_callbacks.delete(fn); }); } after_navigate.push(cleanup); after_navigate.forEach((fn) => { after_navigate_callbacks.add(fn); }); } root.$set(navigation_result.props); update(navigation_result.props.page); has_navigated = true; } else { initialize(navigation_result, target, false); } const { activeElement } = document; // need to render the DOM before we can scroll to the rendered elements and do focus management await tick(); // we reset scroll before dealing with focus, to avoid a flash of unscrolled content const scroll = popped ? popped.scroll : noscroll ? scroll_state() : null; if (autoscroll) { const deep_linked = url.hash && document.getElementById( decodeURIComponent(app.hash ? (url.hash.split('#')[2] ?? '') : url.hash.slice(1)) ); if (scroll) { scrollTo(scroll.x, scroll.y); } else if (deep_linked) { // Here we use `scrollIntoView` on the element instead of `scrollTo` // because it natively supports the `scroll-margin` and `scroll-behavior` // CSS properties. deep_linked.scrollIntoView(); } else { scrollTo(0, 0); } } const changed_focus = // reset focus only if any manual focus management didn't override it document.activeElement !== activeElement && // also refocus when activeElement is body already because the // focus event might not have been fired on it yet document.activeElement !== document.body; if (!keepfocus && !changed_focus) { reset_focus(); } autoscroll = true; if (navigation_result.props.page) { Object.assign(page, navigation_result.props.page); } is_navigating = false; if (type === 'popstate') { restore_snapshot(current_navigation_index); } nav.fulfil(undefined); after_navigate_callbacks.forEach((fn) => fn(/** @type {import('@sveltejs/kit').AfterNavigate} */ (nav.navigation)) ); stores.navigating.set((navigating.current = null)); updating = false; } /** * Does a full page reload if it wouldn't result in an endless loop in the SPA case * @param {URL} url * @param {{ id: string | null }} route * @param {App.Error} error * @param {number} status * @returns {Promise<import('./types.js').NavigationFinished>} */ async function server_fallback(url, route, error, status) { if (url.origin === origin && url.pathname === location.pathname && !hydrated) { // We would reload the same page we're currently on, which isn't hydrated, // which means no SSR, which means we would end up in an endless loop return await load_root_error_page({ status, error, url, route }); } if (DEV && status !== 404) { console.error( 'An error occurred while loading the page. This will cause a full page reload. (This message will only appear during development.)' ); debugger; // eslint-disable-line } return await native_navigation(url); } if (import.meta.hot) { import.meta.hot.on('vite:beforeUpdate', () => { if (current.error) location.reload(); }); } /** @typedef {(typeof PRELOAD_PRIORITIES)['hover'] | (typeof PRELOAD_PRIORITIES)['tap']} PreloadDataPriority */ function setup_preload() { /** @type {NodeJS.Timeout} */ let mousemove_timeout; /** @type {Element} */ let current_a; /** @type {PreloadDataPriority} */ let current_priority; container.addEventListener('mousemove', (event) => { const target = /** @type {Element} */ (event.target); clearTimeout(mousemove_timeout); mousemove_timeout = setTimeout(() => { void preload(target, PRELOAD_PRIORITIES.hover); }, 20); }); /** @param {Event} event */ function tap(event) { if (event.defaultPrevented) return; void preload(/** @type {Element} */ (event.composedPath()[0]), PRELOAD_PRIORITIES.tap); } container.addEventListener('mousedown', tap); container.addEventListener('touchstart', tap, { passive: true }); const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (entry.isIntersecting) { void _preload_code(new URL(/** @type {HTMLAnchorElement} */ (entry.target).href)); observer.unobserve(entry.target); } } }, { threshold: 0 } ); /** * @param {Element} element * @param {PreloadDataPriority} priority */ async function preload(element, priority) { const a = find_anchor(element, container); // we don't want to preload data again if the user has already hovered/tapped const interacted = a === current_a && priority >= current_priority; if (!a || interacted) return; const { url, external, download } = get_link_info(a, base, app.hash); if (external || download) return; const options = get_router_options(a); // we don't want to preload data for a page we're already on const same_url = url && get_page_key(current.url) === get_page_key(url); if (options.reload || same_url) return; if (priority <= options.preload_data) { current_a = a; // we don't want to preload data again on tap if we've already preloaded it on hover current_priority = PRELOAD_PRIORITIES.tap; const intent = await get_navigation_intent(url, false); if (!intent) return; if (DEV) { void _preload_data(intent).then((result) => { if (result.type === 'loaded' && result.state.error) { console.warn( `Preloading data for ${intent.url.pathname} failed with the following error: ${result.state.error.message}\n` + 'If this error is transient, you can ignore it. Otherwise, consider disabling preloading for this route. ' + 'This route was preloaded due to a data-sveltekit-preload-data attribute. ' + 'See https://svelte.dev/docs/kit/link-options for more info' ); } }); } else { void _preload_data(intent); } } else if (priority <= options.preload_code) { current_a = a; current_priority = priority; void _preload_code(/** @type {URL} */ (url)); } } function after_navigate() { observer.disconnect(); for (const a of container.querySelectorAll('a')) { const { url, external, download } = get_link_info(a, base, app.hash); if (external || download) continue; const options = get_router_options(a); if (options.reload) continue; if (options.preload_code === PRELOAD_PRIORITIES.viewport) { observer.observe(a); } if (options.preload_code === PRELOAD_PRIORITIES.eager) { void _preload_code(/** @type {URL} */ (url)); } } } after_navigate_callbacks.add(after_navigate); after_navigate(); } /** * @param {unknown} error * @param {import('@sveltejs/kit').NavigationEvent} event * @returns {import('types').MaybePromise<App.Error>} */ function handle_error(error, event) { if (error instanceof HttpError) { return error.body; } if (DEV) { errored = true; console.warn('The next HMR update will cause the page to reload'); } const status = get_status(error); const message = get_message(error); return ( app.hooks.handleError({ error, event, status, message }) ?? /** @type {any} */ ({ message }) ); } /** * @template {Function} T * @param {Set<T>} callbacks * @param {T} callback */ function add_navigation_callback(callbacks, callback) { onMount(() => { callbacks.add(callback); return () => { callbacks.delete(callback); }; }); } /** * A lifecycle function that runs the supplied `callback` when the current component mounts, and also whenever we navigate to a URL. * * `afterNavigate` must be called during a component initializa