diff --git a/README.md b/README.md
index 03cbb71..28993e2 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,8 @@
[@svelte-router/kit](https://github.com/WJSoftware/svelte-router-kit)
+ **Electron support**: Works with Electron (all routing modes)
+ **Reactivity-based**: All data is reactive, reducing the need for events and imperative programming.
++ **⚡NEW! URL Redirection**: Use `Redirector` instances to route users from deprecated URL's to new URL's, even across
+routing universes.
**Components**:
@@ -470,6 +472,67 @@ As seen, the value of the `href` property never changes. It's always a path, re
At your own risk, you could use exported API like `getRouterContext()` and `setRouterContext()` to perform unholy acts
on the router layouts, again, **at your own risk**.
+## URL Redirection
+
+Create `Redirector` class instances to route users from deprecated URL's to new URL's. The redirection can even cross
+the routing universe boundary. In other words, URL's from one routing universe can be redirected to a different
+routing universe.
+
+This is a same-universe example:
+
+```svelte
+
+```
+
+The constructor of the class sets a Svelte `$effect` up, so instances of this class must be created in places where
+Svelte effects are acceptable, like the initialization code of a component (like in the example).
+
+Redirections are almost identical to route definitions, and even use the same matching algorithm. The `pattern` is
+used to match the current URL (it defines the deprecated URL), while `href` defines the new URL users will be
+redirected to. As seen in the example, parameters can be defined, and `href`, when written as a function, receives
+the route parameters as the first argument.
+
+### Cross-Universe Redirection
+
+Crossing the universe boundary when redirecting is very simple, but there's a catch: Cleaning up the old URL.
+
+```svelte
+
+```
+
+The modifications in the example are:
+
+1. Explicit hash value in the redirector's constructor.
+2. Destination hash value specifications via options.
+
+Now comes the aforementioned catch: The "final" URL will be looking like this: `https://example.com/orders/123#/profile/my-orders/123`.
+
+There's no good way for this library to provide a safe way to "clean up" the path in the deprecated routing universe,
+so it is up to consumers of this library to clean up. How? The recommendation is to tell the redirector to use
+`location.goTo()` and provide a full HREF with all universes accounted for.
+
+See the [Redirecting](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/navigating/redirecting) topic in the online
+documentation for full details, including helper functions available to you.
+
---
[Issues Here](https://github.com/WJSoftware/svelte-router-core/issues)
diff --git a/src/lib/buildHref.test.ts b/src/lib/buildHref.test.ts
new file mode 100644
index 0000000..c6cee7c
--- /dev/null
+++ b/src/lib/buildHref.test.ts
@@ -0,0 +1,310 @@
+import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'vitest';
+import { buildHref } from './buildHref.js';
+import { init } from './init.js';
+import { location } from './kernel/Location.js';
+
+describe('buildHref', () => {
+ let cleanup: Function;
+ beforeAll(() => {
+ cleanup = init();
+ });
+ afterAll(() => {
+ cleanup();
+ });
+
+ beforeEach(() => {
+ // Reset to a clean base URL for each test
+ location.url.href = 'https://example.com/current?currentParam=value';
+ });
+
+ describe('Basic functionality', () => {
+ test('Should combine path from first HREF and hash from second HREF.', () => {
+ const pathPiece = 'https://example.com/new-path';
+ const hashPiece = 'https://example.com/any-path#new-hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/new-path#new-hash');
+ });
+
+ test('Should handle relative URLs correctly.', () => {
+ const pathPiece = '/relative-path';
+ const hashPiece = '/any-path#relative-hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/relative-path#relative-hash');
+ });
+
+ test('Should work when pathPiece has no path component.', () => {
+ const pathPiece = 'https://example.com/';
+ const hashPiece = 'https://example.com/#hash-only';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/#hash-only');
+ });
+
+ test('Should work when hashPiece has no hash component.', () => {
+ const pathPiece = 'https://example.com/path-only';
+ const hashPiece = 'https://example.com/any-path';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path-only');
+ });
+
+ test('Should handle empty hash correctly.', () => {
+ const pathPiece = 'https://example.com/path';
+ const hashPiece = 'https://example.com/any-path#';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path');
+ });
+ });
+
+ describe('Query parameter merging', () => {
+ test('Should merge query parameters from both pieces.', () => {
+ const pathPiece = 'https://example.com/path?pathParam=pathValue';
+ const hashPiece = 'https://example.com/any-path?hashParam=hashValue#hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path?pathParam=pathValue&hashParam=hashValue#hash');
+ });
+
+ test('Should handle query parameters in pathPiece only.', () => {
+ const pathPiece = 'https://example.com/path?onlyPath=value';
+ const hashPiece = 'https://example.com/any-path#hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path?onlyPath=value#hash');
+ });
+
+ test('Should handle query parameters in hashPiece only.', () => {
+ const pathPiece = 'https://example.com/path';
+ const hashPiece = 'https://example.com/any-path?onlyHash=value#hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path?onlyHash=value#hash');
+ });
+
+ test('Should handle duplicate parameter names by keeping both values.', () => {
+ const pathPiece = 'https://example.com/path?shared=pathValue';
+ const hashPiece = 'https://example.com/any-path?shared=hashValue#hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path?shared=pathValue&shared=hashValue#hash');
+ });
+
+ test('Should handle multiple parameters in both pieces.', () => {
+ const pathPiece = 'https://example.com/path?param1=value1¶m2=value2';
+ const hashPiece = 'https://example.com/any-path?param3=value3¶m4=value4#hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path?param1=value1¶m2=value2¶m3=value3¶m4=value4#hash');
+ });
+
+ test('Should work with empty query strings.', () => {
+ const pathPiece = 'https://example.com/path?';
+ const hashPiece = 'https://example.com/any-path?#hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path#hash');
+ });
+ });
+
+ describe('preserveQuery option', () => {
+ beforeEach(() => {
+ // Set up current URL with query parameters to preserve
+ location.url.href = 'https://example.com/current?preserve1=value1&preserve2=value2&preserve3=value3';
+ });
+
+ test('Should preserve all current query parameters when preserveQuery is true.', () => {
+ const pathPiece = 'https://example.com/path?new=param';
+ const hashPiece = 'https://example.com/any-path#hash';
+
+ const result = buildHref(pathPiece, hashPiece, { preserveQuery: true });
+
+ expect(result).toBe('/path?new=param&preserve1=value1&preserve2=value2&preserve3=value3#hash');
+ });
+
+ test('Should preserve specific query parameter when preserveQuery is a string.', () => {
+ const pathPiece = 'https://example.com/path';
+ const hashPiece = 'https://example.com/any-path#hash';
+
+ const result = buildHref(pathPiece, hashPiece, { preserveQuery: 'preserve2' });
+
+ expect(result).toBe('/path?preserve2=value2#hash');
+ });
+
+ test('Should preserve specific query parameters when preserveQuery is an array.', () => {
+ const pathPiece = 'https://example.com/path';
+ const hashPiece = 'https://example.com/any-path#hash';
+
+ const result = buildHref(pathPiece, hashPiece, { preserveQuery: ['preserve1', 'preserve3'] });
+
+ expect(result).toBe('/path?preserve1=value1&preserve3=value3#hash');
+ });
+
+ test('Should not preserve any parameters when preserveQuery is false.', () => {
+ const pathPiece = 'https://example.com/path?new=param';
+ const hashPiece = 'https://example.com/any-path#hash';
+
+ const result = buildHref(pathPiece, hashPiece, { preserveQuery: false });
+
+ expect(result).toBe('/path?new=param#hash');
+ });
+
+ test('Should not preserve any parameters when preserveQuery is not specified.', () => {
+ const pathPiece = 'https://example.com/path?new=param';
+ const hashPiece = 'https://example.com/any-path#hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path?new=param#hash');
+ });
+
+ test('Should handle preserveQuery with existing merged parameters.', () => {
+ const pathPiece = 'https://example.com/path?fromPath=pathVal';
+ const hashPiece = 'https://example.com/any-path?fromHash=hashVal#hash';
+
+ const result = buildHref(pathPiece, hashPiece, { preserveQuery: 'preserve2' });
+
+ expect(result).toBe('/path?fromPath=pathVal&fromHash=hashVal&preserve2=value2#hash');
+ });
+
+ test('Should handle non-existent preserve parameter gracefully.', () => {
+ const pathPiece = 'https://example.com/path';
+ const hashPiece = 'https://example.com/any-path#hash';
+
+ const result = buildHref(pathPiece, hashPiece, { preserveQuery: 'nonExistent' });
+
+ expect(result).toBe('/path#hash');
+ });
+ });
+
+ describe('Edge cases', () => {
+ test('Should handle both pieces being the same URL.', () => {
+ const sameUrl = 'https://example.com/same?param=value#hash';
+
+ const result = buildHref(sameUrl, sameUrl);
+
+ expect(result).toBe('/same?param=value¶m=value#hash');
+ });
+
+ test('Should handle URLs with different domains.', () => {
+ const pathPiece = 'https://other-domain.com/path?param=value';
+ const hashPiece = 'https://another-domain.com/any-path#hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path?param=value#hash');
+ });
+
+ test('Should handle URLs with special characters in parameters.', () => {
+ const pathPiece = 'https://example.com/path?special=hello%20world';
+ const hashPiece = 'https://example.com/any-path?encoded=test%2Bvalue#hash%20with%20spaces';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path?special=hello+world&encoded=test%2Bvalue#hash%20with%20spaces');
+ });
+
+ test('Should handle root paths correctly.', () => {
+ const pathPiece = 'https://example.com/';
+ const hashPiece = 'https://example.com/#root-hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/#root-hash');
+ });
+
+ test('Should handle complex hash fragments.', () => {
+ const pathPiece = 'https://example.com/path';
+ const hashPiece = 'https://example.com/any-path#/complex/hash/route?hashParam=value';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path#/complex/hash/route?hashParam=value');
+ });
+ });
+
+ describe('Cross-universe redirection use case', () => {
+ test('Should support typical cross-universe redirection scenario.', () => {
+ // Simulate getting path piece from path router and hash piece from hash router
+ const pathUniverseHref = 'https://example.com/users/profile?pathParam=value';
+ const hashUniverseHref = 'https://example.com/current#/dashboard/settings?hashParam=value';
+
+ const result = buildHref(pathUniverseHref, hashUniverseHref);
+
+ expect(result).toBe('/users/profile?pathParam=value#/dashboard/settings?hashParam=value');
+ });
+
+ test('Should handle preserving current query in cross-universe scenario.', () => {
+ location.url.href = 'https://example.com/current?globalParam=global&session=active';
+
+ const pathUniverseHref = 'https://example.com/users/profile';
+ const hashUniverseHref = 'https://example.com/current#/dashboard';
+
+ const result = buildHref(pathUniverseHref, hashUniverseHref, { preserveQuery: ['session'] });
+
+ expect(result).toBe('/users/profile?session=active#/dashboard');
+ });
+ });
+
+ describe('Additional edge cases', () => {
+ test('Should handle URL fragments with encoded characters.', () => {
+ const pathPiece = 'https://example.com/path';
+ const hashPiece = 'https://example.com/any#%20encoded%20hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path#%20encoded%20hash');
+ });
+
+ test('Should handle when both pieces have same domain but different protocols.', () => {
+ const pathPiece = 'http://example.com/path';
+ const hashPiece = 'https://example.com/other#hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path#hash');
+ });
+
+ test('Should handle query parameters with empty values.', () => {
+ const pathPiece = 'https://example.com/path?empty=';
+ const hashPiece = 'https://example.com/other?also=&blank=#hash';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/path?empty=&also=&blank=#hash');
+ });
+
+ test('Should handle preserveQuery with empty current URL query.', () => {
+ location.url.href = 'https://example.com/current'; // No query parameters
+
+ const pathPiece = 'https://example.com/path?new=param';
+ const hashPiece = 'https://example.com/other#hash';
+
+ const result = buildHref(pathPiece, hashPiece, { preserveQuery: true });
+
+ expect(result).toBe('/path?new=param#hash');
+ });
+
+ test('Should handle complex multi-hash routing fragment.', () => {
+ const pathPiece = 'https://example.com/app';
+ const hashPiece = 'https://example.com/other#main=/dashboard;sidebar=/menu';
+
+ const result = buildHref(pathPiece, hashPiece);
+
+ expect(result).toBe('/app#main=/dashboard;sidebar=/menu');
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/lib/buildHref.ts b/src/lib/buildHref.ts
new file mode 100644
index 0000000..294fda1
--- /dev/null
+++ b/src/lib/buildHref.ts
@@ -0,0 +1,30 @@
+import type { BuildHrefOptions } from "$lib/types.js";
+import { location } from "./kernel/Location.js";
+import { mergeQueryParams } from "./kernel/preserveQuery.js";
+
+/**
+ * Builds a new HREF by combining the path piece from one HREF and the hash piece from another.
+ *
+ * Any query parameters present in either piece are merged and included in the resulting HREF. Furthermore, if the
+ * `preserveQuery` option is provided, additional query parameters from the current URL are also merged in.
+ *
+ * ### When to Use
+ *
+ * This is a helper function that came to be when the redirection feature was added to the library. The specific use
+ * case is cross-routing-universe redirections, where the "source" universe's path is not changed by normal redirection
+ * because "normal" **cross-universe redirections** don't alter other universes' paths.
+ *
+ * This function, in conjunction with the `calculateHref` function, allows relatively easy construction of the desired
+ * final HREF by combining the results of 2 `calculateHref` calls: One to get the path piece from the source universe,
+ * and another to get the hash piece for the other universe.
+ * @param pathPiece HREF value containing the desired path piece.
+ * @param hashPiece HREF value containing the desired hash piece.
+ * @param options Optional set of options.
+ * @returns The built HREF using the provided pieces.
+ */
+export function buildHref(pathPiece: string, hashPiece: string, options?: BuildHrefOptions): string {
+ const pp = new URL(pathPiece, location.url);
+ const hp = new URL(hashPiece, location.url);
+ let sp = mergeQueryParams(mergeQueryParams(pp.searchParams, hp.searchParams), options?.preserveQuery);
+ return `${pp.pathname}${sp?.size ? `?${sp.toString()}` : ''}${hp.hash}`;
+}
diff --git a/src/lib/index.test.ts b/src/lib/index.test.ts
index 655db9a..f7481fd 100644
--- a/src/lib/index.test.ts
+++ b/src/lib/index.test.ts
@@ -19,6 +19,8 @@ describe('index', () => {
'setRouterContext',
'isRouteActive',
'activeBehavior',
+ 'Redirector',
+ 'buildHref',
];
// Act.
diff --git a/src/lib/index.ts b/src/lib/index.ts
index 768c4b4..6ad9a5a 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -14,3 +14,5 @@ export * from './RouterTrace/RouterTrace.svelte';
export { default as RouterTrace } from './RouterTrace/RouterTrace.svelte';
export * from "./public-utils.js";
export * from "./behaviors/active.svelte.js";
+export { Redirector } from "./kernel/Redirector.svelte.js";
+export { buildHref } from "./buildHref.js";
diff --git a/src/lib/kernel/LocationLite.svelte.test.ts b/src/lib/kernel/LocationLite.svelte.test.ts
index bb003b3..95e0582 100644
--- a/src/lib/kernel/LocationLite.svelte.test.ts
+++ b/src/lib/kernel/LocationLite.svelte.test.ts
@@ -264,4 +264,32 @@ describe("LocationLite", () => {
expect(act).toThrow();
});
});
+ describe('path', () => {
+ const initialPath = '/initial/path';
+ beforeEach(() => {
+ browserMocks.simulateHistoryChange(undefined, `http://example.com${initialPath}`);
+ });
+
+ test("Should return the URL's path.", () => {
+ expect(location.path).toBe(initialPath);
+ });
+
+ test("Should update when location changes.", () => {
+ expect(location.path).toBe(initialPath);
+ const newPath = '/new/path/value';
+ location.navigate(newPath);
+ expect(location.path).toBe(newPath);
+ });
+
+ test("Should remove the drive letter on Windows file URLs.", () => {
+ // Arrange.
+ const fileUrl = 'file:///C:/path/to/file.txt';
+
+ // Act.
+ browserMocks.simulateHistoryChange(undefined, fileUrl);
+
+ // Assert.
+ expect(location.path).toBe('/path/to/file.txt');
+ });
+ });
});
diff --git a/src/lib/kernel/LocationLite.svelte.ts b/src/lib/kernel/LocationLite.svelte.ts
index d3cbf85..dc8da78 100644
--- a/src/lib/kernel/LocationLite.svelte.ts
+++ b/src/lib/kernel/LocationLite.svelte.ts
@@ -14,7 +14,7 @@ import { assertAllowedRoutingMode } from "$lib/utils.js";
*/
export class LocationLite implements Location {
#historyApi: HistoryApi;
-
+
hashPaths = $derived.by(() => {
if (routingOptions.hashMode === 'single') {
return { single: this.#historyApi.url.hash.substring(1) };
@@ -31,6 +31,11 @@ export class LocationLite implements Location {
return result;
});
+ path = $derived.by(() => {
+ const hasDriveLetter = this.url.protocol.startsWith('file:') && this.url.pathname[2] === ':';
+ return hasDriveLetter ? this.url.pathname.substring(3) : this.url.pathname;
+ });
+
constructor(historyApi?: HistoryApi) {
this.#historyApi = historyApi ?? new StockHistoryApi();
}
diff --git a/src/lib/kernel/Redirector.svelte.test.ts b/src/lib/kernel/Redirector.svelte.test.ts
new file mode 100644
index 0000000..47dfd78
--- /dev/null
+++ b/src/lib/kernel/Redirector.svelte.test.ts
@@ -0,0 +1,387 @@
+import { afterAll, afterEach, beforeAll, describe, expect, vi, type MockInstance } from "vitest";
+import { testWithEffect as test } from "$test/testWithEffect.svelte.js";
+import { ALL_HASHES, ROUTING_UNIVERSES } from "$test/test-utils.js";
+import { init } from "$lib/init.js";
+import type { Hash, PatternRouteInfo, RedirectedRouteInfo } from "$lib/types.js";
+import { resolveHashValue } from "./resolveHashValue.js";
+import { Redirector } from "./Redirector.svelte.js";
+import { location } from "./Location.js";
+import { flushSync } from "svelte";
+
+ROUTING_UNIVERSES.forEach((universe) => {
+ describe(`Redirector - ${universe.text}`, () => {
+ let cleanup: () => void;
+ let resolvedHash: Hash;
+ let ruPath: () => string;
+ let navigateSpy: MockInstance;
+ let goToSpy: MockInstance;
+ beforeAll(() => {
+ cleanup = init(universe);
+ resolvedHash = resolveHashValue(universe.hash);
+ switch (resolvedHash) {
+ case ALL_HASHES.path:
+ ruPath = () => location.path;
+ break;
+ case ALL_HASHES.single:
+ ruPath = () => location.hashPaths.single;
+ break;
+ case ALL_HASHES.multi:
+ ruPath = () => location.hashPaths[ALL_HASHES.multi];
+ break;
+ }
+ navigateSpy = vi.spyOn(location, 'navigate');
+ goToSpy = vi.spyOn(location, 'goTo');
+ });
+ afterAll(() => {
+ cleanup();
+ });
+ afterEach(() => {
+ location.goTo('/');
+ vi.clearAllMocks();
+ });
+
+ describe("redirections", () => {
+ const tests: (RedirectedRouteInfo & {
+ triggerUrl: string;
+ expectedPath: string;
+ text: string;
+ })[] = [
+ {
+ triggerUrl: '/old/path',
+ pattern: '/old/path',
+ href: '/new/path',
+ expectedPath: '/new/path',
+ text: "Static pattern; static href"
+ },
+ {
+ pattern: '/old-path/:id',
+ triggerUrl: '/old-path/123',
+ expectedPath: '/new-path/123',
+ href: (rp) => `/new-path/${rp?.id}`,
+ text: "Parameterized pattern; dynamic href"
+ },
+ {
+ pattern: '/old-path/*',
+ triggerUrl: '/old-path/any/number/of/segments',
+ expectedPath: '/new-path/any/number/of/segments',
+ href: (rp) => `/new-path${rp?.rest}`,
+ text: "Rest parameter; dynamic href"
+ },
+ {
+ pattern: '/conditional/:id',
+ triggerUrl: '/conditional/123',
+ expectedPath: '/allowed/123',
+ href: (rp) => `/allowed/${rp?.id}`,
+ and: (rp) => (rp?.id as number) > 100,
+ text: "Conditional redirection with and predicate (allowed)"
+ },
+ ];
+ tests.forEach((tc) => {
+ test(`Should navigate to ${tc.expectedPath} under conditions: ${tc.text}.`, () => {
+ // Arrange.
+ const newPath = "/new-path/123";
+ location.navigate(tc.triggerUrl, { hash: universe.hash });
+ const redirector = new Redirector(universe.hash);
+ navigateSpy.mockClear();
+
+ // Act.
+ redirector.redirections.push({
+ ...tc
+ });
+ flushSync();
+
+ // Assert.
+ expect(navigateSpy).toHaveBeenCalledTimes(1);
+ expect(ruPath()).toBe(tc.expectedPath);
+ });
+ });
+ });
+ test("Should use 'goTo' for navigation when specified in redirection info.", () => {
+ // Arrange.
+ location.navigate('/old-path', { hash: universe.hash });
+ navigateSpy.mockClear();
+ const redirector = new Redirector(universe.hash);
+
+ // Act.
+ redirector.redirections.push({
+ pattern: '/old-path',
+ href: '/new-path',
+ goTo: true,
+ });
+ flushSync();
+
+ // Assert.
+ expect(goToSpy).toHaveBeenCalledTimes(1);
+ expect(navigateSpy).toHaveBeenCalledTimes(0);
+ });
+
+ test("Should not redirect when 'and' predicate returns false.", () => {
+ // Arrange.
+ location.navigate('/conditional/50', { hash: universe.hash });
+ const redirector = new Redirector(universe.hash);
+ navigateSpy.mockClear();
+
+ // Act.
+ redirector.redirections.push({
+ pattern: '/conditional/:id',
+ href: '/not-allowed',
+ and: (rp) => (rp?.id as number) > 100,
+ });
+ flushSync();
+
+ // Assert.
+ expect(navigateSpy).toHaveBeenCalledTimes(0);
+ expect(ruPath()).toBe('/conditional/50'); // Should stay on original path
+ });
+
+ test("Should redirect with first matching redirection when multiple match.", () => {
+ // Arrange.
+ location.navigate('/multi/test', { hash: universe.hash });
+ const redirector = new Redirector(universe.hash);
+ navigateSpy.mockClear();
+
+ // Act.
+ redirector.redirections.push(
+ {
+ pattern: '/multi/*',
+ href: '/first-match',
+ },
+ {
+ pattern: '/multi/test',
+ href: '/second-match',
+ }
+ );
+ flushSync();
+
+ // Assert.
+ expect(navigateSpy).toHaveBeenCalledTimes(1);
+ expect(ruPath()).toBe('/first-match'); // Should use first matching redirection
+ });
+
+ test("Should respect replace option from constructor.", () => {
+ // Arrange.
+ location.navigate('/test-replace', { hash: universe.hash });
+ const redirector = new Redirector(universe.hash, { replace: false });
+ navigateSpy.mockClear();
+
+ // Act.
+ redirector.redirections.push({
+ pattern: '/test-replace',
+ href: '/replaced',
+ });
+ flushSync();
+
+ // Assert.
+ expect(navigateSpy).toHaveBeenCalledWith('/replaced', expect.objectContaining({
+ replace: false
+ }));
+ });
+
+ test("Should pass through redirection options to navigation method.", () => {
+ // Arrange.
+ location.navigate('/with-options', { hash: universe.hash });
+ const redirector = new Redirector(universe.hash);
+ navigateSpy.mockClear();
+
+ // Act.
+ redirector.redirections.push({
+ pattern: '/with-options',
+ href: '/target',
+ options: { preserveQuery: true, state: { custom: 'data' } }
+ });
+ flushSync();
+
+ // Assert.
+ expect(navigateSpy).toHaveBeenCalledWith('/target', expect.objectContaining({
+ preserveQuery: true,
+ state: { custom: 'data' }
+ }));
+ });
+
+ test("Should react to changes in additions to 'redirections' without a URL change.", () => {
+ // Arrange.
+ location.navigate('/test-reactivity', { hash: universe.hash });
+ const redirector = new Redirector(universe.hash);
+
+ // Add initial redirection that won't match
+ redirector.redirections.push({
+ pattern: '/different-path',
+ href: '/not-relevant'
+ });
+ flushSync();
+ navigateSpy.mockClear();
+
+ // Act.
+ redirector.redirections.push({
+ pattern: '/test-reactivity',
+ href: '/should-redirect'
+ });
+ flushSync();
+
+ // Assert.
+ expect(navigateSpy).toHaveBeenCalledTimes(1);
+ });
+
+ test("Should react to changes in the values of a redirection.", () => {
+ // Arrange.
+ location.navigate('/test-reactivity', { hash: universe.hash });
+ const redirector = new Redirector(universe.hash);
+ redirector.redirections.push({
+ pattern: '/different-path',
+ href: '/punch-line'
+ });
+ flushSync();
+ navigateSpy.mockClear();
+
+ // Act.
+ (redirector.redirections[0] as PatternRouteInfo).pattern = '/test-reactivity';
+ flushSync();
+
+ // Assert.
+ expect(navigateSpy).toHaveBeenCalledTimes(1);
+ expect(ruPath()).toBe('/punch-line');
+ });
+ });
+});
+
+describe("Cross-universe Redirection", () => {
+ describe("Path/Hash Scenarios", () => {
+ let cleanup: () => void;
+ let navigateSpy: MockInstance;
+ let goToSpy: MockInstance;
+
+ beforeAll(() => {
+ // Initialize with path routing as the base universe
+ cleanup = init({ defaultHash: false });
+ navigateSpy = vi.spyOn(location, 'navigate');
+ goToSpy = vi.spyOn(location, 'goTo');
+ });
+
+ afterAll(() => {
+ cleanup();
+ });
+
+ afterEach(() => {
+ location.goTo('/');
+ vi.clearAllMocks();
+ });
+
+ test("Should redirect from path universe to hash universe.", () => {
+ // Arrange.
+ location.navigate('/old-path-route', { hash: false }); // Path universe navigation
+ const redirector = new Redirector(false); // Monitor path universe
+ flushSync();
+ navigateSpy.mockClear();
+ console.debug('Location before redirection:', location.url.href);
+
+ // Act.
+ redirector.redirections.push({
+ pattern: '/old-path-route',
+ href: '/new-hash-route',
+ options: { hash: true }
+ });
+ flushSync();
+
+ // Assert.
+ expect(navigateSpy).toHaveBeenCalledWith('/new-hash-route', expect.objectContaining({
+ hash: true,
+ }));
+ expect(location.hashPaths.single).toBe('/new-hash-route');
+ });
+
+ test("Should redirect from hash universe to path universe.", () => {
+ // Arrange.
+ location.navigate('/old-hash-route', { hash: true }); // Hash universe navigation
+ const redirector = new Redirector(true); // Monitor hash universe
+ navigateSpy.mockClear();
+
+ // Act.
+ redirector.redirections.push({
+ pattern: '/old-hash-route',
+ href: '/new-path-route',
+ options: { hash: false } // Target path universe
+ });
+ flushSync();
+
+ // Assert.
+ expect(navigateSpy).toHaveBeenCalledWith('/new-path-route', expect.objectContaining({
+ hash: false,
+ replace: true
+ }));
+ expect(location.path).toBe('/new-path-route');
+ });
+ });
+ describe("Multi-Hash Scenarios", () => {
+ let cleanup: () => void;
+ let navigateSpy: MockInstance;
+ let goToSpy: MockInstance;
+
+ beforeAll(() => {
+ // Initialize with path routing as the base universe
+ cleanup = init({ defaultHash: false, hashMode: 'multi' });
+ navigateSpy = vi.spyOn(location, 'navigate');
+ goToSpy = vi.spyOn(location, 'goTo');
+ });
+
+ afterAll(() => {
+ cleanup();
+ });
+
+ afterEach(() => {
+ location.goTo('/');
+ vi.clearAllMocks();
+ });
+ const tests: {
+ hash: Hash;
+ destinationHash: Hash;
+ finalPath: () => string;
+ sourceName: string;
+ destName: string;
+ }[] = [
+ {
+ hash: false,
+ destinationHash: 'p1',
+ finalPath: () => location.hashPaths.p1,
+ sourceName: 'the path universe',
+ destName: 'a named hash universe',
+ },
+ {
+ hash: 'p1',
+ destinationHash: false,
+ finalPath: () => location.path,
+ sourceName: 'a named hash universe',
+ destName: 'the path universe',
+ },
+ {
+ hash: 'p1',
+ destinationHash: 'p2',
+ finalPath: () => location.hashPaths.p2,
+ sourceName: 'a named hash universe',
+ destName: 'another named hash universe',
+ },
+ ];
+ tests.forEach((tc) => {
+ test(`Should redirect from ${tc.sourceName} to ${tc.destName}.`, () => {
+ // Arrange.
+ location.navigate('/old-path-route', { hash: tc.hash });
+ const redirector = new Redirector(tc.hash);
+ flushSync();
+ navigateSpy.mockClear();
+
+ // Act.
+ redirector.redirections.push({
+ pattern: '/old-path-route',
+ href: '/new-hash-route',
+ options: { hash: tc.destinationHash }
+ });
+ flushSync();
+
+ // Assert.
+ expect(navigateSpy).toHaveBeenCalledWith('/new-hash-route', expect.objectContaining({
+ hash: tc.destinationHash,
+ }));
+ expect(tc.finalPath()).toBe('/new-hash-route');
+ });
+ });
+ });
+});
diff --git a/src/lib/kernel/Redirector.svelte.ts b/src/lib/kernel/Redirector.svelte.ts
new file mode 100644
index 0000000..be1fd41
--- /dev/null
+++ b/src/lib/kernel/Redirector.svelte.ts
@@ -0,0 +1,133 @@
+import type { Hash, ParameterValue, RedirectedRouteInfo } from "$lib/types.js";
+import { RouteHelper } from "./RouteHelper.svelte.js";
+import { location } from "./Location.js";
+import { resolveHashValue } from "./resolveHashValue.js";
+import { untrack } from "svelte";
+
+/**
+ * Options for the Redirector class.
+ */
+export type RedirectorOptions = {
+ replace?: boolean;
+}
+
+/**
+ * Default redirector options.
+ */
+const defaultRedirectorOptions: RedirectorOptions = {
+ replace: true
+};
+
+/**
+ * Class capable of performing URL redirections according to the defined redirection data provided.
+ *
+ * Both the redirection list and the current URL are reactive, so redirections are automatically performed
+ * when either changes.
+ *
+ * **IMPORTANT**: Since this is a reactivity-based redirector that registers an effect during construction, it must be
+ * initialized within a reactive context (e.g., inside the initialization script of a component or anywhere where
+ * `$effect.tracking()` returns `true`).
+ *
+ * ### Sveltekit Developers
+ *
+ * It is best to condition the creation of the class instance to only run on the client side, as Sveltekit's
+ * server-side rendering doesn't run effects, and an effect is what drives redirection. Save some CPU cycles by
+ * only creating the instance on the client side, for example:
+ *
+ * ```svelte
+ *
+ * ```
+ */
+export class Redirector {
+ /**
+ * Redirector options.
+ */
+ #options;
+ /**
+ * List of redirections to perform. Add or remove items from this array. The array is reactive, and adding or
+ * removing items can trigger immediate redirections.
+ *
+ * ### How It Works
+ *
+ * Redirection definitions are almost identical to route definitions, and are "matched" with the exact same
+ * algorithm used for routes. The `path` property specifies the old path to match, and the `href` property
+ * specifies the new URL to navigate to when a match is found.
+ *
+ * Redirection definitions even support the `and` predicate property, which allows more complex redirection
+ * scenarios, and works identically to the `and` property in route definitions. It even extracts "route
+ * parameters" when the path matches, and those are available to both the `and` predicate and the `href` property
+ * when defined as a function.
+ *
+ * @example
+ * ```svelte
+ *
+ * ```
+ */
+ readonly redirections;
+ /**
+ * Route helper used to parse and test routes.
+ */
+ #routeHelper;
+ /**
+ * The resolved hash value used for this redirector.
+ */
+ #hash: Hash;
+ /**
+ * The route patterns derived from the redirection list.
+ */
+ #routePatterns = $derived.by(() => this.redirections.map((url) => this.#routeHelper.parseRoutePattern(url)));
+ /**
+ * Initializes a new instance of this class.
+ * @param hash Resolved hash value that will be used for route testing and navigation if no navigation-specific
+ * hash value is provided via the redirection options.
+ * @param options Redirector options.
+ */
+ constructor(hash?: Hash | undefined, options?: RedirectorOptions) {
+ this.#options = { ...defaultRedirectorOptions, ...options };
+ this.#hash = resolveHashValue(hash);
+ this.redirections = $state([]);
+ this.#routeHelper = new RouteHelper(this.#hash);
+
+ $effect(() => {
+ for (let i = 0; i < this.redirections.length; ++i) {
+ const [match, routeParams] = this.#routeHelper.testRoute(this.#routePatterns[i]);
+ if (match) {
+ untrack(() => this.#navigate(this.redirections[i], routeParams));
+ break;
+ }
+ }
+ });
+ }
+ /**
+ * Performs the navigation according to the provided redirection information.
+ * @param redirection Redirection information to use for navigation.
+ * @param routeParams Route parameters obtained during route matching.
+ */
+ #navigate(redirection: RedirectedRouteInfo, routeParams: Record | undefined) {
+ const url = typeof redirection.href === 'function' ?
+ redirection.href(routeParams) :
+ redirection.href;
+ location[(redirection.goTo ? 'goTo' : 'navigate')](url, {
+ hash: this.#hash,
+ replace: this.#options.replace,
+ ...redirection.options,
+ });
+ }
+}
diff --git a/src/lib/kernel/RouteHelper.svelte.test.ts b/src/lib/kernel/RouteHelper.svelte.test.ts
new file mode 100644
index 0000000..2faf0f2
--- /dev/null
+++ b/src/lib/kernel/RouteHelper.svelte.test.ts
@@ -0,0 +1,556 @@
+import { ROUTING_UNIVERSES, setupBrowserMocks, type RoutingUniverse } from "../testing/test-utils.js";
+import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
+import { RouteHelper } from "./RouteHelper.svelte.js";
+import { resolveHashValue } from "./resolveHashValue.js";
+import { location } from "./Location.js";
+import { init } from "$lib/init.js";
+import type { Hash } from "$lib/types.js";
+
+ROUTING_UNIVERSES.forEach((universe) => {
+ describe(`RouteHelper - ${universe.text}`, () => {
+ let cleanup: () => void;
+ let resolvedHash: Hash;
+ let routeHelper: RouteHelper;
+ beforeAll(() => {
+ cleanup = init(universe);
+ resolvedHash = resolveHashValue(universe.hash ?? universe.defaultHash);
+ });
+ afterAll(() => {
+ cleanup();
+ });
+ beforeEach(() => {
+ routeHelper = new RouteHelper(resolvedHash);
+ });
+
+ describe("testPath", () => {
+ describe("Existing Path", () => {
+ const expectedPath = "/some/path";
+ const unexpectedPath = "/unexpected/stuff";
+ let cleanup: () => void;
+ beforeAll(() => {
+ const testPaths = {
+ 'IPR': `${expectedPath}#${unexpectedPath}`,
+ 'PR': `${expectedPath}#${unexpectedPath}`,
+ 'IHR': `${unexpectedPath}#${expectedPath}`,
+ 'HR': `${unexpectedPath}#${expectedPath}`,
+ 'IMHR': `${unexpectedPath}#unrelated=/a/b;${resolvedHash}=${expectedPath}`,
+ 'MHR': `${unexpectedPath}#unrelated=/a/b;${resolvedHash}=${expectedPath}`,
+ } as const;
+ const initialPath = `http://example.com${testPaths[universe.text]}`;
+ const mocks = setupBrowserMocks(initialPath, location);
+ cleanup = mocks.cleanup;
+ });
+ afterAll(() => {
+ cleanup();
+ });
+
+ test("Should return the expected path.", () => {
+ expect(routeHelper.testPath).toBe(expectedPath);
+ });
+ });
+ describe("No Path", () => {
+ const unexpectedPath = "/unexpected/stuff";
+ let routeHelper: RouteHelper;
+ let cleanup: () => void;
+ beforeAll(() => {
+ const testPaths = {
+ 'IPR': `#/${unexpectedPath}`,
+ 'PR': `#/${unexpectedPath}`,
+ 'IHR': `${unexpectedPath}`,
+ 'HR': `${unexpectedPath}`,
+ 'IMHR': `${unexpectedPath}#unrelated=/a/b`,
+ 'MHR': `${unexpectedPath}#unrelated=/a/b`,
+ } as const;
+ const initialPath = `http://example.com${testPaths[universe.text]}`;
+ const mocks = setupBrowserMocks(initialPath, location);
+ cleanup = mocks.cleanup;
+ });
+ afterAll(() => {
+ cleanup();
+ });
+ beforeEach(() => {
+ routeHelper = new RouteHelper(resolvedHash);
+ });
+
+ test("Should return a single slash when no path exists.", () => {
+ expect(routeHelper.testPath).toBe("/");
+ });
+ });
+ describe("No trailing slash", () => {
+ const expectedPath = "/some/path";
+ let routeHelper: RouteHelper;
+ let cleanup: () => void;
+ beforeAll(() => {
+ const testPaths = {
+ 'IPR': `${expectedPath}/#trailing/slash/`,
+ 'PR': `${expectedPath}/#trailing/slash/`,
+ 'IHR': `trailing/slash/#${expectedPath}/`,
+ 'HR': `trailing/slash/#${expectedPath}/`,
+ 'IMHR': `trailing/slash/#unrelated=/a/b;${resolvedHash}=${expectedPath}/`,
+ 'MHR': `trailing/slash/#unrelated=/a/b;${resolvedHash}=${expectedPath}/`,
+ } as const;
+ const initialPath = `http://example.com${testPaths[universe.text]}`;
+ const mocks = setupBrowserMocks(initialPath, location);
+ cleanup = mocks.cleanup;
+ });
+ afterAll(() => {
+ cleanup();
+ });
+ beforeEach(() => {
+ routeHelper = new RouteHelper(resolvedHash);
+ });
+ test("Should return the expected path without trailing slash.", () => {
+ expect(routeHelper.testPath).toBe(expectedPath);
+ });
+ });
+ describe("Reactivity", () => {
+ let mocks: ReturnType;
+ const initialPath = '/initial/path';
+ const unexpectedPath = '/unexpected/stuff';
+ let testPaths: Record;
+ beforeAll(() => {
+ testPaths = {
+ 'IPR': `${initialPath}#${unexpectedPath}`,
+ 'PR': `${initialPath}#${unexpectedPath}`,
+ 'IHR': `${unexpectedPath}#${initialPath}`,
+ 'HR': `${unexpectedPath}#${initialPath}`,
+ 'IMHR': `${unexpectedPath}#unrelated=/a/b;${resolvedHash}=${initialPath}`,
+ 'MHR': `${unexpectedPath}#unrelated=/a/b;${resolvedHash}=${initialPath}`,
+ };
+ mocks = setupBrowserMocks(`http://example.com${testPaths[universe.text]}`, location);
+ });
+ afterAll(() => {
+ mocks.cleanup();
+ });
+ afterEach(() => {
+ location.goTo(`${testPaths[universe.text]}`);
+ });
+
+ test("Should update when location changes.", () => {
+ expect(routeHelper.testPath).toBe(initialPath);
+ const newPath = '/new/path/value';
+ location.navigate(newPath, { hash: universe.hash });
+ expect(routeHelper.testPath).toBe(newPath);
+ });
+ });
+ });
+ });
+});
+
+describe("RouteHelper", () => {
+ let routeHelper: RouteHelper;
+
+ beforeEach(() => {
+ // Use path routing (false) for these universe-independent tests
+ routeHelper = new RouteHelper(false);
+ });
+
+ describe("parseRoutePattern", () => {
+ describe("Default Props", () => {
+ test("Should return empty regex when pattern is not provided.", () => {
+ // Arrange
+ const routeInfo = { and: undefined, ignoreForFallback: false };
+
+ // Act
+ const result = routeHelper.parseRoutePattern(routeInfo);
+
+ // Assert
+ expect(result.regex).toBeUndefined();
+ expect(result.and).toBeUndefined();
+ expect(result.ignoreForFallback).toBe(false);
+ });
+
+ test("Should set ignoreForFallback to false by default when not provided.", () => {
+ // Arrange
+ const routeInfo = { pattern: "/test" };
+
+ // Act
+ const result = routeHelper.parseRoutePattern(routeInfo);
+
+ // Assert
+ expect(result.ignoreForFallback).toBe(false);
+ });
+
+ test("Should preserve and predicate function when provided.", () => {
+ // Arrange
+ const andPredicate = () => true;
+ const routeInfo = { pattern: "/test", and: andPredicate };
+
+ // Act
+ const result = routeHelper.parseRoutePattern(routeInfo);
+
+ // Assert
+ expect(result.and).toBe(andPredicate);
+ });
+ });
+
+ describe("Explicit Props", () => {
+ test.each([
+ { pattern: "/", expectedRegex: "^\\/$", description: "root path" },
+ { pattern: "/test", expectedRegex: "^\\/test$", description: "simple path" },
+ { pattern: "/test/path", expectedRegex: "^\\/test\\/path$", description: "nested path" },
+ { pattern: "/api/v1/users", expectedRegex: "^\\/api\\/v1\\/users$", description: "multi-segment path" }
+ ])("Should create correct regex for $description .", ({ pattern, expectedRegex }) => {
+ // Arrange
+ const routeInfo = { pattern };
+
+ // Act
+ const result = routeHelper.parseRoutePattern(routeInfo);
+
+ // Assert
+ expect(result.regex?.source).toBe(expectedRegex);
+ expect(result.regex?.flags).toBe("i"); // Case insensitive by default
+ });
+
+ test.each([
+ { pattern: "/:id", expectedRegex: "^\\/(?[^/]+)$", description: "single parameter" },
+ { pattern: "/user/:userId", expectedRegex: "^\\/user\\/(?[^/]+)$", description: "parameter in path" },
+ { pattern: "/:category/:id", expectedRegex: "^\\/(?[^/]+)\\/(?[^/]+)$", description: "multiple parameters" },
+ { pattern: "/api-:version", expectedRegex: "^\\/api-(?[^/]+)$", description: "parameter with prefix" },
+ { pattern: "/user-:id/profile", expectedRegex: "^\\/user-(?[^/]+)\\/profile$", description: "parameter with prefix and suffix" }
+ ])("Should create correct regex for $description .", ({ pattern, expectedRegex }) => {
+ // Arrange
+ const routeInfo = { pattern };
+
+ // Act
+ const result = routeHelper.parseRoutePattern(routeInfo);
+
+ // Assert
+ expect(result.regex?.source).toBe(expectedRegex);
+ });
+
+ test.each([
+ { pattern: "/:id?", expectedRegex: "^\\/?(?:(?[^/]+))?$", description: "optional parameter" },
+ { pattern: "/user/:id?", expectedRegex: "^\\/user\\/?(?:(?[^/]+))?$", description: "optional parameter with leading slash" },
+ { pattern: "/:category/:id?", expectedRegex: "^\\/(?[^/]+)\\/?(?:(?[^/]+))?$", description: "required and optional parameters" },
+ { pattern: "/:category?/:id", expectedRegex: "^\\/?(?:(?[^/]+))?\\/(?[^/]+)$", description: "optional then required parameters" }
+ ])("Should create correct regex for $description .", ({ pattern, expectedRegex }) => {
+ // Arrange
+ const routeInfo = { pattern };
+
+ // Act
+ const result = routeHelper.parseRoutePattern(routeInfo);
+
+ // Assert
+ expect(result.regex?.source).toBe(expectedRegex);
+ });
+
+ test.each([
+ { pattern: "/files/*", expectedRegex: "^\\/files(?.*)$", description: "rest parameter" },
+ { pattern: "/api/v1/*", expectedRegex: "^\\/api\\/v1(?.*)$", description: "rest parameter in nested path" },
+ { pattern: "/*", expectedRegex: "^(?.*)$", description: "root rest parameter" }
+ ])("Should create correct regex for $description .", ({ pattern, expectedRegex }) => {
+ // Arrange
+ const routeInfo = { pattern };
+
+ // Act
+ const result = routeHelper.parseRoutePattern(routeInfo);
+
+ // Assert
+ expect(result.regex?.source).toBe(expectedRegex);
+ });
+
+ test.each([
+ { caseSensitive: true, expectedFlags: "", description: "case sensitive" },
+ { caseSensitive: false, expectedFlags: "i", description: "case insensitive" }
+ ])("Should create regex with correct flags for $description .", ({ caseSensitive, expectedFlags }) => {
+ // Arrange
+ const routeInfo = { pattern: "/test", caseSensitive };
+
+ // Act
+ const result = routeHelper.parseRoutePattern(routeInfo);
+
+ // Assert
+ expect(result.regex?.flags).toBe(expectedFlags);
+ });
+
+ test.each([
+ { ignoreForFallback: true, expected: true, description: "explicit true" },
+ { ignoreForFallback: false, expected: false, description: "explicit false" }
+ ])("Should set ignoreForFallback correctly when $description .", ({ ignoreForFallback, expected }) => {
+ // Arrange
+ const routeInfo = { pattern: "/test", ignoreForFallback };
+
+ // Act
+ const result = routeHelper.parseRoutePattern(routeInfo);
+
+ // Assert
+ expect(result.ignoreForFallback).toBe(expected);
+ });
+ });
+
+ describe("Base Path Integration", () => {
+ test.each([
+ { basePath: "/", pattern: "/test", expectedRegex: "^\\/test$", description: "root base path" },
+ { basePath: "/api", pattern: "/users", expectedRegex: "^\\/api\\/users$", description: "simple base path" },
+ { basePath: "/api/v1", pattern: "/users/:id", expectedRegex: "^\\/api\\/v1\\/users\\/(?[^/]+)$", description: "nested base path with parameters" },
+ { basePath: "/app", pattern: "/", expectedRegex: "^\\/app$", description: "root pattern with base path" },
+ { basePath: "/api/", pattern: "/users/", expectedRegex: "^\\/api\\/users$", description: "trailing slashes handled" }
+ ])("Should join base path correctly for $description .", ({ basePath, pattern, expectedRegex }) => {
+ // Arrange
+ const routeInfo = { pattern };
+
+ // Act
+ const result = routeHelper.parseRoutePattern(routeInfo, basePath);
+
+ // Assert
+ expect(result.regex?.source).toBe(expectedRegex);
+ });
+ });
+
+ describe("Special Characters Escaping", () => {
+ test.each([
+ { pattern: "/test.html", expectedRegex: "^\\/test\\.html$", description: "dot character" },
+ { pattern: "/api+v1", expectedRegex: "^\\/api\\+v1$", description: "plus character" },
+ { pattern: "/query^start", expectedRegex: "^\\/query\\^start$", description: "caret character" },
+ { pattern: "/data$end", expectedRegex: "^\\/data\\$end$", description: "dollar character" },
+ { pattern: "/path{test}", expectedRegex: "^\\/path\\{test\\}$", description: "curly braces" },
+ { pattern: "/file(1)", expectedRegex: "^\\/file\\(1\\)$", description: "parentheses" },
+ { pattern: "/arr[0]", expectedRegex: "^\\/arr\\[0\\]$", description: "square brackets" },
+ { pattern: "/back\\slash", expectedRegex: "^\\/back\\\\slash$", description: "backslash" }
+ ])("Should escape $description correctly .", ({ pattern, expectedRegex }) => {
+ // Arrange
+ const routeInfo = { pattern };
+
+ // Act
+ const result = routeHelper.parseRoutePattern(routeInfo);
+
+ // Assert
+ expect(result.regex?.source).toBe(expectedRegex);
+ });
+ });
+ });
+
+ describe("testRoute", () => {
+ // Note: The testRoute method uses this.testPath internally, which is a derived value
+ // that depends on location.path. Since proper location setup is complex and the location
+ // is tested separately in other test files, we focus on testing the core logic that
+ // doesn't depend on the testPath property.
+
+ describe("Default Props", () => {
+ test("Should match when no regex is provided.", () => {
+ // Arrange
+ const routeMatchInfo = {};
+
+ // Act
+ const [match, params] = routeHelper.testRoute(routeMatchInfo);
+
+ // Assert
+ expect(match).toBe(true);
+ expect(params).toBeUndefined();
+ });
+ });
+
+ describe("And Predicate Integration", () => {
+ test("Should match with and predicate when no regex provided.", () => {
+ // Arrange
+ const andPredicate = vi.fn(() => true);
+ const routeMatchInfo = { and: andPredicate };
+
+ // Act
+ const [match, params] = routeHelper.testRoute(routeMatchInfo);
+
+ // Assert
+ expect(match).toBe(true);
+ expect(andPredicate).toHaveBeenCalledWith(undefined);
+ expect(params).toBeUndefined();
+ });
+
+ test("Should not match with and predicate when no regex provided and predicate returns false.", () => {
+ // Arrange
+ const andPredicate = vi.fn(() => false);
+ const routeMatchInfo = { and: andPredicate };
+
+ // Act
+ const [match, params] = routeHelper.testRoute(routeMatchInfo);
+
+ // Assert
+ expect(match).toBe(false);
+ expect(andPredicate).toHaveBeenCalledWith(undefined);
+ expect(params).toBeUndefined();
+ });
+ });
+
+ describe("Regex Execution Logic", () => {
+ // These tests verify the core regex matching and parameter parsing logic
+ // by creating a mock RouteHelper with a fixed testPath
+
+ class MockRouteHelper extends RouteHelper {
+ readonly mockTestPath: string;
+
+ constructor(mockTestPath: string) {
+ super(false);
+ this.mockTestPath = mockTestPath;
+ // Override the derived testPath by replacing it
+ Object.defineProperty(this, 'testPath', {
+ get: () => this.mockTestPath,
+ enumerable: true,
+ configurable: true
+ });
+ }
+ }
+
+ test.each([
+ {
+ testPath: "/user/123",
+ regex: /^\/user\/(?\d+)$/,
+ expectedMatch: true,
+ expectedParams: { id: 123 },
+ description: "numeric parameter matching"
+ },
+ {
+ testPath: "/user/abc",
+ regex: /^\/user\/(?[^/]+)$/,
+ expectedMatch: true,
+ expectedParams: { id: "abc" },
+ description: "string parameter matching"
+ },
+ {
+ testPath: "/post/123/comment/456",
+ regex: /^\/post\/(?\d+)\/comment\/(?\d+)$/,
+ expectedMatch: true,
+ expectedParams: { postId: 123, commentId: 456 },
+ description: "multiple numeric parameters"
+ },
+ {
+ testPath: "/files/docs/readme.txt",
+ regex: /^\/files\/(?.*)$/,
+ expectedMatch: true,
+ expectedParams: { rest: "docs/readme.txt" },
+ description: "rest parameter matching"
+ },
+ {
+ testPath: "/different",
+ regex: /^\/user\/(?\d+)$/,
+ expectedMatch: false,
+ expectedParams: undefined,
+ description: "non-matching path"
+ }
+ ])("Should handle $description correctly .", ({ testPath, regex, expectedMatch, expectedParams }) => {
+ // Arrange
+ const mockHelper = new MockRouteHelper(testPath);
+ const routeMatchInfo = { regex };
+
+ // Act
+ const [match, params] = mockHelper.testRoute(routeMatchInfo);
+
+ // Assert
+ expect(match).toBe(expectedMatch);
+ expect(params).toEqual(expectedParams);
+ });
+
+ test.each([
+ { value: "123", expected: 123, description: "string number to number" },
+ { value: "true", expected: true, description: "string true to boolean" },
+ { value: "false", expected: false, description: "string false to boolean" },
+ { value: "hello", expected: "hello", description: "regular string unchanged" },
+ { value: "", expected: "", description: "empty string unchanged" }
+ ])("Should parse parameter values correctly: $description .", ({ value, expected }) => {
+ // Arrange
+ const mockHelper = new MockRouteHelper(`/test/${value}`);
+ const regex = /^\/test\/(?.*)$/;
+ const routeMatchInfo = { regex };
+
+ // Act
+ const [match, params] = mockHelper.testRoute(routeMatchInfo);
+
+ // Assert
+ expect(match).toBe(true);
+ expect(params?.param).toBe(expected);
+ });
+
+ test("Should decode URI components in parameters.", () => {
+ // Arrange
+ const encodedValue = "hello%20world%21"; // "hello world!"
+ const expectedValue = "hello world!";
+ const mockHelper = new MockRouteHelper(`/test/${encodedValue}`);
+ const regex = /^\/test\/(?[^/]+)$/;
+ const routeMatchInfo = { regex };
+
+ // Act
+ const [match, params] = mockHelper.testRoute(routeMatchInfo);
+
+ // Assert
+ expect(match).toBe(true);
+ expect(params?.param).toBe(expectedValue);
+ });
+
+ test("Should remove undefined parameters from result.", () => {
+ // Arrange
+ const mockHelper = new MockRouteHelper("/user/123");
+ const regex = /^\/user\/(?\d+)(?:\/(?[^/]+))?$/;
+ const routeMatchInfo = { regex };
+
+ // Act
+ const [match, params] = mockHelper.testRoute(routeMatchInfo);
+
+ // Assert
+ expect(match).toBe(true);
+ expect(params).toEqual({ id: 123 });
+ expect(params).not.toHaveProperty('optional');
+ });
+
+ test("Should handle empty route groups correctly.", () => {
+ // Arrange
+ const mockHelper = new MockRouteHelper("/test");
+ const regex = /^\/test$/; // No groups
+ const routeMatchInfo = { regex };
+
+ // Act
+ const [match, params] = mockHelper.testRoute(routeMatchInfo);
+
+ // Assert
+ expect(match).toBe(true);
+ expect(params).toBeUndefined(); // No groups means no params
+ });
+
+ test("Should pass parameters to and predicate.", () => {
+ // Arrange
+ const mockHelper = new MockRouteHelper("/user/123");
+ const regex = /^\/user\/(?\d+)$/;
+ const andPredicate = vi.fn(() => true);
+ const routeMatchInfo = { regex, and: andPredicate };
+
+ // Act
+ const [match, params] = mockHelper.testRoute(routeMatchInfo);
+
+ // Assert
+ expect(match).toBe(true);
+ expect(andPredicate).toHaveBeenCalledWith({ id: 123 });
+ expect(params).toEqual({ id: 123 });
+ });
+
+ test("Should not match when and predicate returns false.", () => {
+ // Arrange
+ const mockHelper = new MockRouteHelper("/user/123");
+ const regex = /^\/user\/(?\d+)$/;
+ const andPredicate = vi.fn(() => false);
+ const routeMatchInfo = { regex, and: andPredicate };
+
+ // Act
+ const [match, params] = mockHelper.testRoute(routeMatchInfo);
+
+ // Assert
+ expect(match).toBe(false);
+ expect(andPredicate).toHaveBeenCalledWith({ id: 123 });
+ expect(params).toEqual({ id: 123 });
+ });
+
+ test("Should not call and predicate when regex doesn't match.", () => {
+ // Arrange
+ const mockHelper = new MockRouteHelper("/different");
+ const regex = /^\/user\/(?\d+)$/;
+ const andPredicate = vi.fn(() => true);
+ const routeMatchInfo = { regex, and: andPredicate };
+
+ // Act
+ const [match, params] = mockHelper.testRoute(routeMatchInfo);
+
+ // Assert
+ expect(match).toBe(false);
+ expect(andPredicate).not.toHaveBeenCalled();
+ expect(params).toBeUndefined();
+ });
+ });
+ });
+});
diff --git a/src/lib/kernel/RouteHelper.svelte.ts b/src/lib/kernel/RouteHelper.svelte.ts
new file mode 100644
index 0000000..11b64a2
--- /dev/null
+++ b/src/lib/kernel/RouteHelper.svelte.ts
@@ -0,0 +1,122 @@
+import type { AndUntyped, Hash, PatternRouteInfo, RouteStatus } from "$lib/types.js";
+import { location } from "./Location.js";
+
+function noTrailingSlash(path: string) {
+ return path !== '/' && path.endsWith('/') ? path.slice(0, -1) : path;
+}
+
+function hasLeadingSlash(paths: (string | undefined)[]) {
+ for (let path of paths) {
+ if (!path) {
+ continue;
+ }
+ return path.startsWith('/');
+ }
+ return false;
+}
+
+/**
+ * Joins the provided paths into a single path.
+ * @param paths Paths to join.
+ * @returns The joined path.
+ */
+export function joinPaths(...paths: string[]) {
+ const result = paths.reduce((acc, path, index) => {
+ const trimmedPath = (path ?? '').replace(/^\/|\/$/g, '');
+ return acc + (index > 0 && !acc.endsWith('/') && trimmedPath.length > 0 ? '/' : '') + trimmedPath;
+ }, hasLeadingSlash(paths) ? '/' : '');
+ return noTrailingSlash(result);
+}
+
+function escapeRegExp(string: string): string {
+ return string.replace(/[.+^${}()|[\]\\]/g, '\\$&');
+}
+
+function tryParseValue(value: string) {
+ if (value === '' || value === undefined || value === null) {
+ return value;
+ }
+ const num = Number(value);
+ if (!isNaN(num)) {
+ return num;
+ }
+ if (value === 'true') {
+ return true;
+ }
+ if (value === 'false') {
+ return false;
+ }
+ return value;
+}
+
+const identifierRegex = /(\/)?:([a-zA-Z_]\w*)(\?)?/g;
+const paramNamePlaceholder = "paramName";
+const paramValueRegex = `(?<${paramNamePlaceholder}>[^/]+)`;
+const restParamRegex = /\/\*$/;
+
+/**
+ * Helper class for route parsing and testing (route matching).
+ */
+export class RouteHelper {
+ /**
+ * The hash path ID this route helper uses (if any). Undefined if using path routing.
+ */
+ #hashId;
+ /**
+ * Gets the test path this route helper uses to test route paths. Its value depends on the routing mode (universe).
+ */
+ readonly testPath = $derived.by(() => noTrailingSlash(this.#hashId ? (location.hashPaths[this.#hashId] || '/') : location.path));
+ /**
+ * Initializes a new instance of this class.
+ * @param hash Resolved hash value to use for (almost) all functions.
+ */
+ constructor(hash: Hash) {
+ this.#hashId = typeof hash === 'string' ? hash : (hash ? 'single' : undefined);
+ }
+ /**
+ * Parses the string pattern in the provided route information object into a regular expression.
+ * @param routeInfo Pattern route information to parse.
+ * @returns An object with the regular expression, the optional predicate function, and the ignoreForFallback flag.
+ */
+ parseRoutePattern(routeInfo: PatternRouteInfo, basePath?: string): { regex?: RegExp; and?: AndUntyped; ignoreForFallback: boolean; } {
+ if (!routeInfo.pattern) {
+ return {
+ and: routeInfo.and,
+ ignoreForFallback: !!routeInfo.ignoreForFallback
+ }
+ }
+ const fullPattern = joinPaths(basePath || '/', routeInfo.pattern === '/' ? '' : routeInfo.pattern);
+ const escapedPattern = escapeRegExp(fullPattern);
+ let regexPattern = escapedPattern.replace(identifierRegex, (_match, startingSlash, paramName, optional, offset) => {
+ let regex = paramValueRegex.replace(paramNamePlaceholder, paramName);
+ return (startingSlash ? `/${optional ? '?' : ''}` : '')
+ + (optional ? `(?:${regex})?` : regex);
+ });
+ regexPattern = regexPattern.replace(restParamRegex, `(?.*)`);
+ return {
+ regex: new RegExp(`^${regexPattern}$`, routeInfo.caseSensitive ? undefined : 'i'),
+ and: routeInfo.and,
+ ignoreForFallback: !!routeInfo.ignoreForFallback
+ };
+ }
+ /**
+ * Tests the route defined by the provided route information against the current URL to determine if it matches.
+ * @param routeMatchInfo Route information used for route matching.
+ * @returns A tuple containing the match result (a Boolean value) and any route parameters obtained.
+ */
+ testRoute(routeMatchInfo: { regex?: RegExp; and?: AndUntyped; }) {
+ const matches = routeMatchInfo.regex ? routeMatchInfo.regex.exec(this.testPath) : null;
+ const routeParams = matches?.groups ? { ...matches.groups } as RouteStatus['routeParams'] : undefined;
+ if (routeParams) {
+ for (let key in routeParams) {
+ if (routeParams[key] === undefined) {
+ delete routeParams[key];
+ continue;
+ }
+ routeParams[key] = tryParseValue(decodeURIComponent(routeParams[key] as string));
+ }
+ }
+ const match = (!!matches || !routeMatchInfo.regex) && (!routeMatchInfo.and || routeMatchInfo.and(routeParams));
+ return [match, routeParams] as const;
+ }
+}
diff --git a/src/lib/kernel/RouterEngine.svelte.ts b/src/lib/kernel/RouterEngine.svelte.ts
index 883b64d..82f43bb 100644
--- a/src/lib/kernel/RouterEngine.svelte.ts
+++ b/src/lib/kernel/RouterEngine.svelte.ts
@@ -1,9 +1,10 @@
-import type { AndUntyped, Hash, PatternRouteInfo, RegexRouteInfo, RouteInfo, RouteStatus } from "../types.js";
+import type { AndUntyped, Hash, RegexRouteInfo, RouteInfo, RouteStatus } from "../types.js";
import { traceOptions, registerRouter, unregisterRouter } from "./trace.svelte.js";
import { location } from "./Location.js";
import { routingOptions } from "./options.js";
import { resolveHashValue } from "./resolveHashValue.js";
import { assertAllowedRoutingMode } from "$lib/utils.js";
+import { joinPaths, RouteHelper } from "./RouteHelper.svelte.js";
/**
* RouterEngine's options.
@@ -30,63 +31,10 @@ function isRouterEngine(obj: unknown): obj is RouterEngine {
return obj instanceof RouterEngine;
}
-/**
- * Joins the provided paths into a single path.
- * @param paths Paths to join.
- * @returns The joined path.
- */
-export function joinPaths(...paths: string[]) {
- const result = paths.reduce((acc, path, index) => {
- const trimmedPath = (path ?? '').replace(/^\/|\/$/g, '');
- return acc + (index > 0 && !acc.endsWith('/') && trimmedPath.length > 0 ? '/' : '') + trimmedPath;
- }, hasLeadingSlash(paths) ? '/' : '');
- return noTrailingSlash(result);
-}
-
-function hasLeadingSlash(paths: (string | undefined)[]) {
- for (let path of paths) {
- if (!path) {
- continue;
- }
- return path.startsWith('/');
- }
- return false;
-}
-
-function noTrailingSlash(path: string) {
- return path !== '/' && path.endsWith('/') ? path.slice(0, -1) : path;
-}
-
function routeInfoIsRegexInfo(info: unknown): info is RegexRouteInfo {
return (info as RegexRouteInfo).regex instanceof RegExp;
}
-function escapeRegExp(string: string): string {
- return string.replace(/[.+^${}()|[\]\\]/g, '\\$&');
-}
-
-function tryParseValue(value: string) {
- if (value === '' || value === undefined || value === null) {
- return value;
- }
- const num = Number(value);
- if (!isNaN(num)) {
- return num;
- }
- if (value === 'true') {
- return true;
- }
- if (value === 'false') {
- return false;
- }
- return value;
-}
-
-const identifierRegex = /(\/)?:([a-zA-Z_]\w*)(\?)?/g;
-const paramNamePlaceholder = "paramName";
-const paramValueRegex = `(?<${paramNamePlaceholder}>[^/]+)`;
-const restParamRegex = /\/\*$/;
-
/**
* Internal key used to access the route patterns of a router engine.
*/
@@ -99,10 +47,10 @@ export const routePatternsKey = Symbol();
* `Route` components.
*/
export class RouterEngine {
+ #routeHelper;
#cleanup = false;
#parent: RouterEngine | undefined;
#resolvedHash: Hash;
- #hashId: string | undefined;
/**
* Gets or sets the router's identifier. This is displayed by the `RouterTracer` component.
*/
@@ -129,7 +77,7 @@ export class RouterEngine {
map.set(
key, routeInfoIsRegexInfo(route) ?
{ regex: route.regex, and: route.and, ignoreForFallback: !!route.ignoreForFallback } :
- this.#parseRoutePattern(route)
+ this.#routeHelper.parseRoutePattern(route, this.basePath)
);
return map;
}, new Map()));
@@ -138,25 +86,18 @@ export class RouterEngine {
return this.#routePatterns;
}
- testPath = $derived.by(() => noTrailingSlash(this.#hashId ? (location.hashPaths[this.#hashId] || '/') : this.path));
+ /**
+ * Gets the test path this router engine uses to test route paths. Its value depends on the router's routing mode
+ * (universe).
+ */
+ readonly testPath = $derived.by(() => this.#routeHelper.testPath);
#routeStatusData = $derived.by(() => {
const routeStatus = {} as Record;
let noMatches = true;
for (let routeKey of Object.keys(this.routes)) {
const pattern = this.#routePatterns.get(routeKey)!;
- const matches = pattern.regex ? pattern.regex.exec(this.testPath) : null;
- const routeParams = matches?.groups ? { ...matches.groups } as RouteStatus['routeParams'] : undefined;
- if (routeParams) {
- for (let key in routeParams) {
- if (routeParams[key] === undefined) {
- delete routeParams[key];
- continue;
- }
- routeParams[key] = tryParseValue(decodeURIComponent(routeParams[key] as string));
- }
- }
- const match = (!!matches || !pattern.regex) && (!pattern.and || pattern.and(routeParams));
+ const [match, routeParams] = this.#routeHelper.testRoute(pattern);
noMatches = noMatches && (pattern.ignoreForFallback ? true : !match);
routeStatus[routeKey] = {
match,
@@ -175,32 +116,6 @@ export class RouterEngine {
* patterns.
*/
noMatches = $derived(this.#routeStatusData[1]);
- /**
- * Parses the string pattern in the provided route information object into a regular expression.
- * @param routeInfo Pattern route information to parse.
- * @returns An object with the regular expression and the optional predicate function.
- */
- #parseRoutePattern(routeInfo: PatternRouteInfo): { regex?: RegExp; and?: AndUntyped; ignoreForFallback: boolean; } {
- if (!routeInfo.pattern) {
- return {
- and: routeInfo.and,
- ignoreForFallback: !!routeInfo.ignoreForFallback
- }
- }
- const fullPattern = joinPaths(this.basePath, routeInfo.pattern === '/' ? '' : routeInfo.pattern);
- const escapedPattern = escapeRegExp(fullPattern);
- let regexPattern = escapedPattern.replace(identifierRegex, (_match, startingSlash, paramName, optional, offset) => {
- let regex = paramValueRegex.replace(paramNamePlaceholder, paramName);
- return (startingSlash ? `/${optional ? '?' : ''}` : '')
- + (optional ? `(?:${regex})?` : regex);
- });
- regexPattern = regexPattern.replace(restParamRegex, `(?.*)`);
- return {
- regex: new RegExp(`^${regexPattern}$`, routeInfo.caseSensitive ? undefined : 'i'),
- and: routeInfo.and,
- ignoreForFallback: !!routeInfo.ignoreForFallback
- };
- }
/**
* Initializes a new instance of this class with the specified options.
*/
@@ -229,11 +144,9 @@ export class RouterEngine {
if (routingOptions.hashMode !== 'multi' && typeof this.#resolvedHash === 'string') {
throw new Error("A hash path ID was given, but is only allowed when the library's hash mode has been set to 'multi'.");
}
- this.#hashId = typeof this.#resolvedHash === 'string' ?
- this.#resolvedHash :
- (this.#resolvedHash ? 'single' : undefined);
}
assertAllowedRoutingMode(this.#resolvedHash);
+ this.#routeHelper = new RouteHelper(this.#resolvedHash);
if (traceOptions.routerHierarchy) {
registerRouter(this);
this.#cleanup = true;
@@ -247,16 +160,6 @@ export class RouterEngine {
get url() {
return location.url;
}
- /**
- * Gets the environment's current path.
- *
- * This is a sanitized version of `location.url.pathname` that strips out drive letters for the case of Electron in
- * Windows. It is highly recommended to always use this path whenever possible.
- */
- get path() {
- const hasDriveLetter = this.url.protocol.startsWith('file:') && this.url.pathname[2] === ':';
- return hasDriveLetter ? this.url.pathname.substring(3) : this.url.pathname;
- }
/**
* Gets the browser's current state.
*
diff --git a/src/lib/kernel/calculateHref.test.ts b/src/lib/kernel/calculateHref.test.ts
index a29f05a..4a6b46a 100644
--- a/src/lib/kernel/calculateHref.test.ts
+++ b/src/lib/kernel/calculateHref.test.ts
@@ -1,5 +1,6 @@
-import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
-import { calculateHref, type CalculateHrefOptions } from "./calculateHref.js";
+import { describe, test, expect, beforeAll, afterAll, beforeEach, vi } from "vitest";
+import { calculateHref } from "./calculateHref.js";
+import * as calculateMultiHashHrefModule from "./calculateMultiHashFragment.js";
import { init } from "../init.js";
import { location } from "./Location.js";
import { ROUTING_UNIVERSES, ALL_HASHES } from "$test/test-utils.js";
@@ -164,30 +165,18 @@ describe("calculateHref", () => {
});
if (universe.hashMode === 'multi') {
- describe("Multi-hash routing behavior", () => {
- test("Should preserve all existing paths when adding a new path", () => {
+ describe("Multi-hash routing integration", () => {
+ test("Should delegate to calculateMultiHashHref for multi-hash calculations", () => {
// Arrange
const newPath = "/sample/path";
- const newHashId = 'new';
+ const hashId = universe.hash || universe.defaultHash;
// Act
- const href = calculateHref({ hash: newHashId }, newPath);
+ const href = calculateHref({ hash: hashId }, newPath);
- // Assert
- expect(href).toBe(`${baseHash};${newHashId}=${newPath}`);
- });
-
- test("Should preserve all existing paths when updating an existing path", () => {
- // Arrange
- const newPath = "/sample/path";
- const existingHashId = universe.hash || universe.defaultHash; // Use the universe's hash ID
- const expected = baseHash.replace(new RegExp(`(${existingHashId}=)[^;]+`), `$1${newPath}`);
-
- // Act
- const href = calculateHref({ hash: existingHashId }, newPath);
-
- // Assert
- expect(href).toEqual(expected);
+ // Assert - Verify the result follows multi-hash format
+ expect(href).toMatch(/^#[^;]+=\/sample\/path/);
+ expect(href).toContain(';p2=path/two'); // Should preserve existing paths
});
});
}
@@ -274,4 +263,79 @@ describe("calculateHref", () => {
expect(() => calculateHref("/http-endpoint", "/https-folder")).not.toThrow();
});
});
+
+ describe("Integration with calculateMultiHashHref", () => {
+ let cleanup: Function;
+
+ beforeAll(() => {
+ cleanup = init({ hashMode: 'multi' });
+ });
+
+ afterAll(() => {
+ cleanup();
+ });
+
+ beforeEach(() => {
+ location.url.href = "https://example.com#p1=/existing/path;p2=/another/path";
+ });
+
+ test("Should call calculateMultiHashHref when hash is a string (named hash routing)", () => {
+ // Arrange
+ const spy = vi.spyOn(calculateMultiHashHrefModule, 'calculateMultiHashFragment').mockReturnValue('p1=/new/path;p2=/another/path');
+ const newPath = "/new/path";
+
+ // Act
+ const href = calculateHref({ hash: 'p1' }, newPath);
+
+ // Assert
+ expect(spy).toHaveBeenCalledWith({ p1: newPath });
+ expect(href).toBe('#p1=/new/path;p2=/another/path');
+
+ spy.mockRestore();
+ });
+
+ test("Should not call calculateMultiHashHref when hash is false (path routing)", () => {
+ // Arrange
+ const spy = vi.spyOn(calculateMultiHashHrefModule, 'calculateMultiHashFragment');
+ const newPath = "/new/path";
+
+ // Act
+ const href = calculateHref({ hash: false }, newPath);
+
+ // Assert
+ expect(spy).not.toHaveBeenCalled();
+ expect(href).toBe(newPath);
+
+ spy.mockRestore();
+ });
+
+ test("Should not call calculateMultiHashHref when hash is true (single hash routing)", () => {
+ // Arrange
+ const spy = vi.spyOn(calculateMultiHashHrefModule, 'calculateMultiHashFragment');
+ const newPath = "/new/path";
+
+ // Act
+ const href = calculateHref({ hash: true }, newPath);
+
+ // Assert
+ expect(spy).not.toHaveBeenCalled();
+ expect(href).toBe('#/new/path');
+
+ spy.mockRestore();
+ });
+
+ test("Should pass correct parameters to calculateMultiHashHref with joined paths", () => {
+ // Arrange
+ const spy = vi.spyOn(calculateMultiHashHrefModule, 'calculateMultiHashFragment').mockReturnValue('p1=/path1/path2;p2=/another/path');
+
+ // Act
+ const href = calculateHref({ hash: 'p1' }, '/path1', '/path2');
+
+ // Assert
+ expect(spy).toHaveBeenCalledWith({ p1: '/path1/path2' });
+ expect(href).toBe('#p1=/path1/path2;p2=/another/path');
+
+ spy.mockRestore();
+ });
+ });
});
diff --git a/src/lib/kernel/calculateHref.ts b/src/lib/kernel/calculateHref.ts
index 86b56f4..eab8b40 100644
--- a/src/lib/kernel/calculateHref.ts
+++ b/src/lib/kernel/calculateHref.ts
@@ -2,8 +2,9 @@ import type { Hash, PreserveQuery } from "../types.js";
import { dissectHrefs } from "./dissectHrefs.js";
import { location } from "./Location.js";
import { mergeQueryParams } from "./preserveQuery.js";
-import { joinPaths } from "./RouterEngine.svelte.js";
+import { joinPaths } from "./RouteHelper.svelte.js";
import { resolveHashValue } from "./resolveHashValue.js";
+import { calculateMultiHashFragment } from "./calculateMultiHashFragment.js";
export type CalculateHrefOptions = {
/**
@@ -27,21 +28,7 @@ export type CalculateHrefOptions = {
hash?: Hash;
};
-function calculateMultiHashHref(hashId: string, newPath: string) {
- let idExists = false;
- let finalUrl = '';
- for (let [id, path] of Object.entries(location.hashPaths)) {
- if (id === hashId) {
- idExists = true;
- path = newPath;
- }
- finalUrl += `;${id}=${path}`;
- }
- if (!idExists) {
- finalUrl += `;${hashId}=${newPath}`;
- }
- return finalUrl.substring(1);
-}
+
/**
* Combines the given HREF's into a single HREF that also includes any query string parameters that are either carried
@@ -99,7 +86,7 @@ export function calculateHref(...allArgs: (CalculateHrefOptions | string | undef
}
searchParams = mergeQueryParams(searchParams, preserveQuery);
const path = typeof hash === 'string' ?
- calculateMultiHashHref(hash, joinPaths(...dissected.paths)) :
+ calculateMultiHashFragment({ [hash]: joinPaths(...dissected.paths) }) :
joinPaths(...dissected.paths);
let hashValue = hash === false ?
dissected.hashes.find(h => h.length) || (preserveHash ? location.url.hash.substring(1) : '') :
diff --git a/src/lib/kernel/calculateMultiHashFragment.test.ts b/src/lib/kernel/calculateMultiHashFragment.test.ts
new file mode 100644
index 0000000..156574d
--- /dev/null
+++ b/src/lib/kernel/calculateMultiHashFragment.test.ts
@@ -0,0 +1,364 @@
+import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
+import { calculateMultiHashFragment } from "./calculateMultiHashFragment.js";
+import { init } from "../init.js";
+import { location } from "./Location.js";
+
+describe("calculateMultiHashHref", () => {
+ let cleanup: Function;
+
+ beforeAll(() => {
+ cleanup = init({ hashMode: 'multi' });
+ });
+
+ afterAll(() => {
+ cleanup();
+ });
+
+ describe("Basic functionality", () => {
+ beforeEach(() => {
+ // Reset location to a clean state with multiple hash paths
+ location.url.href = "https://example.com#p1=/initial/path;p2=/another/path;p3=/third/path";
+ });
+
+ test("Should preserve existing paths not specified in input.", () => {
+ // Act
+ const result = calculateMultiHashFragment({ p1: "/new/path" });
+
+ // Assert
+ expect(result).toBe("p1=/new/path;p2=/another/path;p3=/third/path");
+ });
+
+ test("Should update existing paths when specified in input.", () => {
+ // Act
+ const result = calculateMultiHashFragment({
+ p2: "/updated/path",
+ p3: "/also/updated"
+ });
+
+ // Assert
+ expect(result).toBe("p1=/initial/path;p2=/updated/path;p3=/also/updated");
+ });
+
+ test("Should add new hash paths not present in existing paths.", () => {
+ // Act
+ const result = calculateMultiHashFragment({
+ p4: "/new/hash/path",
+ p5: "/another/new/path"
+ });
+
+ // Assert
+ expect(result).toBe("p1=/initial/path;p2=/another/path;p3=/third/path;p4=/new/hash/path;p5=/another/new/path");
+ });
+
+ test("Should handle combination of preserving, updating, and adding paths.", () => {
+ // Act
+ const result = calculateMultiHashFragment({
+ p2: "/updated/existing",
+ p4: "/brand/new",
+ p6: "/another/new"
+ });
+
+ // Assert
+ expect(result).toBe("p1=/initial/path;p2=/updated/existing;p3=/third/path;p4=/brand/new;p6=/another/new");
+ });
+ });
+
+ describe("Edge cases", () => {
+ test("Should handle empty input when existing paths are present.", () => {
+ // Arrange
+ location.url.href = "https://example.com#p1=/path/one;p2=/path/two";
+
+ // Act
+ const result = calculateMultiHashFragment({});
+
+ // Assert
+ expect(result).toBe("p1=/path/one;p2=/path/two");
+ });
+
+ test("Should handle input when no existing paths are present.", () => {
+ // Arrange
+ location.url.href = "https://example.com";
+
+ // Act
+ const result = calculateMultiHashFragment({
+ p1: "/first/path",
+ p2: "/second/path"
+ });
+
+ // Assert
+ expect(result).toBe("p1=/first/path;p2=/second/path");
+ });
+
+ test("Should handle empty input with no existing paths.", () => {
+ // Arrange
+ location.url.href = "https://example.com";
+
+ // Act
+ const result = calculateMultiHashFragment({});
+
+ // Assert
+ expect(result).toBe("");
+ });
+
+ test("Should handle single existing path being updated.", () => {
+ // Arrange
+ location.url.href = "https://example.com#p1=/original/path";
+
+ // Act
+ const result = calculateMultiHashFragment({ p1: "/updated/path" });
+
+ // Assert
+ expect(result).toBe("p1=/updated/path");
+ });
+
+ test("Should handle single new path with no existing paths.", () => {
+ // Arrange
+ location.url.href = "https://example.com";
+
+ // Act
+ const result = calculateMultiHashFragment({ p1: "/new/path" });
+
+ // Assert
+ expect(result).toBe("p1=/new/path");
+ });
+ });
+
+ describe("Path value handling", () => {
+ beforeEach(() => {
+ location.url.href = "https://example.com#p1=/base/path";
+ });
+
+ test("Should handle root paths correctly.", () => {
+ // Act
+ const result = calculateMultiHashFragment({
+ p1: "/",
+ p2: "/"
+ });
+
+ // Assert
+ expect(result).toBe("p1=/;p2=/");
+ });
+
+ test("Should remove paths when given empty path values.", () => {
+ // Act
+ const result = calculateMultiHashFragment({
+ p1: "",
+ p2: ""
+ });
+
+ // Assert - Empty paths should be completely removed
+ expect(result).toBe("");
+ });
+
+ test("Should handle complex nested paths.", () => {
+ // Act
+ const result = calculateMultiHashFragment({
+ p1: "/users/123/profile/edit",
+ p2: "/admin/settings/permissions/advanced"
+ });
+
+ // Assert
+ expect(result).toBe("p1=/users/123/profile/edit;p2=/admin/settings/permissions/advanced");
+ });
+
+ test("Should preserve path parameters and special characters.", () => {
+ // Act
+ const result = calculateMultiHashFragment({
+ p1: "/api/users/:id",
+ p2: "/path/with-dashes/and_underscores"
+ });
+
+ // Assert
+ expect(result).toBe("p1=/api/users/:id;p2=/path/with-dashes/and_underscores");
+ });
+ });
+
+ describe("Empty string path removal", () => {
+ beforeEach(() => {
+ location.url.href = "https://example.com#p1=/existing/path;p2=/another/existing;p3=/third/existing";
+ });
+
+ test("Should remove existing paths when updated with empty string.", () => {
+ // Act
+ const result = calculateMultiHashFragment({ p2: "" });
+
+ // Assert - p2 should be completely removed
+ expect(result).toBe("p1=/existing/path;p3=/third/existing");
+ });
+
+ test("Should not add new paths when given empty string.", () => {
+ // Act
+ const result = calculateMultiHashFragment({ p4: "" });
+
+ // Assert - p4 should not be added at all
+ expect(result).toBe("p1=/existing/path;p2=/another/existing;p3=/third/existing");
+ });
+
+ test("Should handle mix of valid updates and empty string removals.", () => {
+ // Act
+ const result = calculateMultiHashFragment({
+ p1: "/updated/path", // Update existing
+ p2: "", // Remove existing
+ p4: "/new/path" // Add new valid
+ });
+
+ // Assert
+ expect(result).toBe("p1=/updated/path;p3=/third/existing;p4=/new/path");
+ });
+
+ test("Should handle all existing paths being cleared with empty strings.", () => {
+ // Act
+ const result = calculateMultiHashFragment({
+ p1: "",
+ p2: "",
+ p3: ""
+ });
+
+ // Assert - All paths removed, result should be empty
+ expect(result).toBe("");
+ });
+
+ test("Should distinguish between empty string (removal) and valid root path.", () => {
+ // Arrange
+ location.url.href = "https://example.com#p1=/existing";
+
+ // Act
+ const result = calculateMultiHashFragment({
+ p1: "/", // Valid root path
+ p2: "", // Empty string - should be skipped
+ p3: "/valid" // Valid path
+ });
+
+ // Assert - Only valid paths should be included
+ expect(result).toBe("p1=/;p3=/valid");
+ });
+
+ test("Should preserve order when some paths are removed via empty strings.", () => {
+ // Arrange
+ location.url.href = "https://example.com#a=/path/a;b=/path/b;c=/path/c;d=/path/d;e=/path/e";
+
+ // Act - Remove alternating paths with empty strings
+ const result = calculateMultiHashFragment({
+ a: "/updated/a",
+ b: "", // Remove
+ c: "/updated/c",
+ d: "", // Remove
+ f: "/new/f"
+ });
+
+ // Assert - Should maintain original order for kept paths, append new ones
+ expect(result).toBe("a=/updated/a;c=/updated/c;e=/path/e;f=/new/f");
+ });
+
+ test("Should handle when only empty strings are provided for new paths.", () => {
+ // Arrange
+ location.url.href = "https://example.com#existing=/kept";
+
+ // Act
+ const result = calculateMultiHashFragment({
+ new1: "",
+ new2: "",
+ new3: ""
+ });
+
+ // Assert - No new paths should be added, only existing preserved
+ expect(result).toBe("existing=/kept");
+ });
+ });
+
+ describe("Hash ID handling", () => {
+ beforeEach(() => {
+ location.url.href = "https://example.com#main=/main/path;secondary=/secondary/path";
+ });
+
+ test("Should handle various hash ID formats.", () => {
+ // Act
+ const result = calculateMultiHashFragment({
+ "main": "/updated/main",
+ "my-hash": "/new/dash",
+ "my_hash": "/new/underscore",
+ "hash123": "/numeric/suffix"
+ });
+
+ // Assert
+ expect(result).toBe("main=/updated/main;secondary=/secondary/path;my-hash=/new/dash;my_hash=/new/underscore;hash123=/numeric/suffix");
+ });
+
+ test("Should handle single character hash IDs.", () => {
+ // Act
+ const result = calculateMultiHashFragment({
+ "a": "/path/a",
+ "x": "/path/x",
+ "z": "/path/z"
+ });
+
+ // Assert
+ expect(result).toBe("main=/main/path;secondary=/secondary/path;a=/path/a;x=/path/x;z=/path/z");
+ });
+ });
+
+ describe("Order preservation", () => {
+ test("Should maintain existing path order and append new paths in input order.", () => {
+ // Arrange
+ location.url.href = "https://example.com#zebra=/z/path;alpha=/a/path;beta=/b/path";
+
+ // Act
+ const result = calculateMultiHashFragment({
+ gamma: "/g/path",
+ alpha: "/updated/a/path",
+ delta: "/d/path"
+ });
+
+ // Assert
+ // Existing paths maintain their original order, new paths are appended in input order
+ expect(result).toBe("zebra=/z/path;alpha=/updated/a/path;beta=/b/path;gamma=/g/path;delta=/d/path");
+ });
+
+ test("Should preserve order when only adding new paths.", () => {
+ // Arrange
+ location.url.href = "https://example.com#c=/c/path;a=/a/path;b=/b/path";
+
+ // Act
+ const result = calculateMultiHashFragment({
+ z: "/z/path",
+ x: "/x/path",
+ y: "/y/path"
+ });
+
+ // Assert
+ expect(result).toBe("c=/c/path;a=/a/path;b=/b/path;z=/z/path;x=/x/path;y=/y/path");
+ });
+ });
+
+ describe("Cross-universe redirection use cases", () => {
+ test("Should support clearing a source universe path while setting target universe path.", () => {
+ // Arrange - simulate having both source and target universes
+ location.url.href = "https://example.com#source=/current/source/path;target=/current/target/path";
+
+ // Act - Clear source and redirect to new target
+ const result = calculateMultiHashFragment({
+ source: "", // Clear the source universe with empty string
+ target: "/new/target/path" // Set new target path
+ });
+
+ // Assert - Source should be completely removed, not left as empty
+ expect(result).toBe("target=/new/target/path");
+ });
+
+ test("Should support updating multiple universes simultaneously for complex redirection scenarios.", () => {
+ // Arrange
+ location.url.href = "https://example.com#main=/main/current;sidebar=/sidebar/current;modal=/modal/current";
+
+ // Act - Complex cross-universe update scenario
+ const result = calculateMultiHashFragment({
+ main: "/main/redirected",
+ sidebar: "", // Clear sidebar with empty string
+ modal: "/modal/new",
+ notifications: "/notifications/init" // Add new universe
+ });
+
+ // Assert - Sidebar should be completely removed, not left as empty
+ expect(result).toBe("main=/main/redirected;modal=/modal/new;notifications=/notifications/init");
+ });
+ });
+});
diff --git a/src/lib/kernel/calculateMultiHashFragment.ts b/src/lib/kernel/calculateMultiHashFragment.ts
new file mode 100644
index 0000000..542f3eb
--- /dev/null
+++ b/src/lib/kernel/calculateMultiHashFragment.ts
@@ -0,0 +1,26 @@
+import { location } from "./Location.js";
+
+/**
+ * Calculates a new hash fragment with the specified named hash paths while preserving any existing hash paths not
+ * specified. Paths set to empty string ("") will be completely removed from the hash fragment.
+ * @param hashPaths The hash paths to include (or remove via empty strings) in the final HREF.
+ * @returns The calculated hash fragment (without the leading `#`).
+ */
+export function calculateMultiHashFragment(hashPaths: Record) {
+ const existingIds = new Set();
+ let finalUrl = '';
+ for (let [id, path] of Object.entries(location.hashPaths)) {
+ existingIds.add(id);
+ path = hashPaths[id] ?? path;
+ if (path) {
+ finalUrl += `;${id}=${path}`;
+ }
+ }
+ for (let [hashId, newPath] of Object.entries(hashPaths)) {
+ if (existingIds.has(hashId) || !newPath) {
+ continue;
+ }
+ finalUrl += `;${hashId}=${newPath}`;
+ }
+ return finalUrl.substring(1);
+}
diff --git a/src/lib/kernel/index.test.ts b/src/lib/kernel/index.test.ts
index 0b2d41f..b33bde1 100644
--- a/src/lib/kernel/index.test.ts
+++ b/src/lib/kernel/index.test.ts
@@ -17,6 +17,7 @@ describe('index', () => {
'LocationLite',
'LocationFull',
'preserveQueryInUrl',
+ 'calculateMultiHashFragment'
];
// Act.
diff --git a/src/lib/kernel/index.ts b/src/lib/kernel/index.ts
index 27e9744..fee3d66 100644
--- a/src/lib/kernel/index.ts
+++ b/src/lib/kernel/index.ts
@@ -1,7 +1,9 @@
export { location } from "./Location.js";
-export { RouterEngine, joinPaths } from "./RouterEngine.svelte.js";
+export { RouterEngine } from "./RouterEngine.svelte.js";
+export { joinPaths } from "./RouteHelper.svelte.js";
export { isConformantState } from "./isConformantState.js";
export { calculateHref } from "./calculateHref.js";
+export { calculateMultiHashFragment } from "./calculateMultiHashFragment.js";
export { calculateState } from "./calculateState.js";
export { initCore } from "./initCore.js";
export { LocationState } from "./LocationState.svelte.js";
diff --git a/src/lib/kernel/preserveQuery.test.ts b/src/lib/kernel/preserveQuery.test.ts
index fa1c1c4..d2c081c 100644
--- a/src/lib/kernel/preserveQuery.test.ts
+++ b/src/lib/kernel/preserveQuery.test.ts
@@ -72,4 +72,219 @@ describe('preserveQuery utilities', () => {
expect(result?.get('another')).toBe('param');
});
});
+
+ describe('mergeQueryParams - Two URLSearchParams overload', () => {
+ test('Should merge two non-empty URLSearchParams correctly.', () => {
+ const set1 = new URLSearchParams('param1=value1¶m2=value2');
+ const set2 = new URLSearchParams('param3=value3¶m4=value4');
+
+ const result = mergeQueryParams(set1, set2);
+
+ expect(result?.get('param1')).toBe('value1');
+ expect(result?.get('param2')).toBe('value2');
+ expect(result?.get('param3')).toBe('value3');
+ expect(result?.get('param4')).toBe('value4');
+ });
+
+ test('Should handle duplicate parameter names by keeping both values.', () => {
+ const set1 = new URLSearchParams('shared=first&unique1=value1');
+ const set2 = new URLSearchParams('shared=second&unique2=value2');
+
+ const result = mergeQueryParams(set1, set2);
+
+ expect(result?.getAll('shared')).toEqual(['first', 'second']);
+ expect(result?.get('unique1')).toBe('value1');
+ expect(result?.get('unique2')).toBe('value2');
+ });
+
+ test('Should return set1 when set2 is undefined.', () => {
+ const set1 = new URLSearchParams('param=value');
+
+ const result = mergeQueryParams(set1, undefined);
+
+ expect(result).toBe(set1);
+ });
+
+ test('Should return set1 when set2 is empty.', () => {
+ const set1 = new URLSearchParams('param=value');
+ const set2 = new URLSearchParams();
+
+ const result = mergeQueryParams(set1, set2);
+
+ expect(result).toBe(set1);
+ });
+
+ test('Should return set2 when set1 is undefined and set2 has parameters.', () => {
+ const set2 = new URLSearchParams('param=value');
+
+ const result = mergeQueryParams(undefined, set2);
+
+ expect(result).toBe(set2);
+ });
+
+ test('Should return undefined when both sets are undefined.', () => {
+ const result = mergeQueryParams(undefined, undefined);
+
+ expect(result).toBeUndefined();
+ });
+
+ test('Should return undefined when set1 is undefined and set2 is empty.', () => {
+ const set2 = new URLSearchParams();
+
+ const result = mergeQueryParams(undefined, set2);
+
+ expect(result).toBeUndefined();
+ });
+
+ test('Should return set1 when both sets are empty.', () => {
+ const set1 = new URLSearchParams();
+ const set2 = new URLSearchParams();
+
+ const result = mergeQueryParams(set1, set2);
+
+ expect(result).toBe(set1);
+ });
+
+ test('Should handle parameters with empty values.', () => {
+ const set1 = new URLSearchParams('empty1=&normal=value');
+ const set2 = new URLSearchParams('empty2=&another=test');
+
+ const result = mergeQueryParams(set1, set2);
+
+ expect(result?.get('empty1')).toBe('');
+ expect(result?.get('empty2')).toBe('');
+ expect(result?.get('normal')).toBe('value');
+ expect(result?.get('another')).toBe('test');
+ });
+
+ test('Should handle parameters with special characters.', () => {
+ const set1 = new URLSearchParams('special=hello%20world&plus=test+value');
+ const set2 = new URLSearchParams('encoded=user%40example.com&symbols=%21%40%23');
+
+ const result = mergeQueryParams(set1, set2);
+
+ expect(result?.get('special')).toBe('hello world');
+ expect(result?.get('plus')).toBe('test value');
+ expect(result?.get('encoded')).toBe('user@example.com');
+ expect(result?.get('symbols')).toBe('!@#');
+ });
+
+ test('Should handle multiple values for the same parameter name.', () => {
+ const set1 = new URLSearchParams();
+ set1.append('multi', 'value1');
+ set1.append('multi', 'value2');
+
+ const set2 = new URLSearchParams();
+ set2.append('multi', 'value3');
+ set2.append('other', 'single');
+
+ const result = mergeQueryParams(set1, set2);
+
+ expect(result?.getAll('multi')).toEqual(['value1', 'value2', 'value3']);
+ expect(result?.get('other')).toBe('single');
+ });
+
+ test('Should preserve parameter order when merging.', () => {
+ const set1 = new URLSearchParams('a=1&b=2');
+ const set2 = new URLSearchParams('c=3&d=4');
+
+ const result = mergeQueryParams(set1, set2);
+
+ const entries = Array.from(result?.entries() || []);
+ expect(entries).toEqual([
+ ['a', '1'],
+ ['b', '2'],
+ ['c', '3'],
+ ['d', '4']
+ ]);
+ });
+
+ test('Should handle complex real-world scenario.', () => {
+ // Simulate path router parameters
+ const pathParams = new URLSearchParams('userId=123&action=edit');
+
+ // Simulate hash router parameters
+ const hashParams = new URLSearchParams('tab=settings&mode=advanced&userId=456');
+
+ const result = mergeQueryParams(pathParams, hashParams);
+
+ expect(result?.getAll('userId')).toEqual(['123', '456']);
+ expect(result?.get('action')).toBe('edit');
+ expect(result?.get('tab')).toBe('settings');
+ expect(result?.get('mode')).toBe('advanced');
+ });
+
+ test('Should return set1 with set2 parameters appended when merging occurs.', () => {
+ const set1 = new URLSearchParams('param1=value1');
+ const set2 = new URLSearchParams('param2=value2');
+
+ const result = mergeQueryParams(set1, set2);
+
+ // Function returns set1 (performance optimization) with set2 params appended
+ expect(result).toBe(set1);
+ expect(result?.get('param1')).toBe('value1');
+ expect(result?.get('param2')).toBe('value2');
+ });
+
+ test('Should handle edge case with only set2 having parameters when set1 is empty.', () => {
+ const set1 = new URLSearchParams(); // Empty
+ const set2 = new URLSearchParams('onlyInSet2=value');
+
+ const result = mergeQueryParams(set1, set2);
+
+ // Function returns set1 (performance optimization) with set2 params appended
+ expect(result).toBe(set1);
+ expect(result?.get('onlyInSet2')).toBe('value');
+ });
+
+ test('Should verify performance optimization - returns original objects when possible.', () => {
+ // Test case 1: Returns set2 when set1 is undefined
+ const set2Only = new URLSearchParams('param=value');
+ const result1 = mergeQueryParams(undefined, set2Only);
+ expect(result1).toBe(set2Only);
+
+ // Test case 2: Returns set1 when set2 is empty
+ const set1Only = new URLSearchParams('param=value');
+ const emptySet = new URLSearchParams();
+ const result2 = mergeQueryParams(set1Only, emptySet);
+ expect(result2).toBe(set1Only);
+
+ // Test case 3: Returns set1 when both have parameters (modifies set1 in-place)
+ const set1Modified = new URLSearchParams('existing=value');
+ const set2ToMerge = new URLSearchParams('new=param');
+ const result3 = mergeQueryParams(set1Modified, set2ToMerge);
+ expect(result3).toBe(set1Modified);
+ expect(set1Modified.get('existing')).toBe('value'); // Original param
+ expect(set1Modified.get('new')).toBe('param'); // Merged param
+ });
+
+ test('Should create new URLSearchParams only when set1 is undefined and set2 has parameters.', () => {
+ const set2 = new URLSearchParams('param=value');
+
+ // This is the only case where a truly new instance is created
+ const result = mergeQueryParams(undefined, set2);
+
+ // Actually, this returns set2 directly for performance, so this test documents that behavior
+ expect(result).toBe(set2);
+ });
+
+ test('Should not modify set2 when merging into set1.', () => {
+ const set1 = new URLSearchParams('original1=value1');
+ const set2 = new URLSearchParams('original2=value2');
+
+ // Store original values to verify they don't change
+ const originalSet2String = set2.toString();
+
+ const result = mergeQueryParams(set1, set2);
+
+ // set1 should be modified (it's the return value)
+ expect(result).toBe(set1);
+ expect(result?.get('original1')).toBe('value1');
+ expect(result?.get('original2')).toBe('value2');
+
+ // set2 should remain unchanged
+ expect(set2.toString()).toBe(originalSet2String);
+ expect(set2.get('original1')).toBeNull(); // Should not have set1's params
+ });
+ });
});
diff --git a/src/lib/kernel/preserveQuery.ts b/src/lib/kernel/preserveQuery.ts
index 9bd2c9a..fcbc916 100644
--- a/src/lib/kernel/preserveQuery.ts
+++ b/src/lib/kernel/preserveQuery.ts
@@ -14,25 +14,52 @@ export function preserveQueryInUrl(url: string, preserveQuery: PreserveQuery): s
}
/**
- * Internal helper to merge query parameters for calculateHref.
- * This handles the URLSearchParams merging logic without URL reconstruction.
- * @param existingParams Existing URLSearchParams from the new URL.
+ * Helper that merges query parameters from 2 URL's together.
+ *
+ * ### Important Notes
+ *
+ * + To preserve system resources, `set1` is modified directly to contain the merged results.
+ * + If the provided `set1` is `undefined` and all query parameters are to be preserved, then `set2` will be returned
+ * directly.
+ * + If `set1` is `undefined`, a new `URLSearchParams` will be created (and returned) to contain the merged results.
+ * + The return value will be `undefined` whenever `set1` is `undefined` and `set2` is also `undefined` or empty.
+ * @param set1: First set of query parameters.
+ * @param set2: Second set of query parameters.
+ * @returns The merged `URLSearchParams`, or `undefined`.
+ */
+export function mergeQueryParams(set1: URLSearchParams | undefined, set2: URLSearchParams | undefined): URLSearchParams | undefined;
+/**
+ * Helper that merges the given search parameters with the ones found in the current environment's URL.
+ *
+ * ### Important Notes
+ *
+ * + To preserve system resources, `existingParams` is modified directly to contain the merged results.
+ * + The `URLSearchParams` from the global `location` object will be returned when all query parameters are preserved
+ * and `existingParams` is `undefined`.
+ * + If `existingParams` is `undefined`, a new `URLSearchParams` will be created (and returned) to contain the merged
+ * results.
+ * + The return value will be `undefined` whenever `existingParams` is `undefined` and the global `location`'s search
+ * parameters are empty.
+ * @param existingParams Existing `URLSearchParams` from the new URL.
* @param preserveQuery The query preservation options.
- * @returns The merged URLSearchParams or undefined if no merging is needed.
+ * @returns The merged `URLSearchParams`, or `undefined`.
*/
-export function mergeQueryParams(existingParams: URLSearchParams | undefined, preserveQuery: PreserveQuery): URLSearchParams | undefined {
- if (!preserveQuery || !location.url.searchParams.size) {
- return existingParams;
+export function mergeQueryParams(existingParams: URLSearchParams | undefined, preserveQuery?: PreserveQuery): URLSearchParams | undefined;
+export function mergeQueryParams(set1: URLSearchParams | undefined, pqOrSet2: PreserveQuery | URLSearchParams | undefined): URLSearchParams | undefined {
+ const set2 = pqOrSet2 instanceof URLSearchParams ? pqOrSet2 : location.url.searchParams;
+ const preserveQuery = pqOrSet2 instanceof URLSearchParams ? true : pqOrSet2;
+ if (!pqOrSet2 || !set2.size) {
+ return set1;
}
- if (!existingParams && preserveQuery === true) {
- return location.url.searchParams;
+ if (!set1 && preserveQuery === true) {
+ return set2;
}
- const mergedParams = existingParams ?? new URLSearchParams();
+ const mergedParams = set1 ?? new URLSearchParams();
const transferValue = (key: string) => {
- const values = location.url.searchParams.getAll(key);
+ const values = set2.getAll(key);
if (values.length) {
values.forEach((v) => mergedParams.append(key, v));
}
@@ -41,7 +68,7 @@ export function mergeQueryParams(existingParams: URLSearchParams | undefined, pr
if (typeof preserveQuery === 'string') {
transferValue(preserveQuery);
} else {
- for (let key of (Array.isArray(preserveQuery) ? preserveQuery : location.url.searchParams.keys())) {
+ for (let key of (Array.isArray(preserveQuery) ? preserveQuery : set2.keys())) {
transferValue(key);
}
}
diff --git a/src/lib/testing/test-utils.ts b/src/lib/testing/test-utils.ts
index 4473e57..b4580aa 100644
--- a/src/lib/testing/test-utils.ts
+++ b/src/lib/testing/test-utils.ts
@@ -24,7 +24,7 @@ export type RoutingUniverse = {
/**
* Short universe identifier. Used in test titles and descriptions.
*/
- text: string;
+ text: 'IPR' | 'PR' | 'IHR' | 'HR' | 'IMHR' | 'MHR';
/**
* Descriptive universe name. More of a document-by-code property. Not commonly used as it makes text very long.
*/
diff --git a/src/lib/testing/testWithEffect.svelte.ts b/src/lib/testing/testWithEffect.svelte.ts
new file mode 100644
index 0000000..ff813e3
--- /dev/null
+++ b/src/lib/testing/testWithEffect.svelte.ts
@@ -0,0 +1,16 @@
+import { test } from "vitest";
+
+export function testWithEffect(name: string, fn: () => void | Promise) {
+ test(name, () => {
+ let promise!: void | Promise;
+ const cleanup = $effect.root(() => {
+ promise = fn();
+ });
+ if (promise) {
+ return promise.finally(cleanup);
+ }
+ else {
+ cleanup();
+ }
+ });
+}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index abeb0c4..6bde376 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -101,6 +101,40 @@ export type PatternRouteInfo = CoreRouteInfo & {
*/
export type RouteInfo = RegexRouteInfo | PatternRouteInfo;
+/**
+ * Distributes the Omit over unions.
+ */
+type NoIgnoreForFallback = T extends any ? Omit : never;
+
+/**
+ * Defines the shape of redirection information used by the Redirector class.
+ */
+export type RedirectedRouteInfo = NoIgnoreForFallback & {
+ /**
+ * The HREF to navigate to (via `location.navigate()` or `location.goTo()`). It can be a string or a function that
+ * receives the matched route parameters and returns a string.
+ */
+ href: string | ((routeParams: Record | undefined) => string);
+} & ({
+ /**
+ * Indicates that the redirection should use the `Location.goTo` method.
+ */
+ goTo: true;
+ /**
+ * Options for the `Location.goTo` method.
+ */
+ options?: GoToOptions;
+} | {
+ /**
+ * Indicates that the redirection should use the `Location.goTo` method.
+ */
+ goTo?: false;
+ /**
+ * Options for the `Location.navigate` method.
+ */
+ options?: NavigateOptions;
+});
+
/**
* Defines the options that can be used when calling `Location.goTo`.
*/
@@ -149,6 +183,11 @@ export type NavigateOptions = Omit & {
preserveHash?: boolean;
});
+/**
+ * Defines the options for the `buildHref` function.
+ */
+export type BuildHrefOptions = Pick;
+
/**
* Defines the capabilities of the location object, central for all routing functionality.
*/
@@ -157,6 +196,12 @@ export interface Location {
* Gets a reactive URL object with the current window's URL.
*/
readonly url: URL;
+ /**
+ * Gets the environment's current path, "sanitized" for the cases of `file:` URL's in Windows.
+ *
+ * It is highly recommended to always use this path instead of `Location.url.pathname` whenever possible.
+ */
+ readonly path: string;
/**
* Gets the current hash path or paths, depending on how the library was initialized.
*