Skip to content

Commit

Permalink
feat: hash based routing (#13191)
Browse files Browse the repository at this point in the history
* hash based routing

* tests

* fix test

* fml this took me 40 minutes to figure out

* for push/replaceState, too

* more robust tests

* please?

* change approach: make hash visible in page.url etc, require user to pass in hash links

* lint, fix test

* Apply suggestions from code review

* hashchange handling

* rename 'history' to 'pathname', update docs

* update internal jsdoc

* disable SSR and server-only files

* disallow page options at build time

* oops

* allow access to event.url.hash in hash mode

* prerender shell

* add failing reroute test

* fix

* make hash trackable

* skip test for now

* don't normalise

* lint

* i don't understand this code — even with the prior normalization it's unclear what it would do. no tests fail without it so for now i'm just gonna remove to shut typescript up

* get_route_id -> get_page_key

* detect hash changes via URL bar

---------

Co-authored-by: Rich Harris <rich.harris@vercel.com>
  • Loading branch information
dummdidumm and Rich-Harris authored Dec 21, 2024
1 parent 7afa167 commit fe8c37c
Show file tree
Hide file tree
Showing 35 changed files with 461 additions and 46 deletions.
5 changes: 5 additions & 0 deletions .changeset/thirty-wasps-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: add hash-based routing option
3 changes: 3 additions & 0 deletions packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ const get_defaults = (prefix = '') => ({
moduleExtensions: ['.js', '.ts'],
output: { preloadStrategy: 'modulepreload', bundleStrategy: 'split' },
outDir: join(prefix, '.svelte-kit'),
router: {
type: 'pathname'
},
serviceWorker: {
register: true
},
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ const options = object(
})
}),

router: object({
type: list(['pathname', 'hash'])
}),

serviceWorker: object({
register: boolean(true),
files: fun((filename) => !/\.DS_Store/.test(filename))
Expand Down
22 changes: 21 additions & 1 deletion packages/kit/src/core/postbuild/analyse.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,22 @@ export default forked(import.meta.url, analyse);

/**
* @param {{
* hash: boolean;
* manifest_path: string;
* manifest_data: import('types').ManifestData;
* server_manifest: import('vite').Manifest;
* tracked_features: Record<string, string[]>;
* env: Record<string, string>
* }} opts
*/
async function analyse({ manifest_path, manifest_data, server_manifest, tracked_features, env }) {
async function analyse({
hash,
manifest_path,
manifest_data,
server_manifest,
tracked_features,
env
}) {
/** @type {import('@sveltejs/kit').SSRManifest} */
const manifest = (await import(pathToFileURL(manifest_path).href)).manifest;

Expand Down Expand Up @@ -67,6 +75,18 @@ async function analyse({ manifest_path, manifest_data, server_manifest, tracked_

// analyse nodes
for (const node of nodes) {
if (hash && node.universal) {
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'\` (${node.universal_id} has ${options
.filter((o) => o !== 'load')
.map((o) => `'${o}'`)
.join(', ')})`
);
}
}

metadata.nodes[node.index] = {
has_server_load: node.server?.load !== undefined || node.server?.trailingSlash !== undefined
};
Expand Down
21 changes: 20 additions & 1 deletion packages/kit/src/core/postbuild/prerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { crawl } from './crawl.js';
import { forked } from '../../utils/fork.js';
import * as devalue from 'devalue';
import { createReadableStream } from '@sveltejs/kit/node';
import generate_fallback from './fallback.js';

export default forked(import.meta.url, prerender);

Expand All @@ -24,14 +25,15 @@ const SPECIAL_HASHLINKS = new Set(['', 'top']);

/**
* @param {{
* hash: boolean;
* out: string;
* manifest_path: string;
* metadata: import('types').ServerMetadata;
* verbose: boolean;
* env: Record<string, string>
* }} opts
*/
async function prerender({ out, manifest_path, metadata, verbose, env }) {
async function prerender({ hash, out, manifest_path, metadata, verbose, env }) {
/** @type {import('@sveltejs/kit').SSRManifest} */
const manifest = (await import(pathToFileURL(manifest_path).href)).manifest;

Expand Down Expand Up @@ -98,6 +100,23 @@ async function prerender({ out, manifest_path, metadata, verbose, env }) {
/** @type {import('types').ValidatedKitConfig} */
const config = (await load_config()).kit;

if (hash) {
const fallback = await generate_fallback({
manifest_path,
env
});

const file = output_filename('/', true);
const dest = `${config.outDir}/output/prerendered/pages/${file}`;

mkdirp(dirname(dest));
writeFileSync(dest, fallback);

prerendered.pages.set('/', { file });

return { prerendered, prerender_map };
}

const emulator = await config.adapter?.emulate?.();

/** @type {import('types').Logger} */
Expand Down
6 changes: 6 additions & 0 deletions packages/kit/src/core/sync/create_manifest_data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,12 @@ function create_routes_and_nodes(cwd, config, fallback) {
config.kit.moduleExtensions
);

if (config.kit.router.type === 'hash' && item.kind === 'server') {
throw new Error(
`Cannot use server-only files in an app with \`router.type === 'hash': ${project_relative}`
);
}

/**
* @param {string} type
* @param {string} existing_file
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/core/sync/write_client_manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ export function write_client_manifest(kit, manifest_data, output, metadata) {
export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode]));
export const hash = ${JSON.stringify(kit.router.type === 'hash')};
export const decode = (type, value) => decoders[type](value);
export { default as root } from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}';
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/sync/write_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const options = {
embedded: ${config.kit.embedded},
env_public_prefix: '${config.kit.env.publicPrefix}',
env_private_prefix: '${config.kit.env.privatePrefix}',
hash_routing: ${s(config.kit.router.type === 'hash')},
hooks: null, // added lazily, via \`get_hooks\`
preload_strategy: ${s(config.kit.output.preloadStrategy)},
root,
Expand Down
10 changes: 10 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,16 @@ export interface KitConfig {
*/
origin?: string;
};
router?: {
/**
* What type of client-side router to use.
* - `'pathname'` is the default and means the current URL pathname determines the route
* - `'hash'` means the route is determined by `location.hash`. In this case, SSR and prerendering are disabled. This is only recommended if `pathname` is not an option, for example because you don't control the webserver where your app is deployed.
*
* @default "pathname"
*/
type?: 'pathname' | 'hash';
};
serviceWorker?: {
/**
* Whether to automatically register the service worker, if it exists.
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/exports/vite/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,7 @@ async function kit({ svelte_config }) {
);

const metadata = await analyse({
hash: kit.router.type === 'hash',
manifest_path,
manifest_data,
server_manifest,
Expand Down Expand Up @@ -897,6 +898,7 @@ async function kit({ svelte_config }) {

// ...and prerender
const { prerendered, prerender_map } = await prerender({
hash: kit.router.type === 'hash',
out,
manifest_path,
metadata,
Expand Down
107 changes: 82 additions & 25 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,19 @@ async function load_node({ loader, parent, url, params, route, server_data_node

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) {
Expand Down Expand Up @@ -653,7 +666,8 @@ async function load_node({ loader, parent, url, params, route, server_data_node
if (is_tracking) {
uses.search_params.add(param);
}
}
},
app.hash
),
async fetch(resource, init) {
/** @type {URL | string} */
Expand Down Expand Up @@ -810,7 +824,6 @@ function create_data_node(node, previous) {
}

/**
*
* @param {URL | null} old_url
* @param {URL} new_url
*/
Expand Down Expand Up @@ -1169,16 +1182,21 @@ async function load_root_error_page({ status, error, url, route }) {
* @param {boolean} invalidating
*/
function get_navigation_intent(url, invalidating) {
if (!url) return undefined;
if (is_external_url(url, base)) return;
if (!url) return;
if (is_external_url(url, base, app.hash)) return;

// reroute could alter the given URL, so we pass a copy
let rerouted;
try {
rerouted = app.hooks.reroute({ url: new URL(url) }) ?? url;

if (typeof rerouted === 'string') {
url.pathname = rerouted;
if (app.hash) {
url.hash = rerouted;
} else {
url.pathname = rerouted;
}

rerouted = url;
}
} catch (e) {
Expand Down Expand Up @@ -1216,12 +1234,16 @@ function get_navigation_intent(url, invalidating) {

/** @param {URL} url */
function get_url_path(url) {
return decode_pathname(url.pathname.slice(base.length) || '/');
return (
decode_pathname(
app.hash ? url.hash.replace(/^#/, '').replace(/[?#].+/, '') : url.pathname.slice(base.length)
) || '/'
);
}

/** @param {URL} url */
function get_page_key(url) {
return url.pathname + url.search;
return (app.hash ? url.hash.replace(/^#/, '') : url.pathname) + url.search;
}

/**
Expand Down Expand Up @@ -1313,19 +1335,42 @@ async function navigate({
let navigation_result = intent && (await load_route(intent));

if (!navigation_result) {
if (is_external_url(url, base)) {
return await native_navigation(url);
}
navigation_result = await server_fallback(
url,
{ id: null },
await handle_error(new SvelteKitError(404, 'Not Found', `Not found: ${url.pathname}`), {
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,
params: {},
route: { id: null }
}),
404
);
{ 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
Expand Down Expand Up @@ -1447,7 +1492,11 @@ async function navigate({
const scroll = popped ? popped.scroll : noscroll ? scroll_state() : null;

if (autoscroll) {
const deep_linked = url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1)));
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) {
Expand Down Expand Up @@ -1573,7 +1622,7 @@ function setup_preload() {
const a = find_anchor(element, container);
if (!a) return;

const { url, external, download } = get_link_info(a, base);
const { url, external, download } = get_link_info(a, base, app.hash);
if (external || download) return;

const options = get_router_options(a);
Expand Down Expand Up @@ -1610,7 +1659,7 @@ function setup_preload() {
observer.disconnect();

for (const a of container.querySelectorAll('a')) {
const { url, external, download } = get_link_info(a, base);
const { url, external, download } = get_link_info(a, base, app.hash);
if (external || download) continue;

const options = get_router_options(a);
Expand Down Expand Up @@ -2085,7 +2134,7 @@ function _start_router() {
const a = find_anchor(/** @type {Element} */ (event.composedPath()[0]), container);
if (!a) return;

const { url, external, target, download } = get_link_info(a, base);
const { url, external, target, download } = get_link_info(a, base, app.hash);
if (!url) return;

// bail out before `beforeNavigate` if link opens in a different tab
Expand Down Expand Up @@ -2115,7 +2164,7 @@ function _start_router() {

if (download) return;

const [nonhash, hash] = url.href.split('#');
const [nonhash, hash] = (app.hash ? url.hash.replace(/^#/, '') : url.href).split('#');
const same_pathname = nonhash === strip_hash(location);

// Ignore the following but fire beforeNavigate
Expand Down Expand Up @@ -2210,11 +2259,12 @@ function _start_router() {

if (method !== 'get') return;

// It is impossible to use form actions with hash router, so we just ignore handling them here
const url = new URL(
(submitter?.hasAttribute('formaction') && submitter?.formAction) || form.action
);

if (is_external_url(url, base)) return;
if (is_external_url(url, base, false)) return;

const event_form = /** @type {HTMLFormElement} */ (event.target);

Expand Down Expand Up @@ -2323,6 +2373,13 @@ function _start_router() {
'',
location.href
);
} else if (app.hash) {
// If the user edits the hash via the browser URL bar, it
// (surprisingly!) mutates `current.url`, allowing us to
// detect it and trigger a navigation
if (current.url.hash === location.hash) {
navigate({ type: 'goto', url: current.url });
}
}
});

Expand Down
Loading

0 comments on commit fe8c37c

Please sign in to comment.