diff --git a/src/lib/Fallback/Fallback.svelte b/src/lib/Fallback/Fallback.svelte index 3ca5ea7..070fa5a 100644 --- a/src/lib/Fallback/Fallback.svelte +++ b/src/lib/Fallback/Fallback.svelte @@ -2,6 +2,7 @@ import { resolveHashValue } from '$lib/core/resolveHashValue.js'; import { getRouterContext } from '$lib/Router/Router.svelte'; import type { RouteStatus, WhenPredicate } from '$lib/types.js'; + import { assertAllowedRoutingMode } from '$lib/utils.js'; import type { Snippet } from 'svelte'; type Props = { @@ -63,7 +64,10 @@ let { hash, when, children }: Props = $props(); - const router = getRouterContext(resolveHashValue(hash)); + const resolvedHash = resolveHashValue(hash); + assertAllowedRoutingMode(resolvedHash); + + const router = getRouterContext(resolvedHash); {#if (router && when?.(router.routeStatus, router.noMatches)) || (!when && router?.noMatches)} diff --git a/src/lib/Fallback/Fallback.svelte.test.ts b/src/lib/Fallback/Fallback.svelte.test.ts index 9fba6de..8cde575 100644 --- a/src/lib/Fallback/Fallback.svelte.test.ts +++ b/src/lib/Fallback/Fallback.svelte.test.ts @@ -2,8 +2,10 @@ import { init } from "$lib/init.js"; import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest"; import { render } from "@testing-library/svelte"; import Fallback from "./Fallback.svelte"; -import { addMatchingRoute, addRoutes, createRouterTestSetup, createTestSnippet, ROUTING_UNIVERSES } from "../../testing/test-utils.js"; +import { addMatchingRoute, addRoutes, createRouterTestSetup, createTestSnippet, ROUTING_UNIVERSES, ALL_HASHES } from "../../testing/test-utils.js"; import { flushSync } from "svelte"; +import { resetRoutingOptions, setRoutingOptions } from "$lib/core/options.js"; +import type { ExtendedRoutingOptions } from "$lib/types.js"; function defaultPropsTests(setup: ReturnType) { const contentText = "Fallback content."; @@ -161,6 +163,57 @@ function reactivityTests(setup: ReturnType) { }); } +describe("Routing Mode Assertions", () => { + const contentText = "Fallback content."; + const content = createTestSnippet(contentText); + let cleanup: () => void; + + beforeAll(() => { + cleanup = init(); + }); + + beforeEach(() => { + resetRoutingOptions(); + }); + + afterAll(() => { + resetRoutingOptions(); + cleanup(); + }); + + test.each<{ + options: Partial; + hash: typeof ALL_HASHES[keyof typeof ALL_HASHES]; + description: string; + }>([ + { + options: { disallowHashRouting: true }, + hash: ALL_HASHES.single, + description: 'hash routing is disallowed' + }, + { + options: { disallowMultiHashRouting: true }, + hash: ALL_HASHES.multi, + description: 'multi-hash routing is disallowed' + }, + { + options: { disallowPathRouting: true }, + hash: ALL_HASHES.path, + description: 'path routing is disallowed' + } + ])("Should throw error when $description and hash=$hash .", ({ options, hash }) => { + // Arrange + setRoutingOptions(options); + + // Act & Assert + expect(() => { + render(Fallback, { + props: { hash, children: content }, + }); + }).toThrow(); + }); +}); + ROUTING_UNIVERSES.forEach(ru => { describe(`Fallback - ${ru.text}`, () => { const setup = createRouterTestSetup(ru.hash); diff --git a/src/lib/Link/Link.svelte b/src/lib/Link/Link.svelte index 67d1a97..b74f146 100644 --- a/src/lib/Link/Link.svelte +++ b/src/lib/Link/Link.svelte @@ -6,6 +6,7 @@ import { getLinkContext, type ILinkContext } from '$lib/LinkContext/LinkContext.svelte'; import { getRouterContext } from '$lib/Router/Router.svelte'; import type { ActiveState, Hash, RouteStatus } from '$lib/types.js'; + import { assertAllowedRoutingMode } from '$lib/utils.js'; import { type Snippet } from 'svelte'; import type { HTMLAnchorAttributes } from 'svelte/elements'; @@ -85,6 +86,7 @@ }: Props = $props(); const resolvedHash = resolveHashValue(hash); + assertAllowedRoutingMode(resolvedHash); const router = getRouterContext(resolvedHash); const linkContext = getLinkContext(); const calcReplace = $derived(replace ?? linkContext?.replace ?? false); diff --git a/src/lib/Link/Link.svelte.test.ts b/src/lib/Link/Link.svelte.test.ts index 07c441c..f8041ee 100644 --- a/src/lib/Link/Link.svelte.test.ts +++ b/src/lib/Link/Link.svelte.test.ts @@ -1,10 +1,12 @@ import { init } from "$lib/init.js"; import { location } from "$lib/core/Location.js"; -import { describe, test, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import { describe, test, expect, beforeAll, afterAll, beforeEach, vi, afterEach } from "vitest"; import { render, fireEvent } from "@testing-library/svelte"; import Link from "./Link.svelte"; -import { createRouterTestSetup, createTestSnippet, ROUTING_UNIVERSES } from "../../testing/test-utils.js"; +import { createRouterTestSetup, createTestSnippet, ROUTING_UNIVERSES, ALL_HASHES } from "../../testing/test-utils.js"; import { flushSync } from "svelte"; +import { resetRoutingOptions, setRoutingOptions } from "$lib/core/options.js"; +import type { ExtendedRoutingOptions } from "$lib/types.js"; function basicLinkTests(setup: ReturnType) { const linkText = "Test Link"; @@ -636,6 +638,61 @@ function reactivityTests(setup: ReturnType) { }); } +describe("Routing Mode Assertions", () => { + const linkText = "Test Link"; + const content = createTestSnippet(linkText); + let cleanup: () => void; + + beforeAll(() => { + cleanup = init(); + }); + + afterEach(() => { + resetRoutingOptions(); + }); + + afterAll(() => { + cleanup(); + }); + + test.each<{ + options: Partial; + hash: typeof ALL_HASHES[keyof typeof ALL_HASHES]; + description: string; + }>([ + { + options: { disallowHashRouting: true }, + hash: ALL_HASHES.single, + description: 'hash routing is disallowed' + }, + { + options: { disallowMultiHashRouting: true }, + hash: ALL_HASHES.multi, + description: 'multi-hash routing is disallowed' + }, + { + options: { disallowPathRouting: true }, + hash: ALL_HASHES.path, + description: 'path routing is disallowed' + } + ])("Should throw error when $description and hash=$hash .", ({ options, hash }) => { + // Arrange + setRoutingOptions(options); + + // Act & Assert + expect(() => { + render(Link, { + props: { + href: "/test", + hash, + children: content + }, + }); + }).toThrow(); + }); +}); + + ROUTING_UNIVERSES.forEach(ru => { describe(`Link - ${ru.text}`, () => { const setup = createRouterTestSetup(ru.hash); @@ -649,7 +706,7 @@ ROUTING_UNIVERSES.forEach(ru => { }); afterAll(() => { - cleanup(); + cleanup?.(); }); describe("Basic Link Functionality", () => { diff --git a/src/lib/Route/Route.svelte b/src/lib/Route/Route.svelte index cb82add..8e69e71 100644 --- a/src/lib/Route/Route.svelte +++ b/src/lib/Route/Route.svelte @@ -21,6 +21,7 @@ import { getRouterContext } from '../Router/Router.svelte'; import { resolveHashValue } from '$lib/core/resolveHashValue.js'; import type { ParameterValue, RouteStatus } from '$lib/types.js'; + import { assertAllowedRoutingMode } from '$lib/utils.js'; type Props = { /** @@ -132,7 +133,10 @@ children }: Props = $props(); - const router = getRouterContext(resolveHashValue(hash)); + const resolvedHash = resolveHashValue(hash); + assertAllowedRoutingMode(resolvedHash); + + const router = getRouterContext(resolvedHash); if (!router) { throw new Error( 'Route components must be used inside a Router component that matches the hash setting.' diff --git a/src/lib/Route/Route.svelte.test.ts b/src/lib/Route/Route.svelte.test.ts index f5a7a73..338830c 100644 --- a/src/lib/Route/Route.svelte.test.ts +++ b/src/lib/Route/Route.svelte.test.ts @@ -1,10 +1,12 @@ -import { describe, test, expect, beforeEach, vi, beforeAll, afterAll } from "vitest"; +import { describe, test, expect, beforeEach, vi, beforeAll, afterAll, afterEach } from "vitest"; import { render } from "@testing-library/svelte"; import Route from "./Route.svelte"; -import { createTestSnippet, createRouterTestSetup, ROUTING_UNIVERSES } from "../../testing/test-utils.js"; +import { createTestSnippet, createRouterTestSetup, ROUTING_UNIVERSES, ALL_HASHES } from "../../testing/test-utils.js"; import { init } from "$lib/init.js"; import { location } from "$lib/core/Location.js"; import TestRouteWithRouter from "../../testing/TestRouteWithRouter.svelte"; +import { resetRoutingOptions, setRoutingOptions } from "$lib/core/options.js"; +import type { ExtendedRoutingOptions, InitOptions } from "$lib/types.js"; function basicRouteTests(setup: ReturnType) { beforeEach(() => { @@ -646,6 +648,78 @@ function routeBindingTestsForUniverse(setup: ReturnType { + let cleanup: () => void; + + beforeAll(() => { + cleanup = init(); + }); + + afterEach(() => { + resetRoutingOptions(); + }); + + afterAll(() => { + cleanup(); + }); + + test.each<{ + options: Partial; + hash: typeof ALL_HASHES[keyof typeof ALL_HASHES]; + description: string; + }>([ + { + options: { disallowHashRouting: true }, + hash: ALL_HASHES.single, + description: 'hash routing is disallowed' + }, + { + options: { disallowMultiHashRouting: true }, + hash: ALL_HASHES.multi, + description: 'multi-hash routing is disallowed' + }, + { + options: { disallowPathRouting: true }, + hash: ALL_HASHES.path, + description: 'path routing is disallowed' + } + ])("Should throw error when $description and hash=$hash .", ({ options, hash }) => { + // Arrange + setRoutingOptions(options); + + // Act & Assert + expect(() => { + render(Route, { + props: { + key: 'r1', + hash, + }, + }); + }).toThrow(); + }); + + test("Should not throw error when all routing modes are allowed.", () => { + // Arrange + const hash = ALL_HASHES.single; + const setup = createRouterTestSetup(hash); + setup.init(); + + // Act & Assert + expect(() => { + render(Route, { + props: { + hash, + key: "test-route", + }, + context: setup.context + }); + }).not.toThrow(); + + // Cleanup + setup.dispose(); + }); +}); + // Run tests for each routing universe for (const ru of ROUTING_UNIVERSES) { describe(`Route - ${ru.text}`, () => { diff --git a/src/lib/RouterTrace/RouterTrace.svelte b/src/lib/RouterTrace/RouterTrace.svelte index e79ac51..5df7ba1 100644 --- a/src/lib/RouterTrace/RouterTrace.svelte +++ b/src/lib/RouterTrace/RouterTrace.svelte @@ -8,6 +8,7 @@ import { getRouterContext } from '$lib/Router/Router.svelte'; import type { PatternRouteInfo } from '$lib/types.js'; import type { HTMLTableAttributes } from 'svelte/elements'; + import { assertAllowedRoutingMode } from '$lib/utils.js'; type Props = Omit & { /** @@ -56,7 +57,9 @@ }: Props = $props(); if (!router) { - router = getRouterContext(resolveHashValue(hash)); + const resolvedHash = resolveHashValue(hash); + assertAllowedRoutingMode(resolvedHash); + router = getRouterContext(resolvedHash); if (!router) { throw new Error( 'There is no router to trace. Make sure a Router component is an ancestor of this RouterTrace component instance, or provide a router using the "router" property.' diff --git a/src/lib/core/RouterEngine.svelte.test.ts b/src/lib/core/RouterEngine.svelte.test.ts index 48b75da..e6a3947 100644 --- a/src/lib/core/RouterEngine.svelte.test.ts +++ b/src/lib/core/RouterEngine.svelte.test.ts @@ -3,8 +3,9 @@ import { routePatternsKey, RouterEngine } from "./RouterEngine.svelte.js"; import { init } from "../init.js"; import { registerRouter } from "./trace.svelte.js"; import { location } from "./Location.js"; -import type { State, RouteInfo } from "../types.js"; +import type { State, RouteInfo, ExtendedRoutingOptions } from "../types.js"; import { setupBrowserMocks, addRoutes, ROUTING_UNIVERSES, ALL_HASHES } from "../../testing/test-utils.js"; +import { resetRoutingOptions, setRoutingOptions } from "./options.js"; describe("RouterEngine", () => { describe('constructor', () => { @@ -112,6 +113,78 @@ describe("RouterEngine", () => { }).not.toThrow(); }); }); + + describe('Routing Mode Assertions', () => { + let cleanupFn: (() => void) | null = null; + + afterEach(() => { + resetRoutingOptions(); + cleanupFn?.(); + cleanupFn = null; + }); + + test.each<{ + options: Partial; + hash: typeof ALL_HASHES[keyof typeof ALL_HASHES]; + description: string; + }>([ + { + options: { disallowHashRouting: true }, + hash: ALL_HASHES.single, + description: 'hash routing is disallowed' + }, + { + options: { disallowMultiHashRouting: true }, + hash: ALL_HASHES.multi, + description: 'multi-hash routing is disallowed' + }, + { + options: { disallowPathRouting: true }, + hash: ALL_HASHES.path, + description: 'path routing is disallowed' + } + ])("Should throw error when $description and hash=$hash in constructor.", ({ options, hash }) => { + // Arrange + setRoutingOptions(options); + cleanupFn = init(); + + // Act & Assert + expect(() => { + new RouterEngine({ hash }); + }).toThrow(); + }); + + test("Should not throw error when all routing modes are allowed in constructor.", () => { + // Arrange + cleanupFn = init(); + + // Act & Assert + expect(() => { + new RouterEngine({ hash: ALL_HASHES.path }); + }).not.toThrow(); + expect(() => { + new RouterEngine({ hash: ALL_HASHES.single }); + }).not.toThrow(); + + cleanupFn(); + cleanupFn = init({ hashMode: 'multi' }); + expect(() => { + new RouterEngine({ hash: ALL_HASHES.multi }); + }).not.toThrow(); + }); + + test("Should throw error when parent router violates restrictions.", () => { + // Arrange + setRoutingOptions({ disallowHashRouting: true }); + cleanupFn = init(); + + // Act & Assert - Parent router creation should fail + expect(() => { + const parent = new RouterEngine({ hash: ALL_HASHES.single }); + new RouterEngine(parent); // Child inherits from parent + }).toThrow(); + }); + }); }); // ======================================== diff --git a/src/lib/core/RouterEngine.svelte.ts b/src/lib/core/RouterEngine.svelte.ts index 7ea7002..79b174b 100644 --- a/src/lib/core/RouterEngine.svelte.ts +++ b/src/lib/core/RouterEngine.svelte.ts @@ -3,6 +3,7 @@ import { traceOptions, registerRouter, unregisterRouter } from "./trace.svelte.j import { location } from "./Location.js"; import { routingOptions } from "./options.js"; import { resolveHashValue } from "./resolveHashValue.js"; +import { assertAllowedRoutingMode } from "$lib/utils.js"; /** * RouterEngine's options. @@ -232,6 +233,7 @@ export class RouterEngine { this.#resolvedHash : (this.#resolvedHash ? 'single' : undefined); } + assertAllowedRoutingMode(this.#resolvedHash); if (traceOptions.routerHierarchy) { registerRouter(this); this.#cleanup = true; diff --git a/src/lib/core/initCore.ts b/src/lib/core/initCore.ts index 92116fa..8d78dfe 100644 --- a/src/lib/core/initCore.ts +++ b/src/lib/core/initCore.ts @@ -1,4 +1,4 @@ -import type { InitOptions, Location } from "../types.js"; +import type { ExtendedInitOptions, Location } from "../types.js"; import { setLocation } from "./Location.js"; import { resetLogger, setLogger } from "./Logger.js"; import { resetRoutingOptions, setRoutingOptions } from "./options.js"; @@ -15,7 +15,7 @@ import { resetTraceOptions, setTraceOptions } from "./trace.svelte.js"; * @param location The Location implementation to use * @returns A cleanup function that reverts the initialization process */ -export function initCore(location: Location, options?: InitOptions) { +export function initCore(location: Location, options?: ExtendedInitOptions) { if (!location) { throw new Error("A valid location object must be provided to initialize the routing library."); } diff --git a/src/lib/core/options.test.ts b/src/lib/core/options.test.ts index 74656f1..11dd083 100644 --- a/src/lib/core/options.test.ts +++ b/src/lib/core/options.test.ts @@ -2,11 +2,6 @@ import { describe, expect, test } from "vitest"; import { resetRoutingOptions, routingOptions } from "./options.js"; describe("options", () => { - test("The routing options' initial values are the expected ones.", () => { - // Assert. - expect(routingOptions).toEqual({ hashMode: 'single', implicitMode: 'path' }); - }); - test("Should have correct default value for hashMode option.", () => { expect(routingOptions.hashMode).toBe('single'); }); @@ -15,6 +10,18 @@ describe("options", () => { expect(routingOptions.implicitMode).toBe('path'); }); + test("Should have correct default value for disallowPathRouting option.", () => { + expect(routingOptions.disallowPathRouting).toBe(false); + }); + + test("Should have correct default value for disallowHashRouting option.", () => { + expect(routingOptions.disallowHashRouting).toBe(false); + }); + + test("Should have correct default value for disallowMultiHashRouting option.", () => { + expect(routingOptions.disallowMultiHashRouting).toBe(false); + }); + test("Should allow modification of hashMode option.", () => { const originalValue = routingOptions.hashMode; routingOptions.hashMode = 'multi'; @@ -46,6 +53,9 @@ describe("options", () => { const original = structuredClone(routingOptions); routingOptions.hashMode = 'multi'; routingOptions.implicitMode = 'hash'; + routingOptions.disallowPathRouting = true; + routingOptions.disallowHashRouting = true; + routingOptions.disallowMultiHashRouting = true; // Act. resetRoutingOptions(); diff --git a/src/lib/core/options.ts b/src/lib/core/options.ts index eea135b..c90fa89 100644 --- a/src/lib/core/options.ts +++ b/src/lib/core/options.ts @@ -1,17 +1,20 @@ -import type { RoutingOptions } from "../types.js"; +import type { ExtendedRoutingOptions } from "../types.js"; /** * Default routing options used for rollback. */ -export const defaultRoutingOptions: Required = { +export const defaultRoutingOptions: Required = { hashMode: 'single', implicitMode: 'path', + disallowPathRouting: false, + disallowHashRouting: false, + disallowMultiHashRouting: false, }; /** * Global routing options. */ -export const routingOptions: Required = structuredClone(defaultRoutingOptions); +export const routingOptions: Required = structuredClone(defaultRoutingOptions); /** * Sets routing options, merging with current values. @@ -19,9 +22,12 @@ export const routingOptions: Required = structuredClone(defaultR * * @param options Partial routing options to set */ -export function setRoutingOptions(options?: Partial): void { +export function setRoutingOptions(options?: Partial): void { routingOptions.hashMode = options?.hashMode ?? routingOptions.hashMode; routingOptions.implicitMode = options?.implicitMode ?? routingOptions.implicitMode; + routingOptions.disallowPathRouting = options?.disallowPathRouting ?? routingOptions.disallowPathRouting; + routingOptions.disallowHashRouting = options?.disallowHashRouting ?? routingOptions.disallowHashRouting; + routingOptions.disallowMultiHashRouting = options?.disallowMultiHashRouting ?? routingOptions.disallowMultiHashRouting; } /** diff --git a/src/lib/types.ts b/src/lib/types.ts index baf0a95..a5aec4f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -401,6 +401,17 @@ export type RoutingOptions = { implicitMode?: 'hash' | 'path'; } +/** + * Internal representation of routing options, including additional flags used internally by the library. + * + * _Meaningful only for library extension packages that need additional control over routing options._ + */ +export type ExtendedRoutingOptions = RoutingOptions & { + disallowPathRouting?: boolean; + disallowHashRouting?: boolean; + disallowMultiHashRouting?: boolean; +}; + /** * Library's tracing options. */ @@ -434,6 +445,13 @@ export type InitOptions = RoutingOptions & { logger?: boolean | ILogger; } +/** + * Extended initialization options that include all routing options. + * + * _Meaningful only for library extension packages that need additional control over routing options._ + */ +export type ExtendedInitOptions = ExtendedRoutingOptions & Pick; + /** * Defines an abstraction over the browser's History API that provides consistent navigation * and state management across different environments (browser, SvelteKit, memory-only, etc.). diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 0000000..4ac3c48 --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { assertAllowedRoutingMode } from "./utils.js"; +import { ALL_HASHES } from "../testing/test-utils.js"; +import { resetRoutingOptions, setRoutingOptions } from "./core/options.js"; +import type { ExtendedRoutingOptions, Hash } from "./types.js"; + +const hashValues = Object.values(ALL_HASHES).filter(x => x !== undefined); + +describe("assertAllowedRoutingMode", () => { + afterEach(() => { + resetRoutingOptions(); + }); + + test.each(hashValues) + ("Should not throw when all routing modes are allowed (hash=%s).", (hash) => { + expect(() => assertAllowedRoutingMode(hash)).not.toThrow(); + }); + + test.each<{ + options: Partial; + hash: Hash; + }>([ + { + options: { + disallowHashRouting: true, + }, + hash: ALL_HASHES.single, + }, + { + options: { + disallowMultiHashRouting: true, + }, + hash: ALL_HASHES.multi, + }, + { + options: { + disallowPathRouting: true, + }, + hash: ALL_HASHES.path, + }, + ])("Should throw when the specified routing mode is disallowed (hash=$hash).", ({ options, hash }) => { + setRoutingOptions(options); + expect(() => assertAllowedRoutingMode(hash)).toThrow(); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..d65b6d1 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,20 @@ +import type { Hash } from "./types.js"; +import { routingOptions } from "./core/options.js"; + +/** + * Asserts that the specified routing mode is allowed by the current routing options. + * + * @param hash The routing mode to assert. + * @throws If the specified routing mode is disallowed by the current routing options. + */ +export function assertAllowedRoutingMode(hash: Hash) { + if (hash === false && routingOptions.disallowPathRouting) { + throw new Error("Path routing has been disallowed by a library extension."); + } + if (hash === true && routingOptions.disallowHashRouting) { + throw new Error("Hash routing has been disallowed by a library extension."); + } + if (typeof hash === 'string' && routingOptions.disallowMultiHashRouting) { + throw new Error("Multi-hash routing has been disallowed by a library extension."); + } +}