diff --git a/.changeset/light-moons-dress.md b/.changeset/light-moons-dress.md new file mode 100644 index 000000000000..85afe61bcaaa --- /dev/null +++ b/.changeset/light-moons-dress.md @@ -0,0 +1,5 @@ +--- +"@sveltejs/kit": minor +--- + +feat: implement shallow routing diff --git a/.changeset/serious-months-happen.md b/.changeset/serious-months-happen.md new file mode 100644 index 000000000000..861d42d2959f --- /dev/null +++ b/.changeset/serious-months-happen.md @@ -0,0 +1,5 @@ +--- +"@sveltejs/kit": major +--- + +breaking: remove state option from goto in favor of shallow routing diff --git a/documentation/docs/30-advanced/67-shallow-routing.md b/documentation/docs/30-advanced/67-shallow-routing.md new file mode 100644 index 000000000000..466f9ee2049e --- /dev/null +++ b/documentation/docs/30-advanced/67-shallow-routing.md @@ -0,0 +1,97 @@ +--- +title: Shallow routing +--- + +As you navigate around a SvelteKit app, you create _history entries_. Clicking the back and forward buttons traverses through this list of entries, re-running any `load` functions and replacing page components as necessary. + +Sometimes, it's useful to create history entries _without_ navigating. For example, you might want to show a modal dialog that the user can dismiss by navigating back. This is particularly valuable on mobile devices, where swipe gestures are often more natural than interacting directly with the UI. In these cases, a modal that is _not_ associated with a history entry can be a source of frustration, as a user may swipe backwards in an attempt to dismiss it and find themselves on the wrong page. + +SvelteKit makes this possible with the [`pushState`](/docs/modules#$app-navigation-pushstate) and [`replaceState`](/docs/modules#$app-navigation-replacestate) functions, which allow you to associate state with a history entry without navigating. For example, to implement a history-driven modal: + +```svelte + + + +{#if $page.state.showModal} + history.back()} /> +{/if} +``` + +The modal can be dismissed by navigating back (unsetting `$page.state.showModal`) or by interacting with it in a way that causes the `close` callback to run, which will navigate back programmatically. + +## API + +The first argument to `pushState` is the URL, relative to the current URL. To stay on the current URL, use `''`. + +The second argument is the new page state, which can be accessed via the [page store](/docs/modules#$app-stores-page) as `$page.state`. You can make page state type-safe by declaring an [`App.PageState`](/docs/types#app) interface (usually in `src/app.d.ts`). + +To set page state without creating a new history entry, use `replaceState` instead of `pushState`. + +## Loading data for a route + +When shallow routing, you may want to render another `+page.svelte` inside the current page. For example, clicking on a photo thumbnail could pop up the detail view without navigating to the photo page. + +For this to work, you need to load the data that the `+page.svelte` expects. A convenient way to do this is to use [`preloadData`](/docs/modules#$app-navigation-preloaddata) inside the `click` handler of an `` element. If the element (or a parent) uses [`data-sveltekit-preload-data`](/docs/link-options#data-sveltekit-preload-data), the data will have already been requested, and `preloadData` will reuse that request. + +```svelte + + + +{#each data.thumbnails as thumbnail} + { + // bail if opening a new tab, or we're on too small a screen + if (e.metaKey || innerWidth < 640) return; + + // prevent navigation + e.preventDefault(); + + const { href } = e.currentTarget; + + // run `load` functions (or rather, get the result of the `load` functions + // that are already running because of `data-sveltekit-preload-data`) + const result = await preloadData(href); + + if (result.type === 'loaded' && result.status === 200) { + pushState(href, { selected: result.data }); + } else { + // something bad happened! try navigating + goto(href); + } + }} + > + {thumbnail.alt} + +{/each} + +{#if $page.state.selected} + history.goBack()}> + + + +{/if} +``` + +## Caveats + +During server-side rendering, `$page.state` is always an empty object. The same is true for the first page the user lands on — if the user reloads the page, state will _not_ be applied until they navigate. + +Shallow routing is a feature that requires JavaScript to work. Be mindful when using it and try to think of sensible fallback behavior in case JavaScript isn't available. diff --git a/documentation/docs/60-appendix/30-migrating-to-sveltekit-2.md b/documentation/docs/60-appendix/30-migrating-to-sveltekit-2.md index 1ab03abdc277..b4efa640053c 100644 --- a/documentation/docs/60-appendix/30-migrating-to-sveltekit-2.md +++ b/documentation/docs/60-appendix/30-migrating-to-sveltekit-2.md @@ -66,9 +66,9 @@ export function load({ fetch }) { } ``` -## goto(...) no longer accepts external URLs +## goto(...) changes -To navigate to an external URL, use `window.location = url`. +`goto(...)` no longer accepts external URLs. To navigate to an external URL, use `window.location = url`. The `state` option was removed in favor of [shallow routing](shallow-routing). ## paths are now relative by default diff --git a/packages/create-svelte/templates/default/src/app.d.ts b/packages/create-svelte/templates/default/src/app.d.ts index f59b884c51ed..743f07b2e50a 100644 --- a/packages/create-svelte/templates/default/src/app.d.ts +++ b/packages/create-svelte/templates/default/src/app.d.ts @@ -5,6 +5,7 @@ declare global { // interface Error {} // interface Locals {} // interface PageData {} + // interface PageState {} // interface Platform {} } } diff --git a/packages/create-svelte/templates/skeleton/src/app.d.ts b/packages/create-svelte/templates/skeleton/src/app.d.ts index f59b884c51ed..743f07b2e50a 100644 --- a/packages/create-svelte/templates/skeleton/src/app.d.ts +++ b/packages/create-svelte/templates/skeleton/src/app.d.ts @@ -5,6 +5,7 @@ declare global { // interface Error {} // interface Locals {} // interface PageData {} + // interface PageState {} // interface Platform {} } } diff --git a/packages/create-svelte/templates/skeletonlib/src/app.d.ts b/packages/create-svelte/templates/skeletonlib/src/app.d.ts index f59b884c51ed..743f07b2e50a 100644 --- a/packages/create-svelte/templates/skeletonlib/src/app.d.ts +++ b/packages/create-svelte/templates/skeletonlib/src/app.d.ts @@ -5,6 +5,7 @@ declare global { // interface Error {} // interface Locals {} // interface PageData {} + // interface PageState {} // interface Platform {} } } diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index f9de82b1c393..36f8a6cc9d39 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -961,6 +961,10 @@ export interface Page< * The merged result of all data from all `load` functions on the current page. You can type a common denominator through `App.PageData`. */ data: App.PageData & Record; + /** + * The page state, which can be manipulated using the [`pushState`](https://kit.svelte.dev/docs/modules#$app-navigation-pushstate) and [`replaceState`](https://kit.svelte.dev/docs/modules#$app-navigation-replacestate) functions from `$app/navigation`. + */ + state: App.PageState; /** * Filled only after a form submission. See [form actions](https://kit.svelte.dev/docs/form-actions) for more info. */ diff --git a/packages/kit/src/runtime/app/navigation.js b/packages/kit/src/runtime/app/navigation.js index fcd2a2507e42..4bbfae215f9c 100644 --- a/packages/kit/src/runtime/app/navigation.js +++ b/packages/kit/src/runtime/app/navigation.js @@ -11,20 +11,13 @@ export const disableScrollHandling = /* @__PURE__ */ client_method('disable_scro * Returns a Promise that resolves when SvelteKit navigates (or fails to navigate, in which case the promise rejects) to the specified `url`. * For external URLs, use `window.location = url` instead of calling `goto(url)`. * - * @type {(url: string | URL, opts?: { - * replaceState?: boolean; - * noScroll?: boolean; - * keepFocus?: boolean; - * invalidateAll?: boolean; - * state?: any - * }) => Promise} + * @type {(url: string | URL, opts?: { replaceState?: boolean; noScroll?: boolean; keepFocus?: boolean; invalidateAll?: boolean; }) => Promise} * @param {string | URL} url Where to navigate to. Note that if you've set [`config.kit.paths.base`](https://kit.svelte.dev/docs/configuration#paths) and the URL is root-relative, you need to prepend the base path if you want to navigate within the app. * @param {Object} [opts] Options related to the navigation * @param {boolean} [opts.replaceState] If `true`, will replace the current `history` entry rather than creating a new one with `pushState` * @param {boolean} [opts.noScroll] If `true`, the browser will maintain its scroll position rather than scrolling to the top of the page after navigation * @param {boolean} [opts.keepFocus] If `true`, the currently focused element will retain focus after navigation. Otherwise, focus will be reset to the body * @param {boolean} [opts.invalidateAll] If `true`, all `load` functions of the page will be rerun. See https://kit.svelte.dev/docs/load#rerunning-load-functions for more info on invalidation. - * @param {any} [opts.state] The state of the new/updated history entry * @returns {Promise} */ export const goto = /* @__PURE__ */ client_method('goto'); @@ -64,11 +57,11 @@ export const invalidateAll = /* @__PURE__ */ client_method('invalidate_all'); * * This is the same behaviour that SvelteKit triggers when the user taps or mouses over an `` element with `data-sveltekit-preload-data`. * If the next navigation is to `href`, the values returned from load will be used, making navigation instantaneous. - * Returns a Promise that resolves when the preload is complete. + * Returns a Promise that resolves with the result of running the new route's `load` functions once the preload is complete. * - * @type {(href: string) => Promise} + * @type {(href: string) => Promise>} * @param {string} href Page to preload - * @returns {Promise} + * @returns {Promise<{ type: 'loaded'; status: number; data: Record } | { type: 'redirect'; location: string }>} */ export const preloadData = /* @__PURE__ */ client_method('preload_data'); @@ -126,3 +119,23 @@ export const onNavigate = /* @__PURE__ */ client_method('on_navigate'); * @returns {void} */ export const afterNavigate = /* @__PURE__ */ client_method('after_navigate'); + +/** + * Programmatically create a new history entry with the given `$page.state`. To use the current URL, you can pass `''` as the first argument. Used for [shallow routing](https://kit.svelte.dev/docs/shallow-routing). + * + * @type {(url: string | URL, state: App.PageState) => void} + * @param {string | URL} url + * @param {App.PageState} state + * @returns {void} + */ +export const pushState = /* @__PURE__ */ client_method('push_state'); + +/** + * Programmatically replace the current history entry with the given `$page.state`. To use the current URL, you can pass `''` as the first argument. Used for [shallow routing](https://kit.svelte.dev/docs/shallow-routing). + * + * @type {(url: string | URL, state: App.PageState) => void} + * @param {string | URL} url + * @param {App.PageState} state + * @returns {void} + */ +export const replaceState = /* @__PURE__ */ client_method('replace_state'); diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 39b326dbc17d..c778c18d7932 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -4,6 +4,7 @@ import { add_data_suffix, decode_params, decode_pathname, + strip_hash, make_trackable, normalize_path } from '../../utils/url.js'; @@ -28,33 +29,99 @@ import { import { base } from '__sveltekit/paths'; import * as devalue from 'devalue'; -import { compact } from '../../utils/array.js'; +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 { INDEX_KEY, PRELOAD_PRIORITIES, SCROLL_KEY, SNAPSHOT_KEY } from './constants.js'; import { stores } from './singletons.js'; import { get_message, get_status } from '../../utils/error.js'; let errored = false; +/** @typedef {{ x: number; y: number }} ScrollPosition */ + // 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 - -/** @typedef {{ x: number, y: number }} ScrollPosition */ -/** @type {Record} */ +/** + * history index -> { x, y } + * @type {Record} + */ const scroll_positions = storage.get(SCROLL_KEY) ?? {}; -/** @type {Record} */ +/** + * history index -> any + * @type {Record>} + */ +const states = storage.get(STATES_KEY, devalue.parse) ?? {}; + +/** + * navigation index -> any + * @type {Record} + */ const snapshots = storage.get(SNAPSHOT_KEY) ?? {}; +const original_push_state = history.pushState; +const original_replace_state = history.replaceState; + +if (DEV) { + let warned = false; + + const warn = () => { + if (warned) 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." + ); + }; + + history.pushState = (...args) => { + warn(); + return original_push_state.apply(history, args); + }; + + history.replaceState = (...args) => { + warn(); + return original_replace_state.apply(history, args); + }; +} + /** @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 @@ -66,6 +133,8 @@ function native_navigation(url) { return new Promise(() => {}); } +function noop() {} + /** * @param {import('./types.js').SvelteKitApp} app * @param {HTMLElement} target @@ -124,6 +193,8 @@ export function create_client(app, target) { let updating = false; let 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; @@ -131,16 +202,25 @@ export function create_client(app, target) { let root; // keeping track of the history index in order to prevent popstate navigation events if needed - let current_history_index = history.state?.[INDEX_KEY]; + /** @type {number} */ + let current_history_index = history.state?.[HISTORY_INDEX]; + + /** @type {number} */ + let 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 = Date.now(); + current_history_index = current_navigation_index = Date.now(); // create initial history entry, so we can return here - history.replaceState( - { ...history.state, [INDEX_KEY]: current_history_index }, + original_replace_state.call( + history, + { + ...history.state, + [HISTORY_INDEX]: current_history_index, + [NAVIGATION_INDEX]: current_navigation_index + }, '', location.href ); @@ -171,8 +251,7 @@ export function create_client(app, target) { if (!pending_invalidate) return; pending_invalidate = null; - const url = new URL(location.href); - const intent = get_navigation_intent(url, true); + const intent = 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 @@ -186,7 +265,7 @@ export function create_client(app, target) { if (navigation_result) { if (navigation_result.type === 'redirect') { - await goto(new URL(navigation_result.location, url).href, {}, 1, nav_token); + await goto(new URL(navigation_result.location, current.url).href, {}, 1, nav_token); } else { if (navigation_result.props.page !== undefined) { page = navigation_result.props.page; @@ -216,45 +295,31 @@ export function create_client(app, target) { update_scroll_positions(current_history_index); storage.set(SCROLL_KEY, scroll_positions); - capture_snapshot(current_history_index); + capture_snapshot(current_navigation_index); storage.set(SNAPSHOT_KEY, snapshots); + storage.set(STATES_KEY, states, devalue.stringify); } /** * @param {string | URL} url - * @param {{ noScroll?: boolean; replaceState?: boolean; keepFocus?: boolean; state?: any; invalidateAll?: boolean }} opts + * @param {{ replaceState?: boolean; noScroll?: boolean; keepFocus?: boolean; invalidateAll?: boolean; }} options * @param {number} redirect_count * @param {{}} [nav_token] */ - async function goto( - url, - { - noScroll = false, - replaceState = false, - keepFocus = false, - state = {}, - invalidateAll = false - }, - redirect_count, - nav_token - ) { + async function goto(url, options, redirect_count, nav_token) { return navigate({ + type: 'goto', url: resolve_url(url), - scroll: noScroll ? scroll_state() : null, - keepfocus: keepFocus, + keepfocus: options.keepFocus, + noscroll: options.noScroll, + replace_state: options.replaceState, redirect_count, - details: { - state, - replaceState - }, nav_token, - accepted: () => { - if (invalidateAll) { + accept: () => { + if (options.invalidateAll) { force_invalidation = true; } - }, - blocked: () => {}, - type: 'goto' + } }); } @@ -300,7 +365,7 @@ export function create_client(app, target) { hydrate: true }); - restore_snapshot(current_history_index); + restore_snapshot(current_navigation_index); /** @type {import('@sveltejs/kit').AfterNavigate} */ const navigation = { @@ -361,7 +426,8 @@ export function create_client(app, target) { }, props: { // @ts-ignore Somehow it's getting SvelteComponent and SvelteComponentDev mixed up - constructors: compact(branch).map((branch_node) => branch_node.node.component) + constructors: compact(branch).map((branch_node) => branch_node.node.component), + page } }; @@ -405,6 +471,7 @@ export function create_client(app, target) { route: { id: route?.id ?? null }, + state: {}, status, url: new URL(url), form: form ?? null, @@ -1004,45 +1071,47 @@ export function create_client(app, target) { /** * @param {{ - * url: URL; - * scroll: { x: number, y: number } | null; - * keepfocus: boolean; - * redirect_count: number; - * details: { - * replaceState: boolean; - * state: any; - * } | null; * type: import('@sveltejs/kit').Navigation["type"]; - * delta?: number; + * url: URL; + * popped?: { + * state: Record; + * scroll: { x: number, y: number }; + * delta: number; + * }; + * keepfocus?: boolean; + * noscroll?: boolean; + * replace_state?: boolean; + * redirect_count?: number; * nav_token?: {}; - * accepted: () => void; - * blocked: () => void; + * accept?: () => void; + * block?: () => void; * }} opts */ async function navigate({ + type, url, - scroll, + popped, keepfocus, - redirect_count, - details, - type, - delta, + noscroll, + replace_state, + redirect_count = 0, nav_token = {}, - accepted, - blocked + accept = noop, + block = noop }) { const intent = get_navigation_intent(url, false); - const nav = before_navigate({ url, type, delta, intent }); + const nav = before_navigate({ url, type, delta: popped?.delta, intent }); if (!nav) { - blocked(); + block(); return; } - // store this before calling `accepted()`, which may change the index + // store this before calling `accept()`, which may change the index const previous_history_index = current_history_index; + const previous_navigation_index = current_navigation_index; - accepted(); + accept(); navigating = true; @@ -1096,7 +1165,7 @@ export function create_client(app, target) { 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) { + } else if (/** @type {number} */ (navigation_result.props.page.status) >= 400) { const updated = await stores.updated.check(); if (updated) { await native_navigation(url); @@ -1111,36 +1180,39 @@ export function create_client(app, target) { updating = true; update_scroll_positions(previous_history_index); - capture_snapshot(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 && - navigation_result.props.page.url.pathname !== url.pathname - ) { - url.pathname = navigation_result.props.page?.url.pathname; + if (navigation_result.props.page.url.pathname !== url.pathname) { + url.pathname = navigation_result.props.page.url.pathname; } - if (details) { - const change = details.replaceState ? 0 : 1; - details.state[INDEX_KEY] = current_history_index += change; - history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', url); - - if (!details.replaceState) { - // 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 (snapshots[i] || scroll_positions[i]) { - delete snapshots[i]; - delete scroll_positions[i]; - i += 1; - } + const state = popped ? popped.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) + }; + + const fn = replace_state ? original_replace_state : original_push_state; + fn.call(history, entry, '', url); + + if (!replace_state) { + clear_onward_history(current_history_index, current_navigation_index); } } + states[current_history_index] = state; + // 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; @@ -1172,6 +1244,7 @@ export function create_client(app, target) { } root.$set(navigation_result.props); + has_navigated = true; } else { initialize(navigation_result); } @@ -1182,6 +1255,8 @@ export function create_client(app, target) { 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(url.hash.slice(1))); @@ -1217,7 +1292,7 @@ export function create_client(app, target) { navigating = false; if (type === 'popstate') { - restore_snapshot(current_history_index); + restore_snapshot(current_navigation_index); } nav.fulfil(undefined); @@ -1432,6 +1507,14 @@ export function create_client(app, target) { goto: (url, opts = {}) => { url = resolve_url(url); + // @ts-expect-error + if (DEV && opts.state) { + // TOOD 3.0 remove + throw new Error( + 'Passing `state` to `goto` is no longer supported. Use `pushState` and `replaceState` from `$app/navigation` instead.' + ); + } + if (url.origin !== origin) { return Promise.reject( new Error( @@ -1469,7 +1552,16 @@ export function create_client(app, target) { throw new Error(`Attempted to preload a URL that does not belong to this app: ${url}`); } - await preload_data(intent); + const result = await preload_data(intent); + if (result.type === 'redirect') { + return { + type: result.type, + location: result.location + }; + } + + const { status, data } = result.props.page ?? page; + return { type: result.type, status, data }; }, preload_code: (pathname) => { @@ -1488,6 +1580,55 @@ export function create_client(app, target) { return preload_code(pathname); }, + push_state: (url, state) => { + if (DEV) { + try { + devalue.stringify(state); + } catch (error) { + // @ts-expect-error + throw new Error(`Could not serialize state${error.path}`); + } + } + + const opts = { + [HISTORY_INDEX]: (current_history_index += 1), + [NAVIGATION_INDEX]: current_navigation_index, + [PAGE_URL_KEY]: page.url.href + }; + + original_push_state.call(history, opts, '', resolve_url(url)); + + page = { ...page, state }; + root.$set({ page }); + + states[current_history_index] = state; + clear_onward_history(current_history_index, current_navigation_index); + }, + + replace_state: (url, state) => { + if (DEV) { + try { + devalue.stringify(state); + } catch (error) { + // @ts-expect-error + throw new Error(`Could not serialize state${error.path}`); + } + } + + const opts = { + [HISTORY_INDEX]: current_history_index, + [NAVIGATION_INDEX]: current_navigation_index, + [PAGE_URL_KEY]: page.url.href + }; + + original_replace_state.call(history, opts, '', resolve_url(url)); + + page = { ...page, state }; + root.$set({ page }); + + states[current_history_index] = state; + }, + apply_action: async (result) => { if (result.type === 'error') { const url = new URL(location.href); @@ -1643,7 +1784,7 @@ export function create_client(app, target) { // 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 [nonhash, hash] = url.href.split('#'); - if (hash !== undefined && nonhash === location.href.split('#')[0]) { + if (hash !== undefined && nonhash === strip_hash(location)) { // If we are trying to navigate to the same hash, we should only // attempt to scroll to that element and avoid any history changes. // Otherwise, this can cause Firefox to incorrectly assign a null @@ -1665,21 +1806,16 @@ export function create_client(app, target) { // hashchange event shouldn't occur if the router is replacing state. hash_navigating = false; - event.preventDefault(); } + event.preventDefault(); + navigate({ + type: 'link', url, - scroll: options.noscroll ? scroll_state() : null, - keepfocus: options.keep_focus ?? false, - redirect_count: 0, - details: { - state: {}, - replaceState: options.replace_state ?? url.href === location.href - }, - accepted: () => event.preventDefault(), - blocked: () => event.preventDefault(), - type: 'link' + keepfocus: options.keepfocus, + noscroll: options.noscroll, + replace_state: options.replace_state ?? url.href === location.href }); }); @@ -1706,8 +1842,8 @@ export function create_client(app, target) { const event_form = /** @type {HTMLFormElement} */ (event.target); - const { keep_focus, noscroll, reload, replace_state } = get_router_options(event_form); - if (reload) return; + const options = get_router_options(event_form); + if (options.reload) return; event.preventDefault(); event.stopPropagation(); @@ -1723,57 +1859,67 @@ export function create_client(app, target) { url.search = new URLSearchParams(data).toString(); navigate({ + type: 'form', url, - scroll: noscroll ? scroll_state() : null, - keepfocus: keep_focus ?? false, - redirect_count: 0, - details: { - state: {}, - replaceState: replace_state ?? url.href === location.href - }, - nav_token: {}, - accepted: () => {}, - blocked: () => {}, - type: 'form' + keepfocus: options.keepfocus, + noscroll: options.noscroll, + replace_state: options.replace_state ?? url.href === location.href }); }); addEventListener('popstate', async (event) => { - token = {}; - if (event.state?.[INDEX_KEY]) { + if (event.state?.[HISTORY_INDEX]) { + const history_index = event.state[HISTORY_INDEX]; + token = {}; + // 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; - - const scroll = scroll_positions[event.state[INDEX_KEY]]; - const url = new URL(location.href); - - // if the only change is the hash, we don't need to do anything (see https://github.com/sveltejs/kit/pull/10636 for why we need to do `url?.`)... - if (current.url?.href.split('#')[0] === location.href.split('#')[0]) { - // ...except update our internal URL tracking and handle scroll + if (history_index === current_history_index) return; + + const scroll = scroll_positions[history_index]; + const state = states[history_index] ?? {}; + const url = new URL(event.state[PAGE_URL_KEY] ?? location.href); + const navigation_index = event.state[NAVIGATION_INDEX]; + const is_hash_change = strip_hash(location) === strip_hash(current.url); + const shallow = + navigation_index === current_navigation_index && (has_navigated || is_hash_change); + + if (shallow) { + // We don't need to navigate, we just need to update scroll and/or state. + // This happens with hash links and `pushState`/`replaceState`. The + // exception is if we haven't navigated yet, since we could have + // got here after a modal navigation then a reload update_url(url); + scroll_positions[current_history_index] = scroll_state(); - current_history_index = event.state[INDEX_KEY]; - scrollTo(scroll.x, scroll.y); + if (scroll) scrollTo(scroll.x, scroll.y); + + if (state !== page.state) { + page = { ...page, state }; + root.$set({ page }); + } + + current_history_index = history_index; return; } - const delta = event.state[INDEX_KEY] - current_history_index; + const delta = history_index - current_history_index; await navigate({ + type: 'popstate', url, - scroll, - keepfocus: false, - redirect_count: 0, - details: null, - accepted: () => { - current_history_index = event.state[INDEX_KEY]; + popped: { + state, + scroll, + delta + }, + accept: () => { + current_history_index = history_index; + current_navigation_index = navigation_index; }, - blocked: () => { + block: () => { history.go(-delta); }, - type: 'popstate', - delta, nav_token: token }); } else { @@ -1792,8 +1938,13 @@ export function create_client(app, target) { // we need to update history, otherwise we have to leave it alone if (hash_navigating) { hash_navigating = false; - history.replaceState( - { ...history.state, [INDEX_KEY]: ++current_history_index }, + original_replace_state.call( + history, + { + ...history.state, + [HISTORY_INDEX]: ++current_history_index, + [NAVIGATION_INDEX]: current_navigation_index + }, '', location.href ); @@ -1914,6 +2065,10 @@ export function create_client(app, target) { }); } + if (result.props.page) { + result.props.page.state = {}; + } + initialize(result); } }; diff --git a/packages/kit/src/runtime/client/constants.js b/packages/kit/src/runtime/client/constants.js index e6b515332801..982c25d7debe 100644 --- a/packages/kit/src/runtime/client/constants.js +++ b/packages/kit/src/runtime/client/constants.js @@ -1,6 +1,10 @@ export const SNAPSHOT_KEY = 'sveltekit:snapshot'; export const SCROLL_KEY = 'sveltekit:scroll'; -export const INDEX_KEY = 'sveltekit:index'; +export const STATES_KEY = 'sveltekit:states'; +export const PAGE_URL_KEY = 'sveltekit:pageurl'; + +export const HISTORY_INDEX = 'sveltekit:history'; +export const NAVIGATION_INDEX = 'sveltekit:navigation'; export const PRELOAD_PRIORITIES = /** @type {const} */ ({ tap: 1, diff --git a/packages/kit/src/runtime/client/session-storage.js b/packages/kit/src/runtime/client/session-storage.js index dc2639ef1b5e..e49543bc8b82 100644 --- a/packages/kit/src/runtime/client/session-storage.js +++ b/packages/kit/src/runtime/client/session-storage.js @@ -1,10 +1,11 @@ /** * Read a value from `sessionStorage` * @param {string} key + * @param {(value: string) => any} parse */ -export function get(key) { +export function get(key, parse = JSON.parse) { try { - return JSON.parse(sessionStorage[key]); + return parse(sessionStorage[key]); } catch { // do nothing } @@ -14,11 +15,12 @@ export function get(key) { * Write a value to `sessionStorage` * @param {string} key * @param {any} value + * @param {(value: any) => string} stringify */ -export function set(key, value) { - const json = JSON.stringify(value); +export function set(key, value, stringify = JSON.stringify) { + const data = stringify(value); try { - sessionStorage[key] = json; + sessionStorage[key] = data; } catch { // do nothing } diff --git a/packages/kit/src/runtime/client/singletons.js b/packages/kit/src/runtime/client/singletons.js index e6bf5808eef9..aacaa04907ec 100644 --- a/packages/kit/src/runtime/client/singletons.js +++ b/packages/kit/src/runtime/client/singletons.js @@ -21,7 +21,13 @@ export function init(opts) { */ export function client_method(key) { if (!BROWSER) { - if (key === 'before_navigate' || key === 'after_navigate' || key === 'on_navigate') { + if ( + key === 'before_navigate' || + key === 'after_navigate' || + key === 'on_navigate' || + key === 'push_state' || + key === 'replace_state' + ) { // @ts-expect-error doesn't recognize that both keys here return void so expects a async function return () => {}; } else { diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 6466bd916447..458c36ddddae 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -7,7 +7,9 @@ import { invalidate, invalidateAll, preloadCode, - preloadData + preloadData, + pushState, + replaceState } from '../app/navigation.js'; import { SvelteComponent } from 'svelte'; import { ClientHooks, CSRPageNode, CSRPageNodeLoader, CSRRoute, TrailingSlash, Uses } from 'types'; @@ -51,6 +53,8 @@ export interface Client { invalidate_all: typeof invalidateAll; preload_code: typeof preloadCode; preload_data: typeof preloadData; + push_state: typeof pushState; + replace_state: typeof replaceState; apply_action: typeof applyAction; // private API @@ -92,7 +96,7 @@ export type NavigationFinished = { props: { constructors: Array; components?: Array; - page?: Page; + page: Page; form?: Record | null; [key: `data_${number}`]: Record; }; diff --git a/packages/kit/src/runtime/client/utils.js b/packages/kit/src/runtime/client/utils.js index 05e59b21bc8a..d205f48cdc42 100644 --- a/packages/kit/src/runtime/client/utils.js +++ b/packages/kit/src/runtime/client/utils.js @@ -149,7 +149,7 @@ export function get_link_info(a, base) { */ export function get_router_options(element) { /** @type {ValidLinkOptions<'keepfocus'> | null} */ - let keep_focus = null; + let keepfocus = null; /** @type {ValidLinkOptions<'noscroll'> | null} */ let noscroll = null; @@ -172,7 +172,7 @@ export function get_router_options(element) { while (el && el !== document.documentElement) { if (preload_code === null) preload_code = link_option(el, 'preload-code'); if (preload_data === null) preload_data = link_option(el, 'preload-data'); - if (keep_focus === null) keep_focus = link_option(el, 'keepfocus'); + if (keepfocus === null) keepfocus = link_option(el, 'keepfocus'); if (noscroll === null) noscroll = link_option(el, 'noscroll'); if (reload === null) reload = link_option(el, 'reload'); if (replace_state === null) replace_state = link_option(el, 'replacestate'); @@ -190,14 +190,14 @@ export function get_router_options(element) { case 'false': return false; default: - return null; + return undefined; } } return { preload_code: levels[preload_code ?? 'off'], preload_data: levels[preload_data ?? 'off'], - keep_focus: get_option_state(keep_focus), + keepfocus: get_option_state(keepfocus), noscroll: get_option_state(noscroll), reload: get_option_state(reload), replace_state: get_option_state(replace_state) diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 3b673648135f..fbe8f9d35548 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -141,7 +141,8 @@ export async function render_response({ status, url: event.url, data, - form: form_value + form: form_value, + state: {} }; // use relative paths during rendering, so that the resulting HTML is as diff --git a/packages/kit/src/types/ambient.d.ts b/packages/kit/src/types/ambient.d.ts index c59ed2d3700a..fa32a8a0662b 100644 --- a/packages/kit/src/types/ambient.d.ts +++ b/packages/kit/src/types/ambient.d.ts @@ -7,6 +7,7 @@ * // interface Error {} * // interface Locals {} * // interface PageData {} + * // interface PageState {} * // interface Platform {} * } * } @@ -39,6 +40,11 @@ declare namespace App { */ export interface PageData {} + /** + * The shape of the `$page.state` object, which can be manipulated using the [`pushState`](https://kit.svelte.dev/docs/modules#$app-navigation-pushstate) and [`replaceState`](https://kit.svelte.dev/docs/modules#$app-navigation-replacestate) functions from `$app/navigation`. + */ + export interface PageState {} + /** * If your adapter provides [platform-specific context](https://kit.svelte.dev/docs/adapters#platform-specific-context) via `event.platform`, you can specify it here. */ diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js index ebcd3702ea86..9f6391e42486 100644 --- a/packages/kit/src/utils/url.js +++ b/packages/kit/src/utils/url.js @@ -77,6 +77,14 @@ export function decode_uri(uri) { } } +/** + * Returns everything up to the first `#` in a URL + * @param {{href: string}} url_like + */ +export function strip_hash({ href }) { + return href.split('#')[0]; +} + /** * URL properties that could change during the lifetime of the page, * which excludes things like `origin` diff --git a/packages/kit/test/apps/basics/src/app.d.ts b/packages/kit/test/apps/basics/src/app.d.ts index adba879735d9..16bdf501b907 100644 --- a/packages/kit/test/apps/basics/src/app.d.ts +++ b/packages/kit/test/apps/basics/src/app.d.ts @@ -8,6 +8,10 @@ declare global { url?: URL; } + interface PageState { + active: boolean; + } + interface Platform {} } } diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte new file mode 100644 index 000000000000..28de28c4ab08 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+layout.svelte @@ -0,0 +1,5 @@ +push-state +a +b + + diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.js b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.js new file mode 100644 index 000000000000..e05ece04e7cd --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.js @@ -0,0 +1,5 @@ +export function load() { + return { + now: Date.now() + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte new file mode 100644 index 000000000000..40c616c3b2a5 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/+page.svelte @@ -0,0 +1,23 @@ + + +

parent

+ + + + + +

active: {$page.state.active ?? false}

+{data.now} diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/a/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/a/+page.svelte new file mode 100644 index 000000000000..3be7397f2aca --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/a/+page.svelte @@ -0,0 +1,7 @@ + + +

a

+ +

active: {$page.state.active ?? false}

diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/b/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/b/+page.svelte new file mode 100644 index 000000000000..30c06a44b409 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/push-state/b/+page.svelte @@ -0,0 +1,7 @@ + + +

b

+ +

active: {$page.state.active ?? false}

diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+layout.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+layout.svelte new file mode 100644 index 000000000000..7e6089f7721f --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+layout.svelte @@ -0,0 +1,5 @@ +replace-state +a +b + + diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+page.svelte new file mode 100644 index 000000000000..a19674234665 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/+page.svelte @@ -0,0 +1,19 @@ + + +

parent

+ + + + +

active: {$page.state.active ?? false}

diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/a/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/a/+page.svelte new file mode 100644 index 000000000000..3be7397f2aca --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/a/+page.svelte @@ -0,0 +1,7 @@ + + +

a

+ +

active: {$page.state.active ?? false}

diff --git a/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/b/+page.svelte b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/b/+page.svelte new file mode 100644 index 000000000000..30c06a44b409 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/shallow-routing/replace-state/b/+page.svelte @@ -0,0 +1,7 @@ + + +

b

+ +

active: {$page.state.active ?? false}

diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index a201b9cea9d8..7ebc90b310ae 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -913,3 +913,91 @@ test.describe('goto', () => { await expect(page.locator('p')).toHaveText(message); }); }); + +test.describe('Shallow routing', () => { + test('Pushes state to the current URL', async ({ page }) => { + await page.goto('/shallow-routing/push-state'); + await expect(page.locator('p')).toHaveText('active: false'); + + await page.locator('[data-id="one"]').click(); + await expect(page.locator('p')).toHaveText('active: true'); + + await page.goBack(); + await expect(page.locator('p')).toHaveText('active: false'); + }); + + test('Pushes state to a new URL', async ({ baseURL, page }) => { + await page.goto('/shallow-routing/push-state'); + await expect(page.locator('p')).toHaveText('active: false'); + + await page.locator('[data-id="two"]').click(); + expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state/a`); + await expect(page.locator('h1')).toHaveText('parent'); + await expect(page.locator('p')).toHaveText('active: true'); + + await page.reload(); + await expect(page.locator('h1')).toHaveText('a'); + await expect(page.locator('p')).toHaveText('active: false'); + + await page.goBack(); + expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state`); + await expect(page.locator('h1')).toHaveText('parent'); + await expect(page.locator('p')).toHaveText('active: false'); + + await page.goForward(); + expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state/a`); + await expect(page.locator('h1')).toHaveText('parent'); + await expect(page.locator('p')).toHaveText('active: true'); + }); + + test('Invalidates the correct route after pushing state to a new URL', async ({ + baseURL, + page + }) => { + await page.goto('/shallow-routing/push-state'); + await expect(page.locator('p')).toHaveText('active: false'); + + const now = await page.locator('span').textContent(); + + await page.locator('[data-id="two"]').click(); + expect(page.url()).toBe(`${baseURL}/shallow-routing/push-state/a`); + + await page.locator('[data-id="invalidate"]').click(); + await expect(page.locator('h1')).toHaveText('parent'); + await expect(page.locator('span')).not.toHaveText(now); + }); + + test('Replaces state on the current URL', async ({ baseURL, page, clicknav }) => { + await page.goto('/shallow-routing/replace-state/b'); + await clicknav('[href="/shallow-routing/replace-state"]'); + + await page.locator('[data-id="one"]').click(); + await expect(page.locator('p')).toHaveText('active: true'); + + await page.goBack(); + expect(page.url()).toBe(`${baseURL}/shallow-routing/replace-state/b`); + await expect(page.locator('h1')).toHaveText('b'); + + await page.goForward(); + expect(page.url()).toBe(`${baseURL}/shallow-routing/replace-state`); + await expect(page.locator('h1')).toHaveText('parent'); + await expect(page.locator('p')).toHaveText('active: true'); + }); + + test('Replaces state on a new URL', async ({ baseURL, page, clicknav }) => { + await page.goto('/shallow-routing/replace-state/b'); + await clicknav('[href="/shallow-routing/replace-state"]'); + + await page.locator('[data-id="two"]').click(); + await expect(page.locator('p')).toHaveText('active: true'); + + await page.goBack(); + expect(page.url()).toBe(`${baseURL}/shallow-routing/replace-state/b`); + await expect(page.locator('h1')).toHaveText('b'); + + await page.goForward(); + expect(page.url()).toBe(`${baseURL}/shallow-routing/replace-state/a`); + await expect(page.locator('h1')).toHaveText('parent'); + await expect(page.locator('p')).toHaveText('active: true'); + }); +}); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 59c18f3cf923..07840d091f5c 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -943,6 +943,10 @@ declare module '@sveltejs/kit' { * The merged result of all data from all `load` functions on the current page. You can type a common denominator through `App.PageData`. */ data: App.PageData & Record; + /** + * The page state, which can be manipulated using the [`pushState`](https://kit.svelte.dev/docs/modules#$app-navigation-pushstate) and [`replaceState`](https://kit.svelte.dev/docs/modules#$app-navigation-replacestate) functions from `$app/navigation`. + */ + state: App.PageState; /** * Filled only after a form submission. See [form actions](https://kit.svelte.dev/docs/form-actions) for more info. */ @@ -1927,7 +1931,6 @@ declare module '$app/navigation' { noScroll?: boolean; keepFocus?: boolean; invalidateAll?: boolean; - state?: any; }) => Promise; /** * Causes any `load` functions belonging to the currently active page to re-run if they depend on the `url` in question, via `fetch` or `depends`. Returns a `Promise` that resolves when the page is subsequently updated. @@ -1958,11 +1961,11 @@ declare module '$app/navigation' { * * This is the same behaviour that SvelteKit triggers when the user taps or mouses over an `` element with `data-sveltekit-preload-data`. * If the next navigation is to `href`, the values returned from load will be used, making navigation instantaneous. - * Returns a Promise that resolves when the preload is complete. + * Returns a Promise that resolves with the result of running the new route's `load` functions once the preload is complete. * * @param href Page to preload * */ - export const preloadData: (href: string) => Promise; + export const preloadData: (href: string) => Promise>; /** * Programmatically imports the code for routes that haven't yet been fetched. * Typically, you might call this to speed up subsequent navigation. @@ -2002,6 +2005,16 @@ declare module '$app/navigation' { * `afterNavigate` must be called during a component initialization. It remains active as long as the component is mounted. * */ export const afterNavigate: (callback: (navigation: import('@sveltejs/kit').AfterNavigate) => void) => void; + /** + * Programmatically create a new history entry with the given `$page.state`. To use the current URL, you can pass `''` as the first argument. Used for [shallow routing](https://kit.svelte.dev/docs/shallow-routing). + * + * */ + export const pushState: (url: string | URL, state: App.PageState) => void; + /** + * Programmatically replace the current history entry with the given `$page.state`. To use the current URL, you can pass `''` as the first argument. Used for [shallow routing](https://kit.svelte.dev/docs/shallow-routing). + * + * */ + export const replaceState: (url: string | URL, state: App.PageState) => void; type MaybePromise = T | Promise; } @@ -2064,6 +2077,7 @@ declare module '$app/stores' { * // interface Error {} * // interface Locals {} * // interface PageData {} + * // interface PageState {} * // interface Platform {} * } * } @@ -2096,6 +2110,11 @@ declare namespace App { */ export interface PageData {} + /** + * The shape of the `$page.state` object, which can be manipulated using the [`pushState`](https://kit.svelte.dev/docs/modules#$app-navigation-pushstate) and [`replaceState`](https://kit.svelte.dev/docs/modules#$app-navigation-replacestate) functions from `$app/navigation`. + */ + export interface PageState {} + /** * If your adapter provides [platform-specific context](https://kit.svelte.dev/docs/adapters#platform-specific-context) via `event.platform`, you can specify it here. */