Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cf61978
feat: provide match function to allow the opposite of resolve
hmnd Nov 27, 2025
d02f58d
use get_navigation_intent and reroute hook
hmnd Dec 9, 2025
61870b0
support passing url to match
hmnd Dec 9, 2025
f4197f5
fix types
hmnd Dec 9, 2025
2534e43
extract route matching into find_route
hmnd Dec 9, 2025
d8f31c1
clean up
hmnd Dec 14, 2025
aec93c4
Merge branch 'main' into push-mxvotmyyvmzn
teemingc Jan 6, 2026
92581ce
Merge branch 'main' into push-mxvotmyyvmzn
hmnd Jan 25, 2026
2cb375c
Merge branch 'main' into push-mxvotmyyvmzn
teemingc Jan 26, 2026
81192d8
Merge branch 'main' into push-mxvotmyyvmzn
hmnd Feb 5, 2026
c081980
Merge branch 'main' into push-mxvotmyyvmzn
teemingc Feb 5, 2026
7ef251d
Merge branch 'main' into push-mxvotmyyvmzn
PatrickG Feb 9, 2026
e5c5f49
Merge branch 'main' into push-mxvotmyyvmzn
teemingc Feb 11, 2026
74dc3db
Apply suggestion from @teemingc
teemingc Feb 12, 2026
231c584
standardize internal origin to http://internal
hmnd Feb 12, 2026
26b5933
handle server match reroute failures
hmnd Feb 12, 2026
71c9955
improve match documentation example
hmnd Feb 12, 2026
ca85247
tests: add match base path and search param coverage
hmnd Feb 12, 2026
1ccc74c
touch up
hmnd Feb 12, 2026
99f4abe
Merge branch 'main' into push-mxvotmyyvmzn
hmnd Feb 12, 2026
a93eb14
update generated types
hmnd Feb 13, 2026
6b65a35
Apply suggestion from @teemingc
teemingc Feb 13, 2026
61c2bda
generate types
teemingc Feb 13, 2026
dc01e8b
Merge branch 'main' into push-mxvotmyyvmzn
teemingc Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/new-bugs-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: `match` function to map a path back to a route id and params
38 changes: 38 additions & 0 deletions packages/kit/src/runtime/app/paths/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/** @import { ResolveArgs } from './types.js' */
import { base, assets, hash_routing } from './internal/client.js';
import { resolve_route } from '../../../utils/routing.js';
import { get_navigation_intent } from '../../client/client.js';

/**
* Resolve the URL of an asset in your `static` directory, by prefixing it with [`config.kit.paths.assets`](https://svelte.dev/docs/kit/configuration#paths) if configured, or otherwise by prefixing it with the base path.
Expand Down Expand Up @@ -58,4 +59,41 @@ export function resolve(...args) {
);
}

/**
* Match a path or URL to a route ID and extracts any parameters.
*
* @example
* ```js
* import { match } from '$app/paths';
*
* const route = await match('/blog/hello-world');
*
* if (route?.id === '/blog/[slug]') {
* const slug = route.params.slug;
* const response = await fetch(`/api/posts/${slug}`);
* const post = await response.json();
* }
* ```
* @since 2.52.0
*
* @param {Pathname | URL | (string & {})} url
* @returns {Promise<{ id: RouteId, params: Record<string, string> } | null>}
*/
Comment thread
teemingc marked this conversation as resolved.
export async function match(url) {
if (typeof url === 'string') {
url = new URL(url, location.href);
}

const intent = await get_navigation_intent(url, false);

if (intent) {
return {
id: /** @type {RouteId} */ (intent.route.id),
Comment thread
hmnd marked this conversation as resolved.
params: intent.params
};
}

return null;
}

export { base, assets, resolve as resolveRoute };
2 changes: 1 addition & 1 deletion packages/kit/src/runtime/app/paths/public.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { RouteId, Pathname, ResolvedPathname } from '$app/types';
import { ResolveArgs } from './types.js';

export { resolve, asset } from './client.js';
export { resolve, asset, match } from './client.js';

/**
* A string that matches [`config.kit.paths.base`](https://svelte.dev/docs/kit/configuration#paths).
Expand Down
43 changes: 42 additions & 1 deletion packages/kit/src/runtime/app/paths/server.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { base, assets, relative, initial_base } from './internal/server.js';
import { resolve_route } from '../../../utils/routing.js';
import { resolve_route, find_route } from '../../../utils/routing.js';
import { decode_pathname } from '../../../utils/url.js';
import { try_get_request_store } from '@sveltejs/kit/internal/server';
import { manifest } from '__sveltekit/server';
import { get_hooks } from '__SERVER__/internal.js';

/** @type {import('./client.js').asset} */
export function asset(file) {
Expand All @@ -27,4 +30,42 @@ export function resolve(id, params) {
return base + resolved;
}

/** @type {import('./client.js').match} */
export async function match(url) {
const store = try_get_request_store();

if (typeof url === 'string') {
const origin = store?.event.url.origin ?? 'http://internal';
url = new URL(url, origin);
}

const { reroute } = await get_hooks();

let resolved_path = url.pathname;

try {
resolved_path = decode_pathname(
(await reroute?.({ url: new URL(url), fetch: store?.event.fetch ?? fetch })) ?? url.pathname
);
} catch {
return null;
}

if (base && resolved_path.startsWith(base)) {
resolved_path = resolved_path.slice(base.length) || '/';
}

const matchers = await manifest._.matchers();
const result = find_route(resolved_path, manifest._.routes, matchers);

if (result) {
return {
id: /** @type {import('$app/types').RouteId} */ (result.route.id),
params: result.params
};
}

return null;
}

export { base, assets, resolve as resolveRoute };
2 changes: 1 addition & 1 deletion packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1401,7 +1401,7 @@ async function get_rerouted_url(url) {
* @param {boolean} invalidating
* @returns {Promise<import('./types.js').NavigationIntent | undefined>}
*/
async function get_navigation_intent(url, invalidating) {
export async function get_navigation_intent(url, invalidating) {
if (!url) return;
if (is_external_url(url, base, app.hash)) return;

Expand Down
24 changes: 4 additions & 20 deletions packages/kit/src/runtime/server/page/server_routing.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { base, assets, relative } from '$app/paths/internal/server';
import { text } from '@sveltejs/kit';
import { s } from '../../../utils/misc.js';
import { exec } from '../../../utils/routing.js';
import { decode_params } from '../../../utils/url.js';
import { find_route } from '../../../utils/routing.js';
import { get_relative_path } from '../../utils.js';

/**
Expand Down Expand Up @@ -69,26 +68,11 @@ export async function resolve_route(resolved_path, url, manifest) {
return text('Server-side route resolution disabled', { status: 400 });
}

/** @type {import('types').SSRClientRoute | null} */
let route = null;
/** @type {Record<string, string>} */
let params = {};

const matchers = await manifest._.matchers();
const result = find_route(resolved_path, manifest._.client.routes, matchers);

for (const candidate of manifest._.client.routes) {
const match = candidate.pattern.exec(resolved_path);
if (!match) continue;

const matched = exec(match, candidate.params, matchers);
if (matched) {
route = candidate;
params = decode_params(matched);
break;
}
}

return create_server_routing_response(route, params, url, manifest).response;
return create_server_routing_response(result?.route ?? null, result?.params ?? {}, url, manifest)
.response;
}

/**
Expand Down
20 changes: 7 additions & 13 deletions packages/kit/src/runtime/server/respond.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import {
method_not_allowed,
redirect_response
} from './utils.js';
import { decode_pathname, decode_params, disable_search, normalize_path } from '../../utils/url.js';
import { exec } from '../../utils/routing.js';
import { decode_pathname, disable_search, normalize_path } from '../../utils/url.js';
import { find_route } from '../../utils/routing.js';
import { redirect_json_response, render_data } from './data/index.js';
import { add_cookies_to_headers, get_cookies } from './cookie.js';
import { create_fetch } from './fetch.js';
Expand Down Expand Up @@ -309,18 +309,12 @@ export async function internal_respond(request, options, manifest, state) {
if (!state.prerendering?.fallback) {
// TODO this could theoretically break — should probably be inside a try-catch
const matchers = await manifest._.matchers();
const result = find_route(resolved_path, manifest._.routes, matchers);

for (const candidate of manifest._.routes) {
const match = candidate.pattern.exec(resolved_path);
if (!match) continue;

const matched = exec(match, candidate.params, matchers);
if (matched) {
route = candidate;
event.route = { id: route.id };
event.params = decode_params(matched);
break;
}
if (result) {
route = result.route;
event.route = { id: route.id };
event.params = result.params;
}
}

Expand Down
25 changes: 25 additions & 0 deletions packages/kit/src/utils/routing.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BROWSER } from 'esm-env';
import { decode_params } from './url.js';

const param_pattern = /^(\[)?(\.\.\.)?(\w+)(?:=(\w+))?(\])?$/;

Expand Down Expand Up @@ -280,3 +281,27 @@ export function resolve_route(id, params) {
export function has_server_load(node) {
return node.server?.load !== undefined || node.server?.trailingSlash !== undefined;
}

/**
* Find the first route that matches the given path
* @template {{pattern: RegExp, params: import('types').RouteParam[]}} Route
* @param {string} path - The decoded pathname to match
* @param {Route[]} routes
* @param {Record<string, import('@sveltejs/kit').ParamMatcher>} matchers
* @returns {{ route: Route, params: Record<string, string> } | null}
*/
export function find_route(path, routes, matchers) {
for (const route of routes) {
const match = route.pattern.exec(path);
if (!match) continue;

const matched = exec(match, route.params, matchers);
if (matched) {
return {
route,
params: decode_params(matched)
};
}
}
return null;
}
55 changes: 54 additions & 1 deletion packages/kit/src/utils/routing.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { assert, expect, test, describe } from 'vitest';
import { exec, parse_route_id, resolve_route } from './routing.js';
import { exec, parse_route_id, resolve_route, find_route } from './routing.js';

describe('parse_route_id', () => {
const tests = {
Expand Down Expand Up @@ -379,3 +379,56 @@ describe('resolve_route', () => {
);
});
});

describe('find_route', () => {
/** @param {string} id */
function create_route(id) {
const { pattern, params } = parse_route_id(id);
return { id, pattern, params };
}

test('finds matching route', () => {
const routes = [create_route('/blog'), create_route('/blog/[slug]'), create_route('/about')];

const result = find_route('/blog/hello-world', routes, {});
assert.equal(result?.route.id, '/blog/[slug]');
assert.deepEqual(result?.params, { slug: 'hello-world' });
});

test('returns first matching route', () => {
const routes = [create_route('/blog/[slug]'), create_route('/blog/[...rest]')];

const result = find_route('/blog/hello', routes, {});
assert.equal(result?.route.id, '/blog/[slug]');
});

test('returns null for no match', () => {
const routes = [create_route('/blog'), create_route('/about')];

const result = find_route('/contact', routes, {});
assert.equal(result, null);
});

test('respects matchers', () => {
const routes = [create_route('/blog/[slug=word]'), create_route('/blog/[slug]')];
/** @type {Record<string, import('@sveltejs/kit').ParamMatcher>} */
const matchers = {
word: (param) => /^\w+$/.test(param)
};

// "hello" matches the word matcher
const result1 = find_route('/blog/hello', routes, matchers);
assert.equal(result1?.route.id, '/blog/[slug=word]');

// "hello-world" doesn't match word matcher, falls through to [slug]
const result2 = find_route('/blog/hello-world', routes, matchers);
assert.equal(result2?.route.id, '/blog/[slug]');
});

test('decodes params', () => {
const routes = [create_route('/blog/[slug]')];

const result = find_route('/blog/hello%20world', routes, {});
assert.equal(result?.params.slug, 'hello world');
});
});
12 changes: 12 additions & 0 deletions packages/kit/test/apps/basics/src/routes/match/+page.server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { match } from '$app/paths';
import { testPaths } from './const';

export async function load() {
const serverResults = await Promise.all(
testPaths.map(async (path) => ({ path, result: await match(path) }))
);

return {
serverResults
};
}
34 changes: 34 additions & 0 deletions packages/kit/test/apps/basics/src/routes/match/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script>
import { match } from '$app/paths';
import { onMount } from 'svelte';
import { testPaths } from './const';

let { data } = $props();

const clientResults = $state([]);

onMount(async () => {
for (const path of testPaths) {
const result = await match(path);
clientResults.push({ path, result });
}
});
</script>

<h1>Match Test</h1>

<div id="server-results">
{#each data.serverResults as { path, result }}
<div class="result" data-path={path}>
{JSON.stringify(result)}
</div>
{/each}
</div>

<div id="client-results">
{#each clientResults as { path, result }}
<div class="result" data-path={path}>
{JSON.stringify(result)}
</div>
{/each}
</div>
7 changes: 7 additions & 0 deletions packages/kit/test/apps/basics/src/routes/match/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const testPaths = [
'/match/load/foo',
'/match/slug/test-slug',
'/match/slug/test-slug?from=query',
'/match/not-a-real-route-that-exists',
'/reroute/basic/a'
];
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bar
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
slug
28 changes: 28 additions & 0 deletions packages/kit/test/apps/basics/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@
expect(await page.textContent('h1')).toBe('bar == bar?');
});

test('GET fetches are serialized', async ({ page, javaScriptEnabled }) => {

Check warning on line 226 in packages/kit/test/apps/basics/test/test.js

View workflow job for this annotation

GitHub Actions / test-kit-server-side-route-resolution (build)

flaky test: GET fetches are serialized

retries: 2
/** @type {string[]} */
const requests = [];
page.on('request', (r) => requests.push(r.url()));
Expand Down Expand Up @@ -406,7 +406,7 @@
expect(await page.textContent('h1')).toBe('text.length is 5000000');
});

test('handles external api', async ({ page, start_server }) => {

Check warning on line 409 in packages/kit/test/apps/basics/test/test.js

View workflow job for this annotation

GitHub Actions / test-kit (22, ubuntu-latest, chromium, current)

flaky test: handles external api

retries: 2
/** @type {string[]} */
const requested_urls = [];

Expand Down Expand Up @@ -774,6 +774,34 @@
javaScriptEnabled ? absolute : '../../../../favicon.png'
);
});

test('match() returns route id and params for matching routes', async ({
Comment thread
teemingc marked this conversation as resolved.
page,
javaScriptEnabled
}) => {
await page.goto('/match');

const samples = [
{ path: '/match/load/foo', expected: { id: '/match/load/foo', params: {} } },
{
path: '/match/slug/test-slug',
expected: { id: '/match/slug/[slug]', params: { slug: 'test-slug' } }
},
{
path: '/match/slug/test-slug?from=query',
expected: { id: '/match/slug/[slug]', params: { slug: 'test-slug' } }
},
{ path: '/match/not-a-real-route-that-exists', expected: null },
{ path: '/reroute/basic/a', expected: { id: '/reroute/basic/b', params: {} } }
];

for (const { path, expected } of samples) {
const results = javaScriptEnabled
? page.locator('#client-results')
: page.locator('#server-results');
await expect(results.locator(`[data-path="${path}"]`)).toHaveText(JSON.stringify(expected));
}
});
});

// TODO SvelteKit 3: remove these tests
Expand All @@ -783,7 +811,7 @@
expect(await page.textContent('h1')).toBe(baseURL);
});

test('page store contains data', async ({ page, clicknav }) => {

Check warning on line 814 in packages/kit/test/apps/basics/test/test.js

View workflow job for this annotation

GitHub Actions / test-kit (24, ubuntu-latest, chromium, beta)

flaky test: page store contains data

retries: 2
await page.goto('/store/data/www');

const foo = { bar: 'Custom layout' };
Expand Down Expand Up @@ -1486,7 +1514,7 @@
expect(await span.innerText()).toContain('undefined');
});

test('set a cookie', async ({ page }) => {

Check warning on line 1517 in packages/kit/test/apps/basics/test/test.js

View workflow job for this annotation

GitHub Actions / test-kit-server-side-route-resolution (build)

flaky test: set a cookie

retries: 2
await page.goto('/cookies/set');
const span = page.locator('#cookie-value');
expect(await span.innerText()).toContain('teapot');
Expand Down Expand Up @@ -1609,7 +1637,7 @@
});

test.describe('params prop', () => {
test('params prop is passed to the page', async ({ page, clicknav }) => {

Check warning on line 1640 in packages/kit/test/apps/basics/test/test.js

View workflow job for this annotation

GitHub Actions / test-kit (20, ubuntu-latest, chromium, current)

flaky test: params prop is passed to the page

retries: 2
await page.goto('/params-prop');

await clicknav('[href="/params-prop/123"]');
Expand Down
Loading
Loading