The @svelte-router/core routing library supports simultaneous path and hash routing through "routing universes":
- Path Routing (
hash: false): Uses URL pathname - Single Hash Routing (
hash: true): Uses URL hash as a single path (e.g.,#/path/to/route) - Multi Hash Routing (
hash: 'p1'): Uses semicolon-separated hash segments (e.g.,#p1=/path;p2=/other) - Implicit Path Routing (
hash: undefined,defaultHash: false): Resolves to path routing - Implicit Hash Routing (
hash: undefined,defaultHash: true): Resolves to hash routing - Implicit Named Hash Routing (
hash: undefined,defaultHash: <a string>): Resolves to named (multi) hash routing
<Router>
<Route path="/hash-feature/*">
<Router hash>
<Route hash path="/">
<RouteHashHome />
</Route>
<Route path="/non-hash-route">
<NonHashContentForSomeReason />
</Route>
</Router>
</Route>
</Router>Context is stored per universe using Svelte's setContext() with keys from getRouterContextKey():
- Path routing:
parentCtxKeysymbol - Single hash routing:
hashParentCtxKeysymbol - Multi hash routing:
Symbol.for('hsh-${hashId}')per hash ID
The RouterEngine class is the heart of the routing system:
routes: Record<string, RouteInfo>;Where RouteInfo contains:
pattern?: stringorregex?: RegExp: For URL matchingand?: (routeParams) => boolean: Additional predicate for additional constraining, like guarded routesignoreForFallback?: boolean: Excludes route from fallback calculations
routeStatus: Per-route match status and extracted parametersfallback: Boolean indicating NO routes matched (excludingignoreForFallbackroutes)
Except for the LinkContext components, all components possess the hash: Hash property to specify the routing
universe they belong to.
- Creates
RouterEngineinstance - Sets up context for child components
- Provides
stateandrouteStatusto children
- Registers route patterns with parent router
- Uses context to find parent router
- Props:
path(string pattern/regex) andand(predicate function)
- Shows content when no routes match
- Props:
when?: WhenPredicate: Override defaultfallbackbehaviorchildren: Content snippet
Render logic:
{#if (router && when?.(router.routeStatus, router.fallback)) || (!when && router?.fallback)}
{@render children?.(router.state, router.routeStatus)}
{/if}The init() function:
- Passes library configuration to global singleton
- Creates new Location implementation instance
- Returns a cleanup function; mainly used in unit testing but valid anywhere as required.
- Required when library configurations change
- Multi hash routing needs cleanup between tests
// Standard init
const cleanup = init();
// Multi hash mode
const cleanup = init({ hashMode: 'multi' });
// Always cleanup
afterAll(() => {
cleanup();
});This library supports the creation of extension NPM packages by allowing custom implementations of the Location, HistoryApi and FullModeHistoryApi interfaces.
Location implementations are in charge of obtaining the current environment URL and keeping it in sync across navigation. They read the environment's URL and sets up reactive $state and $derived data for the rest of components and the application that consumes the package.
HistoryApi implementations are helpers for the Location implementations. They either tap into or completely replace the environment's history API to fulfill the sought objective.
NPM extension packages may opt to provide custom implementations for any of these interfaces. They may (and are encouraged to) expose their own initialization functions to provide customized or new options, and to remove stock initialization options that should not be touched by consumers.
Custom initialization functions must ultimately call this package's initCore() function with the desired values; NPM extension packages can provide any number of initialization functions.
- Default library option
- By default, uses the
StockHistoryApiclass as itsHistoryApiimplementation - Base class for
LocationFull
- By default, uses the
InterceptedHistoryApiclass as itsFullModeHistoryApiimplementation
- Relays all functionality to the environment's history object
- Synchronizes reactive values as needed while being used
- Extends the
StockHistoryApiclass - Replaces the environment's history object with itself on construction
- Provides the logic for the
beforeNavigateandnavigationCancelledevents
Test component behavior with minimal/default property values:
function defaultPropsTests(setup: ReturnType<typeof createRouterTestSetup>) {
beforeEach(() => setup.init());
afterAll(() => setup.dispose());
test('Should behave correctly with default props.', () => {
// Test with hash: undefined and minimal props
const { hash, context } = setup;
render(Component, { props: { hash, children: content }, context });
// Assert default behavior
});
}Test specific property configurations and overrides:
function explicitPropsTests(setup: ReturnType<typeof createRouterTestSetup>) {
test.each([
{ propValue: value1, scenario: 'scenario1' },
{ propValue: value2, scenario: 'scenario2' }
])('Should handle $scenario .', ({ propValue }) => {
render(Component, {
props: { hash, specificProp: propValue, children: content },
context
});
// Assert specific behavior
});
}Test two distinct types of reactivity:
A. Prop Value Changes (Component prop reactivity):
test('Should re-render when prop value changes.', async () => {
const { rerender } = render(Component, {
props: { when: () => false, children: content }
});
// Change the entire prop value
await rerender({ when: () => true, children: content });
// Assert new behavior
});B. Reactive State Changes (Svelte rune reactivity):
test('Should re-render when reactive dependency changes.', async () => {
let reactiveState = $state(false);
render(Component, {
props: { when: () => reactiveState, children: content } // Same function, reactive dependency
});
// Change the reactive state
reactiveState = true;
flushSync(); // Only use if the assertion depends on Svelte $effect's having run to completion
// Assert reactive behavior
});// ✅ Good - Test what the user sees
test('Should hide content when routes match.', () => {
addMatchingRoute(router);
const { queryByText } = render(Component, { props, context });
expect(queryByText('content')).toBeNull();
});
// ❌ Bad - Test internal implementation
test('Should call router.fallback.', () => {
const spy = vi.spyOn(router, 'fallback');
render(Component, { props, context });
expect(spy).toHaveBeenCalled(); // Testing implementation detail
});// ✅ Component tests focus on component behavior
test('Component renders when condition is met.', () => {
// Setup: Create the condition (however that's achieved)
// Test: Component responds correctly
});
// ✅ Router tests focus on router logic (separate file)
test('Router calculates fallback correctly.', () => {
// Test router's internal logic
});// ✅ Fresh instance for each test
beforeEach(() => {
setup.init(); // Creates new router
});
// ❌ Reusing router instances
beforeAll(() => {
setup.init(); // Same router for all tests - bad!
});Use data-driven testing across all 5 routing universes:
// Import the complete universe definitions
import { ROUTING_UNIVERSES } from '$test/test-utils.js';
ROUTING_UNIVERSES.forEach((ru) => {
describe(`Component - ${ru.text}`, () => {
let cleanup: () => void;
beforeAll(() => {
cleanup = init({
defaultHash: ru.defaultHash,
hashMode: ru.hashMode
});
});
afterAll(() => {
cleanup();
});
describe('Default Props', () => {
// Component behavior with minimal/default props
});
describe('Explicit Props', () => {
// Testing specific prop configurations
});
describe('Reactivity', () => {
// Testing dynamic changes and reactive behavior
});
});
});See src/testing/test-utils.ts for the complete ROUTING_UNIVERSES array definition with all universe configurations.
// Full source in src/testing/test-utils.ts
function createRouterTestSetup(hash: Hash | undefined): {
readonly hash: Hash | undefined;
readonly router: RouterEngine;
readonly context: Map<any, any>;
init: () => void;
dispose: () => void;
};
// Usage in tests
beforeEach(() => {
setup.init(); // Fresh router for each test
});
afterAll(() => {
setup.dispose(); // Clean disposal
});// Add a single matching route
addMatchingRoute(router, 'optionalRouteName');
// Add a single non-matching route
addNonMatchingRoute(router, 'optionalRouteName');
// Add multiple routes at once
addRoutes(router, {
matching: 2, // Adds 2 matching routes
nonMatching: 1 // Adds 1 non-matching route
});
// Add explicit custom routes using rest parameters
addRoutes(
router,
{ matching: 1 },
{ pattern: '/api/:id', name: 'api-route' },
{ regex: /^\/test$/ } // Auto-generated name
);
// Manual route addition
router.routes['routeName'] = {
pattern: '/some/path',
and: () => true,
ignoreForFallback: false
};- Use
queryByTextfor negative assertions (element should NOT exist) - Use
findByTextfor positive assertions (element should exist) - Avoid
expect().rejectswithfindByText- it waits 1000ms timeout
// ❌ Slow - waits 1000ms timeout
await expect(findByText(content)).rejects.toThrow();
// ✅ Fast - immediate result
expect(queryByText(content)).toBeNull();Bindable properties (declared with $bindable() and used with bind: in Svelte) require special testing patterns since they involve two-way data binding between parent and child components.
The most effective way to test bindable properties is using getter/setter functions in the render() props:
test('Should bind property correctly', async () => {
// Arrange: Set up binding capture
let capturedValue: any;
const propertySetter = vi.fn((value) => {
capturedValue = value;
});
// Act: Render component with bindable property
render(Component, {
props: {
// Other props...
get bindableProperty() {
return capturedValue;
},
set bindableProperty(value) {
propertySetter(value);
}
},
context
});
// Trigger the binding (component-specific logic)
// e.g., navigate to a route, trigger an event, etc.
// Might require the use of Svelte's flushSync() function if the update depends on effects running.
await triggerBindingUpdate();
// Assert: Verify binding occurred
expect(propertySetter).toHaveBeenCalled();
expect(capturedValue).toEqual(expectedValue);
});For components that work across multiple routing universes, test bindable properties for each mode:
function bindablePropertyTests(
setup: ReturnType<typeof createRouterTestSetup>,
ru: RoutingUniverse
) {
test('Should bind property when condition is met.', async () => {
const { hash, context } = setup;
let capturedValue: any;
const propertySetter = vi.fn((value) => {
capturedValue = value;
});
render(Component, {
props: {
hash,
get boundProperty() {
return capturedValue;
},
set boundProperty(value) {
propertySetter(value);
}
// Other component-specific props
},
context
});
// Trigger binding based on routing mode
const shouldUseHash = ru.defaultHash === true || hash === true || typeof hash === 'string';
const url = shouldUseHash ? 'http://example.com/#/test' : 'http://example.com/test';
location.url.href = url;
await vi.waitFor(() => {});
expect(propertySetter).toHaveBeenCalled();
expect(capturedValue).toEqual(expectedValue);
});
test('Should update binding when conditions change.', async () => {
// Test binding updates during navigation or state changes
let capturedValue: any;
const propertySetter = vi.fn((value) => {
capturedValue = value;
});
render(Component, {
props: {
hash,
get boundProperty() {
return capturedValue;
},
set boundProperty(value) {
propertySetter(value);
}
},
context
});
// First state
await triggerFirstState();
expect(capturedValue).toEqual(firstExpectedValue);
// Second state
await triggerSecondState();
expect(capturedValue).toEqual(secondExpectedValue);
});
}
// Apply to all routing universes
ROUTING_UNIVERSES.forEach((ru) => {
describe(`Component - ${ru.text}`, () => {
const setup = createRouterTestSetup(ru.hash);
// ... setup code ...
describe('Bindable Properties', () => {
bindablePropertyTests(setup, ru);
});
});
});Some routing modes may have different behavior or limitations. Handle these gracefully:
test('Should handle complex binding scenarios.', async () => {
let capturedValue: any;
const propertySetter = vi.fn((value) => {
capturedValue = value;
});
render(Component, {
props: {
get boundProperty() {
return capturedValue;
},
set boundProperty(value) {
propertySetter(value);
}
},
context
});
await triggerBinding();
expect(propertySetter).toHaveBeenCalled();
// Handle mode-specific edge cases
if (ru.text === 'MHR') {
// Multi Hash Routing may require different URL format or setup
// Skip complex assertions that aren't supported yet
return;
}
expect(capturedValue).toEqual(expectedComplexValue);
});When testing components that perform automatic type conversion (like RouterEngine), account for expected type changes:
test('Should bind with correct type conversion.', async () => {
let capturedParams: any;
const paramsSetter = vi.fn((value) => {
capturedParams = value;
});
render(RouteComponent, {
props: {
path: '/user/:userId/post/:postId',
get params() {
return capturedParams;
},
set params(value) {
paramsSetter(value);
}
},
context
});
// Navigate to URL with string parameters
location.url.href = 'http://example.com/user/123/post/456';
await vi.waitFor(() => {});
expect(paramsSetter).toHaveBeenCalled();
// Expect automatic string-to-number conversion
expect(capturedParams).toEqual({
userId: 123, // number, not "123"
postId: 456 // number, not "456"
});
});❌ Don't use wrapper components for simple binding tests:
// Bad - overcomplicated
const WrapperComponent = () => {
let boundValue = $state();
return `<Component bind:property={boundValue} />`;
};❌ Don't test binding implementation details:
// Bad - testing internal mechanics
expect(component.$$.callbacks.boundProperty).toHaveBeenCalled();❌ Don't forget routing mode compatibility:
// Bad - hardcoded to one routing mode
location.url.href = 'http://example.com/#/test'; // Only works for hash routing✅ Use the getter/setter pattern for clean, direct testing:
// Good - direct, simple, effective
render(Component, {
props: {
get boundProperty() {
return capturedValue;
},
set boundProperty(value) {
propertySetter(value);
}
}
});test('Should bind route parameters correctly.', async () => {
// Arrange
const { hash, context } = setup;
let capturedParams: any;
const paramsSetter = vi.fn((value) => {
capturedParams = value;
});
// Act
render(TestRouteWithRouter, {
props: {
hash,
routePath: '/user/:userId',
get params() {
return capturedParams;
},
set params(value) {
paramsSetter(value);
},
children: createTestSnippet('<div>User {params?.userId}</div>')
},
context
});
// Navigate to matching route
const shouldUseHash = ru.defaultHash === true || hash === true || typeof hash === 'string';
location.url.href = shouldUseHash
? 'http://example.com/#/user/42'
: 'http://example.com/user/42';
await vi.waitFor(() => {});
// Assert
expect(paramsSetter).toHaveBeenCalled();
expect(capturedParams).toEqual({ userId: 42 }); // Note: number due to auto-conversion
});This pattern provides:
- Clear test intent: What binding behavior is being tested
- Routing mode compatibility: Works across all universe types
- Type safety: Captures actual bound values for verification
- Maintainability: Simple, readable test structure
import { init, type Hash } from '$lib/index.js';
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { render } from '@testing-library/svelte';
import { RouterEngine } from '$lib/kernel/RouterEngine.svelte.js';
import { getRouterContextKey } from '../Router/Router.svelte';
import { createRawSnippet } from 'svelte';
import { flushSync } from 'svelte'; // For Svelte 5 reactivity testing
import {
addMatchingRoute,
addRoutes,
createRouterTestSetup,
createTestSnippet,
ROUTING_UNIVERSES
} from '../testing/test-utils.js'; // Note: moved outside $lib// Using test utility (recommended)
const content = createTestSnippet('Component content text');
// Manual creation
const contentText = 'Component content.';
const content = createRawSnippet(() => {
return {
render: () => `<div>${contentText}</div>`
};
});- Description should be a full English sentence
- Must start capitalized
- Must end with a period
- In data-driven tests (
test.each()()), ensure the sentence generated is unique among all test cases by interpolating test case data in the sentence - Feel free to create description-only test case properties in test cases to help build a good description sentence:
test.each([ { text1: 'throw', text2: 'no' ... }, { text1: 'not throw', text2: 'one or more' } ])("Should $text1 whenever the array has $text2 items.", ...);
- When the test case is a POJO object and a property is used to build a good description sentence:
// Doesn't work: The ending period cannot "touch" the placeholder identifier or vitest will be confused. test.each([...])("Should ... $text.", ...); // Workaround: Add a space before the sentence's ending period. test.each([...])("Should ... $text .", ...);
Important: For tests that use Svelte 5 runes ($state, $derived, etc.), name your test files with .svelte.test.ts:
✅ Component.svelte.test.ts // Enables Svelte runes
❌ Component.test.ts // Standard test file
This allows you to use reactive state in tests:
test('Should react to state changes.', () => {
let reactiveValue = $state(false);
render(Component, {
props: { when: () => reactiveValue }
});
reactiveValue = true;
flushSync(); // Ensure reactive updates are processed
});Purpose: Render content when no routes match, with override capability
Key behaviors to test:
- Shows when
router.fallbackis true - Hides when routes are matching
- Respects
whenpredicate override - Works across all routing universes
Common test scenarios:
- Empty router (no routes) → should show
- Router with matching routes → should hide
- Router with only
ignoreForFallbackroutes → should show - Custom
whenpredicate scenarios
Purpose: Register route patterns and respond to matches
Key behaviors to test:
- Route registration with parent router
- Pattern matching (string patterns, regex, parameters)
andpredicate evaluationignoreForFallbackbehavior
Purpose: Create routing context and manage child routes
Key behaviors to test:
- Context creation and propagation
- Base path handling
- Route status calculation
- State management per universe
- Context Keys: Must use correct
getRouterContextKey(hash)for each universe - Cleanup: Multi hash routing requires proper
init()cleanup between test suites - Route Clearing: Always clear
router.routesbetween tests for clean slate - Async Testing: Use
queryByTextfor immediate results vsfindByTextfor async waiting - Hash Values: Ensure hash values match between component props and context setup
- File Naming: Use
.svelte.test.tsfor files that need Svelte runes support - Reactivity: If a test depends on waiting for a Svelte effect to run, use
flushSync()after changing reactive state - Prop vs State Reactivity: Test both prop changes AND reactive dependency changes
- Bindable Properties: Use getter/setter pattern in
render()props instead of wrapper components for testing two-way binding
For testing components that rely on window.location and window.history (like RouterEngine), use the comprehensive browser mocking utilities:
import { setupBrowserMocks } from '$test/test-utils.js';
describe('Component requiring browser APIs', () => {
beforeEach(() => {
// Automatically mocks window.location, window.history, and integrates with library Location
setupBrowserMocks('/initial/path');
});
test('Should respond to location changes.', () => {
// Browser APIs are now mocked and integrated with library
window.history.pushState({}, '', '/new/path');
// Test component behavior
});
});What setupBrowserMocks() provides:
- Complete
window.locationmock with all properties (href, pathname, hash, search, etc.) - Full
window.historymock withpushState,replaceState, and state management - Automatic
popstateevent triggering on location changes - Integration with library's
LocationLitefor synchronized state - Proper cleanup between tests
The addRoutes() utility supports multiple approaches for flexible route setup:
// Simple route counts
addRoutes(router, { matching: 2, nonMatching: 1 });
// RouteSpecs approach for custom route definitions
addRoutes(router, {
matching: { count: 2, specs: { pattern: '/custom/:id' } },
nonMatching: { count: 1, specs: { pattern: '/other' } }
});
// Rest parameters for explicit route definitions (NEW)
addRoutes(
router,
{ matching: 1, nonMatching: 0 },
{ pattern: '/api/users/:id', name: 'user-detail' },
{ regex: /^\/products\/\d+$/, name: 'product' },
{ pattern: '/settings' } // Name auto-generated if not provided
);
// Combined approach
addRoutes(
router,
{ matching: 2 }, // Generate 2 matching routes
{ pattern: '/custom', name: 'custom-route' }, // Add specific route
{ pattern: '/another' } // Add another with auto-generated name
);Rest Parameters Benefits:
- Explicit control: Define exact routes with specific patterns/regex
- Named routes: Optional
nameproperty for predictable route keys - Type safety: Full IntelliSense support for
RouteInfoproperties - Flexible mixing: Combine generated routes with explicit definitions
Refer to src/testing/test-utils.ts for complete function signatures and type definitions.
Complete test coverage across all 5 routing universes using the standardized pattern:
import { ROUTING_UNIVERSES } from '$test/test-utils.js';
// ✅ Recommended: Test ALL universes with single loop
ROUTING_UNIVERSES.forEach((universe) => {
describe(`Component (${universe.text})`, () => {
let cleanup: () => void;
let setup: ReturnType<typeof createRouterTestSetup>;
beforeAll(() => {
cleanup = init({
defaultHash: universe.defaultHash,
hashMode: universe.hashMode
});
setup = createRouterTestSetup(universe.hash);
});
afterAll(() => {
cleanup();
setup.dispose();
});
beforeEach(() => {
setup.init(); // Fresh router per test
setupBrowserMocks('/'); // Fresh browser state
});
test(`Should behave correctly in ${universe.text}`, () => {
// Test logic that works across all universes
const { hash, context, router } = setup;
// Use universe.text for concise test descriptions
expect(universe.text).toMatch(/^(IMP|IMH|PR|HR|MHR)$/);
});
});
});Benefits:
- 100% Universe Coverage: Ensures behavior works across all routing modes
- Consistent Test Structure: Standardized setup and teardown patterns
- Efficient Execution: Vitest's dynamic skipping capabilities maintain performance
- Clear Reporting: Each universe shows as separate test suite with meaningful names
Use dictionary-based constants for better maintainability:
// Import self-documenting hash values
import { ALL_HASHES } from '$test/test-utils.js';
// Usage in tests
test('Should validate hash compatibility.', () => {
expect(() => {
new RouterEngine({ hash: ALL_HASHES.single });
}).not.toThrow();
});See src/testing/test-utils.ts for the complete ALL_HASHES dictionary definition.
Dictionary Benefits:
- Self-Documentation:
ALL_HASHES.singleis clearer thantrue - Single Source of Truth: Change values in one place
- Type Safety: TypeScript can validate usage
- Discoverability: IDE autocomplete shows available options
For components with runtime validation, test all error scenarios systematically:
describe('Constructor hash validation', () => {
test.each([
{ parent: ALL_HASHES.path, child: ALL_HASHES.single, desc: 'path parent vs hash child' },
{ parent: ALL_HASHES.single, child: ALL_HASHES.path, desc: 'hash parent vs path child' },
{
parent: ALL_HASHES.multi,
child: ALL_HASHES.path,
desc: 'multi-hash parent vs path child'
},
{
parent: ALL_HASHES.path,
child: ALL_HASHES.multi,
desc: 'path parent vs multi-hash child'
}
])(
'Should throw error when parent and child have different hash modes: $desc .',
({ parent, child }) => {
expect(() => {
const parentRouter = new RouterEngine({ hash: parent });
new RouterEngine(parentRouter, { hash: child });
}).toThrow('Parent and child routers must use the same hash mode');
}
);
test.each([
{ parent: ALL_HASHES.path, desc: 'path parent' },
{ parent: ALL_HASHES.single, desc: 'hash parent' },
{ parent: ALL_HASHES.multi, desc: 'multi-hash parent' }
])(
"Should allow child router without explicit hash to inherit parent's hash: $desc .",
({ parent }) => {
expect(() => {
const parentRouter = new RouterEngine({ hash: parent });
new RouterEngine(parentRouter);
}).not.toThrow();
}
);
});Browser Mock State Synchronization:
// ✅ Automatic state sync - setupBrowserMocks handles this
// Best practice: pass the library's location object for full integration
setupBrowserMocks('/initial', libraryLocationObject);
window.history.pushState({}, '', '/new'); // Automatically triggers popstate
// ❌ Manual sync required (old approach)
mockLocation.pathname = '/new';
window.dispatchEvent(new PopStateEvent('popstate')); // Manual event triggerEfficient Test Assertions:
// ✅ Fast negative assertions
expect(queryByText('should not exist')).toBeNull();
// ❌ Slow - waits for timeout
await expect(findByText('should not exist')).rejects.toThrow();
// ✅ Use findByText for elements that should exist
const element = await findByText('should exist');
expect(element).toBeInTheDocument();Test utilities are centralized in src/testing/test-utils.ts (moved from src/lib/testing/ for better organization) and excluded from the published package via the "files" property in package.json. During development, they build to dist/testing/ but are not included in npm pack.
Key utilities:
setupBrowserMocks(): Complete browser API mocking with library integrationaddRoutes(): Enhanced route management with RouteSpecs supportcreateRouterTestSetup(): Standardized router setup with proper lifecycleROUTING_UNIVERSES: Complete universe definitions for comprehensive testingALL_HASHES: Self-documenting hash value constants