diff --git a/docs/testing-guide.md b/AGENTS.md similarity index 83% rename from docs/testing-guide.md rename to AGENTS.md index 3eff92e..4b0ec4b 100644 --- a/docs/testing-guide.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# N-Savant Routing Library - Testing Guide +# N-Savant Routing Library ## Library Architecture Overview @@ -46,7 +46,7 @@ routes: Record Where `RouteInfo` contains: - `pattern?: string` or `regex?: RegExp`: For URL matching -- `and?: (routeParams) => boolean`: Additional predicate for guarded routes +- `and?: (routeParams) => boolean`: Additional predicate for additional constraining, like guarded routes - `ignoreForFallback?: boolean`: Excludes route from fallback calculations #### Reactive Properties @@ -55,6 +55,9 @@ Where `RouteInfo` contains: ### Component Architecture +Except for the `LinkContext` components, all components possess the `hash: Hash` property to specify the routing +universe they belong to. + #### Router Component - Creates `RouterEngine` instance - Sets up context for child components @@ -68,7 +71,6 @@ Where `RouteInfo` contains: #### Fallback Component - Shows content when no routes match - Props: - - `hash`: Routing universe selector - `when?: WhenPredicate`: Override default `noMatches` behavior - `children`: Content snippet @@ -79,6 +81,65 @@ Render logic: {/if} ``` +## Library Initialization + +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 + +```typescript +// Standard init +const cleanup = init(); + +// Multi hash mode +const cleanup = init({ hashMode: 'multi' }); + +// Always cleanup +afterAll(() => { + cleanup(); +}); +``` + +## Extensibility + +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. + +### Location Implementations + +#### LocationLite + +- Default library option +- By default, uses the `StockHistoryApi` class as its `HistoryApi` implementation +- Base class for `LocationFull` + +#### LocationFull + +- By default, uses the `InterceptedHistoryApi` class as its `FullModeHistoryApi` implementation + +### HistoryApi and FullModeHistoryApi Implementations + +#### StockHistoryApi + +- Relays all functionality to the environment's history object +- Synchronizes reactive values as needed while being used + +#### InterceptedHistoryApi + +- Extends the `StockHistoryApi` class +- Replaces the environment's history object with itself on construction +- Provides the logic for the `beforeNavigate` and `navigationCancelled` events + ## Testing Patterns & Best Practices ### Test Structure Categories @@ -90,7 +151,7 @@ function defaultPropsTests(setup: ReturnType) { beforeEach(() => setup.init()); afterAll(() => setup.dispose()); - test("Should behave correctly with default props", () => { + 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 }); @@ -106,7 +167,7 @@ function explicitPropsTests(setup: ReturnType) { test.each([ { propValue: value1, scenario: "scenario1" }, { propValue: value2, scenario: "scenario2" } - ])("Should handle $scenario", ({ propValue }) => { + ])("Should handle $scenario .", ({ propValue }) => { render(Component, { props: { hash, specificProp: propValue, children: content }, context @@ -121,7 +182,7 @@ Test two distinct types of reactivity: **A. Prop Value Changes** (Component prop reactivity): ```typescript -test("Should re-render when prop value changes", async () => { +test("Should re-render when prop value changes.", async () => { const { rerender } = render(Component, { props: { when: () => false, children: content } }); @@ -135,7 +196,7 @@ test("Should re-render when prop value changes", async () => { **B. Reactive State Changes** (Svelte rune reactivity): ```typescript -test("Should re-render when reactive dependency changes", async () => { +test("Should re-render when reactive dependency changes.", async () => { let reactiveState = $state(false); render(Component, { @@ -144,7 +205,7 @@ test("Should re-render when reactive dependency changes", async () => { // Change the reactive state reactiveState = true; - flushSync(); // Ensure reactive updates are processed + flushSync(); // Only use if the assertion depends on Svelte $effect's having run to completion // Assert reactive behavior }); @@ -155,14 +216,14 @@ test("Should re-render when reactive dependency changes", async () => { #### **Focus on Observable Behavior, Not Implementation** ```typescript // ✅ Good - Test what the user sees -test("Should hide content when routes match", () => { +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.noMatches", () => { +test("Should call router.noMatches.", () => { const spy = vi.spyOn(router, 'noMatches'); render(Component, { props, context }); expect(spy).toHaveBeenCalled(); // Testing implementation detail @@ -172,13 +233,13 @@ test("Should call router.noMatches", () => { #### **Maintain Clear Testing Boundaries** ```typescript // ✅ Component tests focus on component behavior -test("Component renders when condition is met", () => { +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 noMatches correctly", () => { +test("Router calculates noMatches correctly.", () => { // Test router's internal logic }); ``` @@ -233,40 +294,14 @@ See `src/testing/test-utils.ts` for the complete `ROUTING_UNIVERSES` array defin ### Context Setup ```typescript -function createRouterTestSetup(hash: Hash | undefined) { - let router: RouterEngine | undefined; - let context: Map; - - const init = () => { - // Dispose previous router if it exists - router?.dispose(); - - // Create fresh router and context for each test - router = new RouterEngine({ hash }); - context = new Map(); - context.set(getRouterContextKey(hash), router); - }; - - const dispose = () => { - router?.dispose(); - router = undefined; - context = new Map(); - }; - - return { - get hash() { return hash; }, - get router() { - if (!router) throw new Error('Router not initialized. Call init() first.'); - return router; - }, - get context() { - if (!context) throw new Error('Context not initialized. Call init() first.'); - return context; - }, - init, - dispose - }; -} +// Full source in src/testing/test-utils.ts +function createRouterTestSetup(hash: Hash | undefined): { + readonly hash: Hash | undefined; + readonly router: RouterEngine; + readonly context: Map; + init: () => void; + dispose: () => void; +}; // Usage in tests beforeEach(() => { @@ -324,7 +359,7 @@ expect(queryByText(content)).toBeNull(); ### Testing Bindable Properties -**Bindable properties** (declared with `export let` and used with `bind:` in Svelte) require special testing patterns since they involve two-way data binding between parent and child components. +**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 Getter/Setter Pattern (Recommended)** @@ -348,6 +383,7 @@ test("Should bind property correctly", async () => { // 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 @@ -362,7 +398,7 @@ For components that work across multiple routing universes, test bindable proper ```typescript function bindablePropertyTests(setup: ReturnType, ru: RoutingUniverse) { - test("Should bind property when condition is met", async () => { + test("Should bind property when condition is met.", async () => { const { hash, context } = setup; let capturedValue: any; const propertySetter = vi.fn((value) => { capturedValue = value; }); @@ -387,7 +423,7 @@ function bindablePropertyTests(setup: ReturnType, expect(capturedValue).toEqual(expectedValue); }); - test("Should update binding when conditions change", async () => { + 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; }); @@ -429,7 +465,7 @@ ROUTING_UNIVERSES.forEach((ru) => { Some routing modes may have different behavior or limitations. Handle these gracefully: ```typescript -test("Should handle complex binding scenarios", async () => { +test("Should handle complex binding scenarios.", async () => { let capturedValue: any; const propertySetter = vi.fn((value) => { capturedValue = value; }); @@ -461,7 +497,7 @@ test("Should handle complex binding scenarios", async () => { When testing components that perform automatic type conversion (like RouterEngine), account for expected type changes: ```typescript -test("Should bind with correct type conversion", async () => { +test("Should bind with correct type conversion.", async () => { let capturedParams: any; const paramsSetter = vi.fn((value) => { capturedParams = value; }); @@ -524,7 +560,7 @@ render(Component, { #### **Real-World Example: Route Parameter Binding** ```typescript -test("Should bind route parameters correctly", async () => { +test("Should bind route parameters correctly.", async () => { // Arrange const { hash, context } = setup; let capturedParams: any; @@ -593,6 +629,37 @@ const content = createRawSnippet(() => { }); ``` +### Test Description Conventions + +- 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: + ```typescript + test.each([ + { + text1: 'throw', + text2: 'no' + ... + }, + { + text1: 'not throw', + text2: 'one or more' + } + ])("Should $text1 whenever the array has $text2 items.", ...); + ``` + +### Gotcha's + +- When the test case is a POJO object and a property is used to build a good description sentence: + ```typescript + // 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 .", ...); + ``` + ## Test File Naming for Svelte 5 **Important**: For tests that use Svelte 5 runes (`$state`, `$derived`, etc.), name your test files with `.svelte.test.ts`: @@ -604,7 +671,7 @@ const content = createRawSnippet(() => { This allows you to use reactive state in tests: ```typescript -test("Should react to state changes", () => { +test("Should react to state changes.", () => { let reactiveValue = $state(false); render(Component, { @@ -616,27 +683,6 @@ test("Should react to state changes", () => { }); ``` -## Library Initialization - -The `init()` function: -- Passes library configuration to global singleton -- Creates new Location implementation instance -- Required when library configurations change -- Multi hash routing needs cleanup between tests - -```typescript -// Standard init -const cleanup = init(); - -// Multi hash mode -const cleanup = init({ hashMode: 'multi' }); - -// Always cleanup -afterAll(() => { - cleanup(); -}); -``` - ## Component-Specific Testing Notes ### Fallback Component @@ -683,7 +729,7 @@ afterAll(() => { 4. **Async Testing**: Use `queryByText` for immediate results vs `findByText` for async waiting 5. **Hash Values**: Ensure hash values match between component props and context setup 6. **File Naming**: Use `.svelte.test.ts` for files that need Svelte runes support -7. **Reactivity**: Remember to call `flushSync()` after changing reactive state +7. **Reactivity**: If a test depends on waiting for a Svelte effect to run, use `flushSync()` after changing reactive state 8. **Prop vs State Reactivity**: Test both prop changes AND reactive dependency changes 9. **Bindable Properties**: Use getter/setter pattern in `render()` props instead of wrapper components for testing two-way binding @@ -702,7 +748,7 @@ describe("Component requiring browser APIs", () => { setupBrowserMocks("/initial/path"); }); - test("Should respond to location changes", () => { + test("Should respond to location changes.", () => { // Browser APIs are now mocked and integrated with library window.history.pushState({}, "", "/new/path"); // Test component behavior @@ -812,7 +858,7 @@ Use dictionary-based constants for better maintainability: import { ALL_HASHES } from "../testing/test-utils.js"; // Usage in tests -test("Should validate hash compatibility", () => { +test("Should validate hash compatibility.", () => { expect(() => { new RouterEngine({ hash: ALL_HASHES.single }); }).not.toThrow(); @@ -838,7 +884,7 @@ describe("Constructor hash validation", () => { { 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 }) => { + ])("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 }); @@ -849,7 +895,7 @@ describe("Constructor hash validation", () => { { 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 }) => { + ])("Should allow child router without explicit hash to inherit parent's hash: $desc .", ({ parent }) => { expect(() => { const parentRouter = new RouterEngine({ hash: parent }); new RouterEngine(parentRouter); diff --git a/README.md b/README.md index 23c79d2..27fb3ee 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,10 @@ > **📝 Small and Unique!** > -> + Less than **1,250** lines of code, including TypeScript typing. +> + Less than **1,400** lines of code, including TypeScript typing. > + Always-on path and hash routing. Simultaneous and independent routing modes. > + The router that invented multi hash routing. +> + **NEW!** Supports extension packages (Sveltekit support coming soon) + **Electron support**: Works with Electron (all routing modes) + **Reactivity-based**: All data is reactive, reducing the need for events and imperative programming. diff --git a/src/lib/core/InterceptedHistoryApi.svelte.test.ts b/src/lib/core/InterceptedHistoryApi.svelte.test.ts new file mode 100644 index 0000000..e1ce7db --- /dev/null +++ b/src/lib/core/InterceptedHistoryApi.svelte.test.ts @@ -0,0 +1,425 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { InterceptedHistoryApi } from "./InterceptedHistoryApi.svelte.js"; +import type { State, BeforeNavigateEvent, NavigationCancelledEvent } from "$lib/types.js"; +import { setupBrowserMocks } from "../../testing/test-utils.js"; + +describe("InterceptedHistoryApi", () => { + const initialUrl = "http://example.com/"; + let historyApi: InterceptedHistoryApi; + let browserMocks: ReturnType; + + beforeEach(() => { + browserMocks = setupBrowserMocks(initialUrl); + historyApi = new InterceptedHistoryApi(); + }); + + afterEach(() => { + historyApi.dispose(); + browserMocks.cleanup(); + }); + + describe("constructor", () => { + test("Should create a new instance with the expected default values.", () => { + // Assert. + expect(historyApi.url.href).toBe(initialUrl); + }); + + test("Should replace window.history with itself.", () => { + // Assert. + expect(globalThis.window.history).toBe(historyApi); + }); + + test("Should use provided initial URL.", () => { + // Arrange. + const customUrl = "http://example.com/custom"; + + // Act. + const customHistoryApi = new InterceptedHistoryApi(customUrl); + + // Assert. + expect(customHistoryApi.url.href).toBe(customUrl); + + // Cleanup. + customHistoryApi.dispose(); + }); + + test("Should use provided initial state.", () => { + // Arrange. + const customState: State = { + path: { custom: "data" }, + hash: { single: { test: "value" } } + }; + + // Act. + const customHistoryApi = new InterceptedHistoryApi(initialUrl, customState); + + // Assert. + expect(customHistoryApi.state).toEqual(customState); + + // Cleanup. + customHistoryApi.dispose(); + }); + }); + + describe("Event system", () => { + describe("on", () => { + test("Should register the provided callback for the 'beforeNavigate' event.", () => { + // Arrange. + const callback = vi.fn(); + const unSub = historyApi.on('beforeNavigate', callback); + + // Act. + historyApi.pushState(null, '', 'http://example.com/other'); + + // Assert. + expect(callback).toHaveBeenCalledOnce(); + + // Cleanup. + unSub(); + }); + + test("Should unregister the provided callback when the returned function is called.", () => { + // Arrange. + const callback = vi.fn(); + const unSub = historyApi.on('beforeNavigate', callback); + historyApi.pushState(null, '', 'http://example.com'); + expect(callback).toHaveBeenCalledOnce(); + callback.mockReset(); + + // Act. + unSub(); + + // Assert. + historyApi.pushState(null, '', 'http://example.com/other'); + expect(callback).not.toHaveBeenCalled(); + }); + + test("Should not affect other handlers when unregistering one of the event handlers.", () => { + // Arrange. + const callback1 = vi.fn(); + const callback2 = vi.fn(); + const unSub1 = historyApi.on('beforeNavigate', callback1); + const unSub2 = historyApi.on('beforeNavigate', callback2); + + // Act. + unSub1(); + + // Assert. + historyApi.pushState(null, '', 'http://example.com/other'); + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledOnce(); + + // Cleanup. + unSub2(); + }); + + test.each<{ + method: 'push' | 'replace'; + stateFn: 'pushState' | 'replaceState'; + }>([ + { + method: 'push', + stateFn: 'pushState', + }, + { + method: 'replace', + stateFn: 'replaceState', + } + ])("Should provide the URL, state and method $method via the event object of 'beforeNavigate'.", ({ method, stateFn }) => { + // Arrange. + const callback = vi.fn(); + const state = { path: { test: 'value' }, hash: {} }; + const unSub = historyApi.on('beforeNavigate', callback); + + // Act. + historyApi[stateFn](state, '', 'http://example.com/other'); + + // Assert. + expect(callback).toHaveBeenCalledWith({ + url: 'http://example.com/other', + method, + state, + wasCancelled: false, + cancelReason: undefined, + cancel: expect.any(Function) + }); + + // Cleanup. + unSub(); + }); + + test("Should set wasCancelled to true and cancelReason to the provided reason when the event is cancelled to subsequent callbacks.", () => { + // Arrange. + const callback = vi.fn(); + const unSub1 = historyApi.on('beforeNavigate', (event) => event.cancel('test')); + const unSub2 = historyApi.on('beforeNavigate', callback); + + // Act. + historyApi.pushState(null, '', 'http://example.com/other'); + + // Assert. + expect(callback).toHaveBeenCalledWith({ + url: 'http://example.com/other', + method: 'push', + state: null, + wasCancelled: true, + cancelReason: 'test', + cancel: expect.any(Function) + }); + + // Cleanup. + unSub1(); + unSub2(); + }); + + test("Should ignore cancellation reasons from callbacks if the event has already been cancelled.", () => { + // Arrange. + const callback = vi.fn(); + const unSub1 = historyApi.on('beforeNavigate', (event) => event.cancel('test')); + const unSub2 = historyApi.on('beforeNavigate', (event) => event.cancel('ignored')); + const unSub3 = historyApi.on('beforeNavigate', callback); + + // Act. + historyApi.pushState(null, '', 'http://example.com/other'); + + // Assert. + expect(callback).toHaveBeenCalledWith({ + url: 'http://example.com/other', + method: 'push', + state: null, + wasCancelled: true, + cancelReason: 'test', + cancel: expect.any(Function) + }); + + // Cleanup. + unSub1(); + unSub2(); + unSub3(); + }); + + test("Should register the provided callback for the 'navigationCancelled' event.", () => { + // Arrange. + const callback = vi.fn(); + const unSub1 = historyApi.on('beforeNavigate', (event) => event.cancel()); + const unSub2 = historyApi.on('navigationCancelled', callback); + + // Act. + historyApi.pushState(null, '', 'http://example.com/other'); + + // Assert. + expect(callback).toHaveBeenCalledOnce(); + + // Cleanup. + unSub1(); + unSub2(); + }); + + test("Should transfer the cause of cancellation and the state to the 'navigationCancelled' event.", () => { + // Arrange. + const callback = vi.fn(); + const reason = 'test'; + const state = { test: 'value' }; + const unSub1 = historyApi.on('beforeNavigate', (event) => event.cancel(reason)); + const unSub2 = historyApi.on('navigationCancelled', callback); + + // Act. + historyApi.pushState(state, '', 'http://example.com/other'); + + // Assert. + expect(callback).toHaveBeenCalledWith({ + url: 'http://example.com/other', + method: 'push', + state, + cause: reason + }); + + // Cleanup. + unSub1(); + unSub2(); + }); + }); + describe('url', () => { + test.each([ + 'pushState', + 'replaceState', + ] satisfies (keyof History)[])("Should update whenever an external call to %s is made.", (fn) => { + // Arrange. + const newUrl = "http://example.com/new"; + + // Act. + globalThis.window.history[fn](null, '', newUrl); + + // Assert. + expect(historyApi.url.href).toBe(newUrl); + }); + }); + }); + + describe("Navigation Interception", () => { + test.each([ + 'pushState' as const, + 'replaceState' as const, + ])("Should ultimately push the state data via the %s method set by beforeNavigate handlers in event.state.", (stateFn) => { + // Arrange. + const state = { path: { test: 'value' }, hash: {} }; + const callback = vi.fn((event: BeforeNavigateEvent) => { + event.state = state; + }); + const unSub = historyApi.on('beforeNavigate', callback); + + // Act. + historyApi[stateFn](null, '', 'http://example.com/other'); + + // Assert. + expect(callback).toHaveBeenCalledOnce(); + expect(historyApi.state).toEqual(state); + + // Cleanup. + unSub(); + }); + + test.each([ + 'pushState' as const, + 'replaceState' as const, + ])("Should preserve the previous valid state whenever %s is called with non-conformant state.", (stateFn) => { + // Arrange. + const validState = { path: { test: 'value' }, hash: {} }; + historyApi.replaceState(validState, '', 'http://example.com/setup'); + const invalidState = { test: 'value' }; // Non-conformant state + + // Act. + historyApi[stateFn](invalidState, '', 'http://example.com/other'); + + // Assert. + expect(historyApi.state).toEqual(validState); + }); + + test("Should not call the original history method when navigation is cancelled.", () => { + // Arrange. + const originalPushState = vi.spyOn(browserMocks.history, 'pushState'); + const unSub = historyApi.on('beforeNavigate', (event) => event.cancel('cancelled')); + + // Act. + historyApi.pushState({ path: {}, hash: {} }, '', 'http://example.com/other'); + + // Assert. + expect(originalPushState).not.toHaveBeenCalled(); + + // Cleanup. + unSub(); + }); + + test("Should call the original history method when navigation is not cancelled.", () => { + // Arrange. + const originalPushState = vi.spyOn(browserMocks.history, 'pushState'); + const state = { path: { test: 'value' }, hash: {} }; + + // Act. + historyApi.pushState(state, '', 'http://example.com/other'); + + // Assert. + expect(originalPushState).toHaveBeenCalledWith(state, '', 'http://example.com/other'); + }); + }); + + describe("State management", () => { + test("Should properly update state when navigation succeeds.", () => { + // Arrange. + const newState = { path: { test: 'data' }, hash: {} }; + + // Act. + historyApi.pushState(newState, '', 'http://example.com/test'); + + // Assert. + expect(historyApi.state).toEqual(newState); + expect(historyApi.url.href).toBe('http://example.com/test'); + }); + + test("Should not update state when navigation is cancelled.", () => { + // Arrange. + const originalState = historyApi.state; + const originalUrl = historyApi.url.href; + const unSub = historyApi.on('beforeNavigate', (event) => event.cancel()); + + // Act. + historyApi.pushState({ path: { test: 'data' }, hash: {} }, '', 'http://example.com/test'); + + // Assert. + expect(historyApi.state).toEqual(originalState); + expect(historyApi.url.href).toBe(originalUrl); + + // Cleanup. + unSub(); + }); + }); + + describe("dispose", () => { + test("Should clear event subscriptions.", () => { + // Arrange. + const callback = vi.fn(); + historyApi.on('beforeNavigate', callback); + + // Act. + historyApi.dispose(); + historyApi.pushState({}, '', 'http://example.com/test'); + + // Assert. + expect(callback).not.toHaveBeenCalled(); + }); + + test("Should restore original window.history.", () => { + // Arrange. + const originalHistory = browserMocks.history; + + // Act. + historyApi.dispose(); + + // Assert. + expect(globalThis.window.history).toBe(originalHistory); + }); + + test("Should be safe to call multiple times.", () => { + // Act & Assert. + expect(() => { + historyApi.dispose(); + historyApi.dispose(); + }).not.toThrow(); + }); + + test("Should call parent dispose method.", () => { + // Arrange. + const superDisposeSpy = vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(historyApi)), 'dispose'); + + // Act. + historyApi.dispose(); + + // Assert. + expect(superDisposeSpy).toHaveBeenCalled(); + }); + }); + + describe("Multiple instances", () => { + test("Should create a chain of interceptors, where the latest uses the previous as original.", () => { + // Arrange. + const historyApi2 = new InterceptedHistoryApi(); + let calledFirst: string | undefined; + const callback1 = vi.fn(() => { calledFirst ??= 'first' }); + const callback2 = vi.fn(() => { calledFirst ??= 'second' }); + historyApi.on('beforeNavigate', callback1); + historyApi2.on('beforeNavigate', callback2); + expect(globalThis.window.history).toBe(historyApi2); + + // Act. + historyApi2.pushState({}, '', 'http://example.com/test'); + + // Assert. + expect(calledFirst).toBe('second'); + expect(callback1).toHaveBeenCalled(); + expect(callback2).toHaveBeenCalled(); + + // Cleanup. + historyApi2.dispose(); + }); + }); +}); \ No newline at end of file diff --git a/src/lib/core/InterceptedHistoryApi.svelte.ts b/src/lib/core/InterceptedHistoryApi.svelte.ts new file mode 100644 index 0000000..d14b505 --- /dev/null +++ b/src/lib/core/InterceptedHistoryApi.svelte.ts @@ -0,0 +1,99 @@ +import type { BeforeNavigateEvent, NavigationCancelledEvent, NavigationEvent, State, FullModeHistoryApi, Events } from "$lib/types.js"; +import { isConformantState } from "./isConformantState.js"; +import { StockHistoryApi } from "./StockHistoryApi.svelte.js"; +import { logger } from "./Logger.js"; + +/** + * HistoryApi implementation that intercepts navigation calls to provide beforeNavigate + * and navigationCancelled events. Used by LocationFull for advanced navigation control. + */ +export class InterceptedHistoryApi extends StockHistoryApi implements FullModeHistoryApi { + #eventSubs: Record> = { + beforeNavigate: {}, + navigationCancelled: {}, + }; + #nextSubId = 0; + #originalHistory: History | undefined; + + constructor(initialUrl?: string, initialState?: State) { + super(initialUrl, initialState); + if (globalThis.window) { + this.#originalHistory = globalThis.window.history; + globalThis.window.history = this; + } + } + + pushState(data: any, unused: string, url?: string | URL | null): void { + this.#navigate('push', data, unused, url); + } + + replaceState(data: any, unused: string, url?: string | URL | null): void { + this.#navigate('replace', data, unused, url); + } + + #navigate(method: NavigationEvent['method'], state: any, unused: string, url?: string | URL | null) { + const urlString = url?.toString() || ''; + const event: BeforeNavigateEvent = { + url: urlString, + state, + method, + wasCancelled: false, + cancelReason: undefined, + cancel: (cause) => { + if (event.wasCancelled) { + return; + } + event.wasCancelled = true; + event.cancelReason = cause; + } + }; + + // Notify beforeNavigate listeners + for (let sub of Object.values(this.#eventSubs.beforeNavigate)) { + sub(event); + } + + if (event.wasCancelled) { + // Notify navigationCancelled listeners + for (let sub of Object.values(this.#eventSubs.navigationCancelled)) { + sub({ + url: urlString, + state: event.state, + method, + cause: event.cancelReason, + }); + } + } else { + if (!isConformantState(event.state)) { + logger.warn(`Warning: Non-conformant state object passed to history.${method}State. Previous state will prevail.`); + event.state = this.state; + } + this.#originalHistory?.[`${method}State`](event.state, unused, url); + this.url.href = globalThis.window?.location?.href ?? new URL(url ?? '', this.url).href; + this.state = event.state as State; + } + } + + /** + * Subscribe to navigation events. + */ + on(event: 'beforeNavigate', callback: (event: BeforeNavigateEvent) => void): () => void; + on(event: 'navigationCancelled', callback: (event: NavigationCancelledEvent) => void): () => void; + on(event: Events, callback: Function): () => void { + const id = ++this.#nextSubId; + this.#eventSubs[event][id] = callback; + return () => delete this.#eventSubs[event][id]; + } + + dispose(): void { + // Clear event subscriptions + this.#eventSubs = { + beforeNavigate: {}, + navigationCancelled: {}, + }; + if (this.#originalHistory) { + globalThis.window.history = this.#originalHistory; + } + super.dispose(); + } +} \ No newline at end of file diff --git a/src/lib/core/LocationFull.test.ts b/src/lib/core/LocationFull.test.ts index 29cd4e6..9927709 100644 --- a/src/lib/core/LocationFull.test.ts +++ b/src/lib/core/LocationFull.test.ts @@ -1,224 +1,101 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { LocationFull } from "./LocationFull.js"; -import type { State, Location } from "$lib/types.js"; +import type { State, Location, FullModeHistoryApi } from "$lib/types.js"; import { setupBrowserMocks, ALL_HASHES } from "../../testing/test-utils.js"; +import { SvelteURL } from "svelte/reactivity"; describe("LocationFull", () => { const initialUrl = "http://example.com/"; let location: Location; let browserMocks: ReturnType; - + beforeEach(() => { browserMocks = setupBrowserMocks(initialUrl); location = new LocationFull(); }); - + afterEach(() => { location.dispose(); browserMocks.cleanup(); }); + describe('constructor', () => { test("Should create a new instance with the expected default values.", () => { // Assert. expect(location.url.href).toBe(initialUrl); }); - }); - describe('on', () => { - test("Should register the provided callback for the 'beforeNavigate' event.", () => { - // Arrange. - const callback = vi.fn(); - const unSub = location.on('beforeNavigate', callback); - - // Act. - browserMocks.history.pushState(null, '', 'http://example.com/other'); - - // Assert. - expect(callback).toHaveBeenCalledOnce(); - - // Cleanup. - unSub(); - }); - test("Should unregister the provided callback when the returned function is called.", () => { - // Arrange. - const callback = vi.fn(); - const unSub = location.on('beforeNavigate', callback); - - // Act. - unSub(); - - // Assert. - browserMocks.history.pushState(null, '', 'http://example.com/other'); - expect(callback).not.toHaveBeenCalled(); - }); - test("Should not affect other handlers when unregistering one of the event handlers.", () => { - // Arrange. - const callback1 = vi.fn(); - const callback2 = vi.fn(); - const unSub1 = location.on('beforeNavigate', callback1); - const unSub2 = location.on('beforeNavigate', callback2); - - // Act. - unSub1(); - - // Assert. - browserMocks.history.pushState(null, '', 'http://example.com/other'); - expect(callback1).not.toHaveBeenCalled(); - expect(callback2).toHaveBeenCalledOnce(); - - // Cleanup. - unSub2(); - }); - test.each([ - { - method: 'push', - stateFn: 'pushState', - }, - { - method: 'replace', - stateFn: 'replaceState', - } - ])("Should provide the URL, state and method $method via the event object of 'beforeNavigate'.", ({ method, stateFn }) => { - // Arrange. - const callback = vi.fn(); - const state = { path: { test: 'value' }, hash: {} }; - const unSub = location.on('beforeNavigate', callback); - - // Act. - // @ts-expect-error stateFn cannot enumerate history. - browserMocks.history[stateFn](state, '', 'http://example.com/other'); - - // Assert. - expect(callback).toHaveBeenCalledWith({ - url: 'http://example.com/other', - method, - state, - wasCancelled: false, - cancelReason: undefined, - cancel: expect.any(Function) - }); - - // Cleanup. - unSub(); - }); - test("Should set wasCancelled to true and cancelReason to the provided reason when the event is cancelled to subsequent callbacks.", () => { - // Arrange. - const callback = vi.fn(); - const unSub1 = location.on('beforeNavigate', (event) => event.cancel('test')); - const unSub2 = location.on('beforeNavigate', callback); - - // Act. - browserMocks.history.pushState(null, '', 'http://example.com/other'); - - // Assert. - expect(callback).toHaveBeenCalledWith({ - url: 'http://example.com/other', - method: 'push', - state: null, - wasCancelled: true, - cancelReason: 'test', - cancel: expect.any(Function) - }); - - // Cleanup. - unSub1(); - unSub2(); - }); - test("Should ignore cancellation reasons from callbacks if the event has already been cancelled.", () => { - // Arrange. - const callback = vi.fn(); - const unSub1 = location.on('beforeNavigate', (event) => event.cancel('test')); - const unSub2 = location.on('beforeNavigate', (event) => event.cancel('ignored')); - const unSub3 = location.on('beforeNavigate', callback); - // Act. - browserMocks.history.pushState(null, '', 'http://example.com/other'); - - // Assert. - expect(callback).toHaveBeenCalledWith({ - url: 'http://example.com/other', - method: 'push', - state: null, - wasCancelled: true, - cancelReason: 'test', - cancel: expect.any(Function) - }); - - // Cleanup. - unSub1(); - unSub2(); - unSub3(); - }); - test.each([ - 'pushState' as const, - 'replaceState' as const, - ])("Should ultimately push the state data via the %s method set by beforeNavigate handlers in event.state.", (stateFn) => { + test("Should use provided FullModeHistoryApi instance.", () => { // Arrange. - const state = { path: { test: 'value' }, hash: {} }; - const callback = vi.fn((event) => { - event.state = state; - }); - const unSub = location.on('beforeNavigate', callback); + const mockHistoryApi: FullModeHistoryApi = { + url: new SvelteURL(initialUrl), + state: { path: { test: 'value' }, hash: {} }, + pushState: vi.fn(), + replaceState: vi.fn(), + dispose: vi.fn(), + length: 0, + scrollRestoration: 'auto' as const, + back: vi.fn(), + forward: vi.fn(), + go: vi.fn(), + on: vi.fn().mockReturnValue(() => { }), + }; + const locationWithMock = new LocationFull(mockHistoryApi); // Act. - browserMocks.history[stateFn](null, '', 'http://example.com/other'); + locationWithMock.goTo(''); + locationWithMock.goTo('', { replace: true }); + locationWithMock.go(1); + locationWithMock.back(); + locationWithMock.forward(); + const href = locationWithMock.url.toString(); + const state = locationWithMock.getState(ALL_HASHES.path); + locationWithMock.dispose(); // Assert. - expect(callback).toHaveBeenCalledOnce(); - expect(browserMocks.history.state).deep.equal(state); - - // Cleanup. - unSub(); + expect(mockHistoryApi.pushState).toHaveBeenCalled(); + expect(mockHistoryApi.replaceState).toHaveBeenCalled(); + expect(mockHistoryApi.go).toHaveBeenCalledWith(1); + expect(mockHistoryApi.back).toHaveBeenCalled(); + expect(mockHistoryApi.forward).toHaveBeenCalled(); + expect(href).toEqual(initialUrl); + expect(state).toEqual({ test: 'value' }); + expect(mockHistoryApi.dispose).toHaveBeenCalled(); }); - test("Should register the provided callback for the 'navigationCancelled' event.", () => { - // Arrange. - const callback = vi.fn(); - const unSub1 = location.on('beforeNavigate', (event) => event.cancel()); - const unSub2 = location.on('navigationCancelled', callback); - - // Act. - browserMocks.history.pushState(null, '', 'http://example.com/other'); - - // Assert. - expect(callback).toHaveBeenCalledOnce(); + }); - // Cleanup. - unSub1(); - unSub2(); - }); - test("Should transfer the cause of cancellation and the state to the 'navigationCancelled' event.", () => { + describe('on', () => { + test("Should delegate event registration to FullModeHistoryApi.", () => { // Arrange. + const mockHistoryApi: FullModeHistoryApi = { + url: new SvelteURL(initialUrl), + state: { path: undefined, hash: {} }, + pushState: vi.fn(), + replaceState: vi.fn(), + dispose: vi.fn(), + length: 0, + scrollRestoration: 'auto' as const, + back: vi.fn(), + forward: vi.fn(), + go: vi.fn(), + on: vi.fn().mockReturnValue(() => { }), + }; + const locationWithMock = new LocationFull(mockHistoryApi); const callback = vi.fn(); - const reason = 'test'; - const state = { test: 'value' }; - const unSub1 = location.on('beforeNavigate', (event) => event.cancel(reason)); - const unSub2 = location.on('navigationCancelled', callback); // Act. - browserMocks.history.pushState(state, '', 'http://example.com/other'); + locationWithMock.on('beforeNavigate', callback); + locationWithMock.on('navigationCancelled', callback); // Assert. - expect(callback).toHaveBeenCalledWith({ url: 'http://example.com/other', cause: 'test', method: 'push', state }); + expect(mockHistoryApi.on).toHaveBeenCalledWith('beforeNavigate', callback); + expect(mockHistoryApi.on).toHaveBeenCalledWith('navigationCancelled', callback); // Cleanup. - unSub1(); - unSub2(); + locationWithMock.dispose(); }); }); - describe('url', () => { - test.each([ - 'pushState', - 'replaceState', - ] satisfies (keyof History)[])("Should update whenever an external call to %s is made.", (fn) => { - // Arrange. - const newUrl = "http://example.com/new"; - // Act. - browserMocks.history[fn](null, '', newUrl); - - // Assert. - expect(location.url.href).toBe(newUrl); - }); - }); describe('getState', () => { test.each([ 'pushState', @@ -228,7 +105,7 @@ describe("LocationFull", () => { const state: State = { path: { test: 'value' }, hash: { single: '/abc', p1: '/def' } }; // Act. - browserMocks.history[fn](state, '', 'http://example.com/new'); + globalThis.window.history[fn](state, '', 'http://example.com/new'); // Assert. expect(location.getState(ALL_HASHES.path)).toEqual(state.path); @@ -236,21 +113,4 @@ describe("LocationFull", () => { expect(location.getState('p1')).toEqual(state.hash.p1); }); }); - describe('Navigation Interception', () => { - test.each([ - 'pushState' as const, - 'replaceState' as const, - ])("Should preserve the previous valid state whenever %s is called with non-conformant state.", (stateFn) => { - // Arrange. - const validState = { path: { test: 'value' }, hash: {} }; - browserMocks.history[stateFn](validState, '', 'http://example.com/'); - const state = { test: 'value' }; - - // Act. - browserMocks.history[stateFn](state, '', 'http://example.com/other'); - - // Assert. - expect(browserMocks.history.state).deep.equals(validState); - }); - }); }); \ No newline at end of file diff --git a/src/lib/core/LocationFull.ts b/src/lib/core/LocationFull.ts index 664b9ad..27e7c13 100644 --- a/src/lib/core/LocationFull.ts +++ b/src/lib/core/LocationFull.ts @@ -1,80 +1,23 @@ -import type { BeforeNavigateEvent, Events, NavigationCancelledEvent, NavigationEvent } from "$lib/types.js"; -import { isConformantState } from "./isConformantState.js"; +import type { BeforeNavigateEvent, NavigationCancelledEvent, FullModeHistoryApi, Events } from "$lib/types.js"; import { LocationLite } from "./LocationLite.svelte.js"; -import { LocationState } from "./LocationState.svelte.js"; -import { logger } from "./Logger.js"; +import { InterceptedHistoryApi } from "./InterceptedHistoryApi.svelte.js"; /** * Location implementation of the library's full mode feature. + * Replaces window.history with an InterceptedHistoryApi to capture all navigation events. */ export class LocationFull extends LocationLite { - #eventSubs: Record> = { - beforeNavigate: {}, - navigationCancelled: {}, - }; - #nextSubId = 0; - #originalPushState = globalThis.window?.history.pushState.bind(globalThis.window?.history); - #originalReplaceState = globalThis.window?.history.replaceState.bind(globalThis.window?.history); - #innerState; - constructor() { - const innerState = new LocationState(); - // @ts-expect-error Base class constructor purposely hides the fact that takes one argument. - super(innerState); - this.#innerState = innerState; - globalThis.window.history.pushState = this.#navigate.bind(this, 'push'); - globalThis.window.history.replaceState = this.#navigate.bind(this, 'replace'); - } - - dispose() { - globalThis.window.history.pushState = this.#originalPushState; - globalThis.window.history.replaceState = this.#originalReplaceState; - super.dispose(); - } - - #navigate(method: NavigationEvent['method'], state: any, _: string, url: string) { - const event: BeforeNavigateEvent = { - url, - state, - method, - wasCancelled: false, - cancelReason: undefined, - cancel: (cause) => { - if (event.wasCancelled) { - return; - } - event.wasCancelled = true; - event.cancelReason = cause; - } - }; - for (let sub of Object.values(this.#eventSubs.beforeNavigate)) { - sub(event); - } - if (event.wasCancelled) { - for (let sub of Object.values(this.#eventSubs.navigationCancelled)) { - sub({ - url, - state: event.state, - method, - cause: event.cancelReason, - }); - } - } else { - if (!isConformantState(event.state)) { - logger.warn(`Warning: Non-conformant state object passed to history.${method}State. Previous state will prevail.`); - event.state = this.#innerState.state; - } - const navFn = method === 'push' ? this.#originalPushState : this.#originalReplaceState; - navFn(event.state, '', url); - this.url.href = globalThis.window?.location.href; - this.#innerState.state = state; - } + #historyApi: FullModeHistoryApi; + + constructor(historyApi?: FullModeHistoryApi) { + const api = historyApi ?? new InterceptedHistoryApi(); + super(api); + this.#historyApi = api; } on(event: 'beforeNavigate', callback: (event: BeforeNavigateEvent) => void): () => void; on(event: 'navigationCancelled', callback: (event: NavigationCancelledEvent) => void): () => void; on(event: Events, callback: Function): () => void { - const id = ++this.#nextSubId; - this.#eventSubs[event][id] = callback; - return () => delete this.#eventSubs.beforeNavigate[id]; + return this.#historyApi.on(event as any, callback as any); } } diff --git a/src/lib/core/LocationLite.svelte.test.ts b/src/lib/core/LocationLite.svelte.test.ts index b6826bc..3334ea6 100644 --- a/src/lib/core/LocationLite.svelte.test.ts +++ b/src/lib/core/LocationLite.svelte.test.ts @@ -1,40 +1,65 @@ -import { describe, test, expect, beforeEach, afterEach, afterAll } from "vitest"; +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; import { LocationLite } from "./LocationLite.svelte.js"; -import { LocationState } from "./LocationState.svelte.js"; -import type { Hash, Location } from "$lib/types.js"; +import type { Hash, HistoryApi, Location } from "$lib/types.js"; import { setupBrowserMocks, ROUTING_UNIVERSES, ALL_HASHES } from "../../testing/test-utils.js"; +import { SvelteURL } from "svelte/reactivity"; describe("LocationLite", () => { const initialUrl = "http://example.com/"; - let location: Location; + let location: LocationLite; let browserMocks: ReturnType; - + beforeEach(() => { browserMocks = setupBrowserMocks(initialUrl); location = new LocationLite(); }); - + afterEach(() => { location.dispose(); browserMocks.cleanup(); }); + describe("constructor", () => { test("Should create a new instance with the expected default values.", () => { // Assert. expect(location.url.href).toBe(initialUrl); }); - test("Should use the provided LocationState instance.", () => { + test("Should use the provided HistoryApi instance.", () => { // Arrange. - const locationState = new LocationState(); + const historyApi: HistoryApi = { + url: new SvelteURL(initialUrl), + state: { + path: { test: 'value' }, + hash: {} + }, + pushState: vi.fn(), + replaceState: vi.fn(), + dispose: vi.fn(), + length: 0, + scrollRestoration: 'auto' as const, + back: vi.fn(), + forward: vi.fn(), + go: vi.fn(), + } + const location = new LocationLite(historyApi); + // Act. - // @ts-expect-error Parameter not disclosed. - const location = new LocationLite(locationState); + location.goTo(''); + location.goTo('', { replace: true }); + location.go(1); + location.back(); + location.forward(); + const href = location.url.toString(); + location.dispose(); // Assert. - expect(location.url).toBe(locationState.url); - - // Cleanup. - location.dispose(); + expect(historyApi.pushState).toHaveBeenCalled(); + expect(historyApi.replaceState).toHaveBeenCalled(); + expect(historyApi.go).toHaveBeenCalledWith(1); + expect(historyApi.back).toHaveBeenCalled(); + expect(historyApi.forward).toHaveBeenCalled(); + expect(href).toBe(initialUrl); + expect(historyApi.dispose).toHaveBeenCalled(); }); }); describe("on", () => { @@ -46,18 +71,6 @@ describe("LocationLite", () => { expect(act).toThrowError(); }); }); - describe("url", () => { - test("Should update whenever a popstate event is triggered.", () => { - // Arrange. - const newUrl = "http://example.com/new"; - - // Act. - browserMocks.setUrl(newUrl); - - // Assert. - expect(location.url.href).toBe(newUrl); - }); - }); describe("getState", () => { test.each<{ hash: Hash; expectedState: any; }>([ { @@ -112,279 +125,4 @@ describe("LocationLite", () => { expect(location.getState('abc')).toBe(abcHashState); }); }); - describe("Event Synchronization", () => { - test.each([ - { - event: 'popstate', - }, - { - event: 'hashchange', - } - ])("Should carry over previous state when a $event occurs that carries no state.", ({ event }) => { - // Arrange - Set up initial state in LocationLite - const initialState = { - path: { preserved: "path-data" }, - hash: { - single: { preserved: "hash-data" }, - multi1: { preserved: "multi-data" } - } - }; - browserMocks.simulateHistoryChange(initialState); - - // Verify initial state is set - expect(location.getState(ALL_HASHES.path)).toEqual({ preserved: "path-data" }); - expect(location.getState(ALL_HASHES.single)).toEqual({ preserved: "hash-data" }); - expect(location.getState("multi1")).toEqual({ preserved: "multi-data" }); - - // Act - Trigger event with no state (simulates anchor hash navigation) - const newUrl = "http://example.com/#new-anchor"; - browserMocks.setUrl(newUrl); - browserMocks.history.state = null; // Simulate no state from browser - - // Trigger the specific event type - const eventObj = event === 'popstate' - ? new PopStateEvent('popstate', { state: null }) - : new HashChangeEvent('hashchange'); - browserMocks.window.dispatchEvent(eventObj); - - // Assert - Previous state should be preserved - expect(location.getState(ALL_HASHES.path)).toEqual({ preserved: "path-data" }); - expect(location.getState(ALL_HASHES.single)).toEqual({ preserved: "hash-data" }); - expect(location.getState("multi1")).toEqual({ preserved: "multi-data" }); - - // URL should be updated though - expect(location.url.href).toBe(newUrl); - }); - - test("Should update state when browser event carries new state", () => { - // Arrange - Set up initial state - const initialState = { - path: { initial: "data" }, - hash: { single: { initial: "hash" } } - }; - browserMocks.simulateHistoryChange(initialState); - - // Act - Trigger popstate with new state - const newState = { - path: { updated: "data" }, - hash: { single: { updated: "hash" } } - }; - const newUrl = "http://example.com/updated"; - browserMocks.simulateHistoryChange(newState, newUrl); - - // Assert - State should be updated - expect(location.getState(ALL_HASHES.path)).toEqual({ updated: "data" }); - expect(location.getState(ALL_HASHES.single)).toEqual({ updated: "hash" }); - expect(location.url.href).toBe(newUrl); - }); - - test("Should preserve state when history.state is undefined", () => { - // Arrange - Set up initial state - const initialState = { - path: { preserved: "path" }, - hash: { single: { preserved: "single" } } - }; - browserMocks.simulateHistoryChange(initialState); - - // Act - Simulate browser event with undefined state - browserMocks.history.state = undefined; - const event = new PopStateEvent('popstate', { state: undefined }); - browserMocks.window.dispatchEvent(event); - - // Assert - State should be preserved due to nullish coalescing - expect(location.getState(ALL_HASHES.path)).toEqual({ preserved: "path" }); - expect(location.getState(ALL_HASHES.single)).toEqual({ preserved: "single" }); - }); - - test("Should preserve state when history.state is null", () => { - // Arrange - Set up initial state - const initialState = { - path: { preserved: "path" }, - hash: { single: { preserved: "single" } } - }; - browserMocks.simulateHistoryChange(initialState); - - // Act - Simulate browser event with null state - browserMocks.history.state = null; - const event = new PopStateEvent('popstate', { state: null }); - browserMocks.window.dispatchEvent(event); - - // Assert - State should be preserved due to nullish coalescing - expect(location.getState(ALL_HASHES.path)).toEqual({ preserved: "path" }); - expect(location.getState(ALL_HASHES.single)).toEqual({ preserved: "single" }); - }); - }); -}); - -describe("LocationLite - Browser API Integration", () => { - const initialUrl = "http://example.com/"; - let location: Location; - let browserMocks: ReturnType; - - beforeEach(() => { - browserMocks = setupBrowserMocks(initialUrl); - location = new LocationLite(); - }); - - afterEach(() => { - location.dispose(); - browserMocks.cleanup(); - }); - - describe("Browser history integration", () => { - test("Should properly sync with browser pushState operations", () => { - // Arrange - const newUrl = "http://example.com/new-path"; - const newState = { path: { test: "data" }, hash: {} }; - - // Act - Simulate external pushState call - browserMocks.history.pushState(newState, "", newUrl); - browserMocks.triggerPopstate(newState); - - // Assert - expect(location.url.href).toBe(newUrl); - expect(location.getState(ALL_HASHES.path)).toEqual({ test: "data" }); - }); - - test("Should properly sync with browser replaceState operations", () => { - // Arrange - const newUrl = "http://example.com/replaced-path"; - const newState = { path: { replaced: true }, hash: {} }; - - // Act - Simulate external replaceState call - browserMocks.history.replaceState(newState, "", newUrl); - browserMocks.triggerPopstate(newState); - - // Assert - expect(location.url.href).toBe(newUrl); - expect(location.getState(ALL_HASHES.path)).toEqual({ replaced: true }); - }); - - test("Should handle hash-based state updates", () => { - // Arrange - const newUrl = "http://example.com/#/hash-path"; - const newState = { path: undefined, hash: { single: { hash: "data" } } }; - - // Act - browserMocks.simulateHistoryChange(newState, newUrl); - - // Assert - expect(location.url.href).toBe(newUrl); - expect(location.getState(ALL_HASHES.single)).toEqual({ hash: "data" }); - }); - - test("Should handle multi-hash state updates", () => { - // Arrange - const hashId = "testHash"; - const newUrl = "http://example.com/#testHash=/multi-path"; - const newState = { - path: undefined, - hash: { [hashId]: { multi: "data" } } - }; - - // Act - browserMocks.simulateHistoryChange(newState, newUrl); - - // Assert - expect(location.url.href).toBe(newUrl); - expect(location.getState(hashId)).toEqual({ multi: "data" }); - }); - - test("Should preserve existing state during partial updates", () => { - // Arrange - Set initial state - const initialState = { - path: { initial: "path" }, - hash: { - single: { initial: "single" }, - multi1: { initial: "multi1" } - } - }; - browserMocks.simulateHistoryChange(initialState); - - // Act - Update only single hash - const updatedState = { - path: { initial: "path" }, - hash: { - single: { updated: "single" }, - multi1: { initial: "multi1" } // Should be preserved - } - }; - browserMocks.simulateHistoryChange(updatedState); - - // Assert - expect(location.getState(ALL_HASHES.path)).toEqual({ initial: "path" }); - expect(location.getState(ALL_HASHES.single)).toEqual({ updated: "single" }); - expect(location.getState("multi1")).toEqual({ initial: "multi1" }); - }); - }); -}); - -// Test across all routing universes for comprehensive coverage -ROUTING_UNIVERSES.forEach((universe) => { - describe(`LocationLite - ${universe.text}`, () => { - let browserMocks: ReturnType; - let location: Location; - - beforeEach(() => { - browserMocks = setupBrowserMocks("http://example.com/"); - location = new LocationLite(); - }); - - afterEach(() => { - location.dispose(); - browserMocks.cleanup(); - }); - - describe("State management across universes", () => { - test("Should properly handle state storage and retrieval", () => { - // Arrange - const testState = { universe: universe.text, data: 123 }; - - // Act - Store state for this universe - if (universe.hash === ALL_HASHES.implicit) { - // For implicit hash, we can't directly test storage without navigation - // This is handled by the routing system, so we skip this test - return; - } else { - // Set state directly and trigger popstate to notify LocationLite - const newState = universe.hash === ALL_HASHES.path - ? { path: testState, hash: {} } - : universe.hash === ALL_HASHES.single - ? { path: undefined, hash: { single: testState } } - : { path: undefined, hash: { [universe.hash]: testState } }; - - browserMocks.simulateHistoryChange(newState); - - // Assert - expect(location.getState(universe.hash)).toEqual(testState); - } - }); - - test("Should handle popstate events correctly", () => { - // Arrange - const testState = { popstate: 'test', value: 456 }; - const newUrl = universe.hash === ALL_HASHES.path - ? "http://example.com/test-path" - : "http://example.com/#test-hash"; - - // Act - browserMocks.simulateHistoryChange( - universe.hash === ALL_HASHES.path - ? { path: testState, hash: {} } - : universe.hash === ALL_HASHES.single - ? { path: undefined, hash: { single: testState } } - : typeof universe.hash === 'string' - ? { path: undefined, hash: { [universe.hash]: testState } } - : { path: undefined, hash: {} }, // implicit case - newUrl - ); - - // Assert - expect(location.url.href).toBe(newUrl); - if (universe.hash !== ALL_HASHES.implicit) { - expect(location.getState(universe.hash)).toEqual(testState); - } - }); - }); - }); }); diff --git a/src/lib/core/LocationLite.svelte.ts b/src/lib/core/LocationLite.svelte.ts index c961e71..35f76df 100644 --- a/src/lib/core/LocationLite.svelte.ts +++ b/src/lib/core/LocationLite.svelte.ts @@ -1,7 +1,6 @@ -import type { BeforeNavigateEvent, Hash, Location, GoToOptions, NavigateOptions, NavigationCancelledEvent, State } from "../types.js"; +import type { BeforeNavigateEvent, Hash, Location, GoToOptions, NavigateOptions, NavigationCancelledEvent, State, HistoryApi } from "../types.js"; import { getCompleteStateKey } from "./Location.js"; -import { on } from "svelte/events"; -import { LocationState } from "./LocationState.svelte.js"; +import { StockHistoryApi } from "./StockHistoryApi.svelte.js"; import { routingOptions } from "./options.js"; import { resolveHashValue } from "./resolveHashValue.js"; import { calculateHref } from "./calculateHref.js"; @@ -13,14 +12,14 @@ import { preserveQueryInUrl } from "./preserveQuery.js"; * which are normally only needed when mixing router libraries. */ export class LocationLite implements Location { - #innerState: LocationState; - #cleanup: (() => void) | undefined; + #historyApi: HistoryApi; + hashPaths = $derived.by(() => { if (routingOptions.hashMode === 'single') { - return { single: this.#innerState.url.hash.substring(1) }; + return { single: this.#historyApi.url.hash.substring(1) }; } const result = {} as Record; - const paths = this.#innerState.url.hash.substring(1).split(';'); + const paths = this.#historyApi.url.hash.substring(1).split(';'); for (let rawPath of paths) { const [id, path] = rawPath.split('='); if (!id || !path) { @@ -31,32 +30,8 @@ export class LocationLite implements Location { return result; }); - constructor() { - const [innerState] = arguments; - if (innerState instanceof LocationState) { - this.#innerState = innerState; - } - else { - this.#innerState = new LocationState(); - } - this.#cleanup = $effect.root(() => { - const cleanups = [] as (() => void)[]; - ['popstate', 'hashchange'].forEach((event) => { - cleanups.push(on(globalThis.window, event, () => { - this.#innerState.url.href = globalThis.window?.location?.href; - if (!globalThis.window?.history?.state) { - // Potential hash navigation. Preserve current state. - this.#goTo(this.#innerState.url.href, true, $state.snapshot(this.#innerState.state)); - } - this.#innerState.state = globalThis.window?.history?.state ?? this.#innerState.state; - })); - }); - return () => { - for (let cleanup of cleanups) { - cleanup(); - } - }; - }); + constructor(historyApi?: HistoryApi) { + this.#historyApi = historyApi ?? new StockHistoryApi(); } on(event: "beforeNavigate", callback: (event: BeforeNavigateEvent) => void): () => void; @@ -66,17 +41,29 @@ export class LocationLite implements Location { } get url() { - return this.#innerState.url; + return this.#historyApi.url; } getState(hash: Hash) { if (typeof hash === 'string') { - return this.#innerState.state?.hash[hash]; + return this.#historyApi.state?.hash[hash]; } if (hash) { - return this.#innerState.state?.hash.single; + return this.#historyApi.state?.hash.single; } - return this.#innerState.state?.path; + return this.#historyApi.state?.path; + } + + back(): void { + this.#historyApi.back(); + } + + forward(): void { + this.#historyApi.forward(); + } + + go(delta: number): void { + this.#historyApi.go(delta); } #goTo(url: string, replace: boolean, state: State | undefined) { @@ -84,13 +71,7 @@ export class LocationLite implements Location { // Shallow routing. url = this.url.href; } - ( - replace ? - globalThis.window?.history.replaceState : - globalThis.window?.history.pushState - ).bind(globalThis.window?.history)(state, '', url); - this.#innerState.url.href = globalThis.window?.location.href; - this.#innerState.state = state ?? { path: undefined, hash: {} }; + this.#historyApi[replace ? 'replaceState' : 'pushState'](state, '', url); } goTo(url: string, options?: GoToOptions): void { @@ -113,11 +94,10 @@ export class LocationLite implements Location { } [getCompleteStateKey](): State { - return $state.snapshot(this.#innerState.state); + return $state.snapshot(this.#historyApi.state); } dispose() { - this.#cleanup?.(); - this.#cleanup = undefined; + this.#historyApi.dispose(); } } diff --git a/src/lib/core/LocationState.svelte.test.ts b/src/lib/core/LocationState.svelte.test.ts index c3eca3e..2c42112 100644 --- a/src/lib/core/LocationState.svelte.test.ts +++ b/src/lib/core/LocationState.svelte.test.ts @@ -157,9 +157,6 @@ describe('LocationState', () => { hash: {} }); expect(mockLoggerWarn).toHaveBeenCalledOnce(); - expect(mockLoggerWarn).toHaveBeenCalledWith( - 'Non-conformant state data detected in History API. Resetting to clean state.' - ); }); }); diff --git a/src/lib/core/LocationState.svelte.ts b/src/lib/core/LocationState.svelte.ts index 7ebcbd5..1bcdee5 100644 --- a/src/lib/core/LocationState.svelte.ts +++ b/src/lib/core/LocationState.svelte.ts @@ -1,21 +1,39 @@ import { SvelteURL } from "svelte/reactivity"; import { isConformantState } from "./isConformantState.js"; import { logger } from "./Logger.js"; +import type { State } from "$lib/types.js"; /** * Helper class used to manage the reactive data of Location implementations. + * This class can serve as a base class for HistoryApi implementations. */ export class LocationState { - url = new SvelteURL(globalThis.window?.location?.href); + url; state; - constructor() { - // Get the current state from History API - let historyState = globalThis.window?.history?.state; - let validState = false; - this.state = $state((validState = isConformantState(historyState)) ? historyState : { path: undefined, hash: {} }); - if (!validState && historyState != null) { - logger.warn('Non-conformant state data detected in History API. Resetting to clean state.'); + constructor(initialUrl?: string, initialState?: State) { + // Initialize URL + this.url = new SvelteURL(initialUrl ?? globalThis.window?.location?.href ?? 'http://localhost/'); + + // Initialize state using normalization + const historyState = initialState ?? globalThis.window?.history?.state; + this.state = $state(this.normalizeState(historyState)); + } + + /** + * Normalizes state data to ensure it conforms to the expected State interface. + * @param state The state to normalize + * @param defaultState Optional default state to use if normalization is needed + * @returns Normalized state that conforms to the State interface + */ + normalizeState(state: any, defaultState?: State): State { + const validState = isConformantState(state); + + if (!validState && state != null) { + const action = defaultState ? 'Using known valid state.' : 'Resetting to clean state.'; + logger.warn(`Non-conformant state data detected. ${action}`); } + + return validState ? state : (defaultState ?? { path: undefined, hash: {} }); } } diff --git a/src/lib/core/RouterEngine.svelte.test.ts b/src/lib/core/RouterEngine.svelte.test.ts index 1b1127e..b2787b5 100644 --- a/src/lib/core/RouterEngine.svelte.test.ts +++ b/src/lib/core/RouterEngine.svelte.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeAll, afterAll, afterEach, vi, beforeEach } from "vitest"; import { routePatternsKey, RouterEngine } from "./RouterEngine.svelte.js"; -import { init, type Hash, type RouteInfo } from "$lib/index.js"; +import { init, type RouteInfo } from "$lib/index.js"; import { registerRouter } from "./trace.svelte.js"; import { location } from "./Location.js"; import type { State } from "$lib/types.js"; @@ -38,17 +38,17 @@ describe("RouterEngine", () => { cleanup(); }); }); - + describe('constructor hash validation', () => { let cleanupFn: (() => void) | null = null; - + afterEach(() => { if (cleanupFn) { cleanupFn(); cleanupFn = null; } }); - + test.each([ { parentHash: ALL_HASHES.path, childHash: ALL_HASHES.single, mode: 'single' as const, description: "path parent vs hash child" }, { parentHash: ALL_HASHES.single, childHash: ALL_HASHES.path, mode: 'single' as const, description: "hash parent vs path child" }, @@ -57,14 +57,14 @@ describe("RouterEngine", () => { ])("Should throw error when parent and child have different hash modes: $description", ({ parentHash, childHash, mode }) => { // Arrange cleanupFn = init({ hashMode: mode }); - + // Act & Assert expect(() => { const parent = new RouterEngine({ hash: parentHash }); new RouterEngine({ parent, hash: childHash }); }).toThrowError("The parent router's hash mode must match the child router's hash mode."); }); - + test.each([ { parentHash: ALL_HASHES.path, mode: 'single' as const, description: "path parent" }, { parentHash: ALL_HASHES.single, mode: 'single' as const, description: "hash parent" }, @@ -72,7 +72,7 @@ describe("RouterEngine", () => { ])("Should allow child router without explicit hash to inherit parent's hash: $description", ({ parentHash, mode }) => { // Arrange cleanupFn = init({ hashMode: mode }); - + // Act & Assert expect(() => { const parent = new RouterEngine({ hash: parentHash }); @@ -80,31 +80,31 @@ describe("RouterEngine", () => { expect(child).toBeDefined(); }).not.toThrow(); }); - + test("Should throw error when using hash path ID without multi hash mode", () => { // Arrange cleanupFn = init({ hashMode: 'single' }); - + // Act & Assert expect(() => { new RouterEngine({ hash: ALL_HASHES.multi }); }).toThrowError("A hash path ID was given, but is only allowed when the library's hash mode has been set to 'multi'."); }); - + test("Should throw error when using non-string hash in multi hash mode", () => { // Arrange cleanupFn = init({ hashMode: 'multi' }); - + // Act & Assert expect(() => { new RouterEngine({ hash: ALL_HASHES.single }); // boolean not allowed in multi mode }).toThrowError("The specified hash value is not valid for the 'multi' hash mode. Either don't specify a hash for path routing, or correct the hash value."); }); - + test("Should allow valid hash path ID in multi hash mode", () => { // Arrange cleanupFn = init({ hashMode: 'multi' }); - + // Act & Assert expect(() => { const router = new RouterEngine({ hash: ALL_HASHES.multi }); @@ -122,35 +122,36 @@ ROUTING_UNIVERSES.forEach(universe => { describe(`RouterEngine (${universe.text})`, () => { let cleanup: () => void; let browserMocks: ReturnType; - + beforeAll(() => { browserMocks = setupBrowserMocks("http://example.com/", location); - cleanup = init({ + cleanup = init({ hashMode: universe.hashMode, - implicitMode: universe.implicitMode + implicitMode: universe.implicitMode }); }); - + beforeEach(() => { - browserMocks.setUrl("http://example.com"); + location.url.href = "http://example.com/"; + browserMocks.setUrl(location.url.href); }); - + afterAll(() => { cleanup(); browserMocks.cleanup(); }); - + describe('constructor', () => { test("Should create router with correct hash configuration", () => { // Act. const router = new RouterEngine({ hash: universe.hash }); - + // Assert. expect(router).toBeDefined(); // Additional assertions could check internal hash configuration }); }); - + describe('basePath', () => { test("Should be '/' by default", () => { // Act. @@ -159,7 +160,7 @@ ROUTING_UNIVERSES.forEach(universe => { // Assert. expect(router.basePath).toBe('/'); }); - + test("Should be the parent's basePath plus the router's basePath", () => { // Arrange. const parent = new RouterEngine({ hash: universe.hash }); @@ -173,7 +174,7 @@ ROUTING_UNIVERSES.forEach(universe => { // Assert. expect(basePath).toBe('/parent/child'); }); - + test("Should remove the trailing slash.", () => { // Arrange. const router = new RouterEngine({ hash: universe.hash }); @@ -186,7 +187,7 @@ ROUTING_UNIVERSES.forEach(universe => { expect(basePath).toBe('/abc'); }); }); - + describe('url', () => { test("Should return the current URL.", () => { // Arrange. @@ -200,7 +201,7 @@ ROUTING_UNIVERSES.forEach(universe => { expect(url).toBe(location.url); }); }); - + describe('state', () => { test("Should return the current state for the routing universe", () => { // Arrange. @@ -226,7 +227,7 @@ ROUTING_UNIVERSES.forEach(universe => { expect(router.state).toBe(expectedState); }); }); - + describe('routes', () => { test("Should recalculate the route patterns whenever a new route is added.", () => { // Arrange. @@ -243,7 +244,7 @@ ROUTING_UNIVERSES.forEach(universe => { // Assert. expect(router[routePatternsKey]().has('route')).toBe(true); }); - + test("Should recalculate the route patterns whenever a route is removed.", () => { // Arrange. const router = new RouterEngine({ hash: universe.hash }); @@ -260,7 +261,7 @@ ROUTING_UNIVERSES.forEach(universe => { // Assert. expect(router[routePatternsKey]().has('route')).toBe(false); }); - + test("Should recalculate the route patterns whenever a route is updated.", () => { // Arrange. const router = new RouterEngine({ hash: universe.hash }); @@ -278,7 +279,7 @@ ROUTING_UNIVERSES.forEach(universe => { expect(router[routePatternsKey]().has('route')).toBe(true); expect(router[routePatternsKey]().get('route')!.regex!.test('/other')).toBe(true); }); - + describe('Route Patterns', () => { test.each( [ @@ -413,7 +414,7 @@ ROUTING_UNIVERSES.forEach(universe => { } } }); - + test.each([ { pattern: '/path', @@ -462,7 +463,7 @@ ROUTING_UNIVERSES.forEach(universe => { // Assert. expect(matches).toBeNull(); }); - + test.each([ { pattern: '/:one?', @@ -585,7 +586,7 @@ ROUTING_UNIVERSES.forEach(universe => { }); }); }); - + describe('noMatches', () => { test("Should be true whenever there are no routes registered.", () => { // Act. @@ -594,10 +595,12 @@ ROUTING_UNIVERSES.forEach(universe => { // Assert. expect(router.noMatches).toBe(true); }); - + test("Should be true whenever there are no matching routes.", () => { - // Act. + // Arrange. const router = new RouterEngine({ hash: universe.hash }); + + // Act. router.routes['route'] = { pattern: '/:one/:two?', caseSensitive: false, @@ -606,7 +609,7 @@ ROUTING_UNIVERSES.forEach(universe => { // Assert. expect(router.noMatches).toBe(true); }); - + test.each([ { text: "is", @@ -627,26 +630,26 @@ ROUTING_UNIVERSES.forEach(universe => { // Arrange. const router = new RouterEngine({ hash: universe.hash }); const nonMatchingCount = totalRoutes - routeCount; - + // Act. addRoutes(router, { matching: routeCount, nonMatching: nonMatchingCount }); // Assert. expect(router.noMatches).toBe(false); }); - + test.each([ 1, 2, 5 ])("Should be true whenever the %d matching route(s) are ignored for fallback.", (routeCount) => { // Arrange. const router = new RouterEngine({ hash: universe.hash }); - + // Act. - addRoutes(router, { - matching: { - count: routeCount, - specs: { ignoreForFallback: true } - } + addRoutes(router, { + matching: { + count: routeCount, + specs: { ignoreForFallback: true } + } }); // Assert. diff --git a/src/lib/core/RouterEngine.svelte.ts b/src/lib/core/RouterEngine.svelte.ts index 61bf9e9..b33acc8 100644 --- a/src/lib/core/RouterEngine.svelte.ts +++ b/src/lib/core/RouterEngine.svelte.ts @@ -112,13 +112,13 @@ export class RouterEngine { * * @default {} */ - #routes = $state({}) as Record; + #routes = $state>({}); /** * Gets or sets the base path of the router. This is the part of the URL that is ignored when matching routes. * * @default '/' */ - #basePath = $state('/') as string; + #basePath = $state('/'); /** * Calculates the route patterns to be used for matching the current URL. * diff --git a/src/lib/core/StockHistoryApi.svelte.test.ts b/src/lib/core/StockHistoryApi.svelte.test.ts new file mode 100644 index 0000000..0105270 --- /dev/null +++ b/src/lib/core/StockHistoryApi.svelte.test.ts @@ -0,0 +1,427 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from "vitest"; +import { StockHistoryApi } from "./StockHistoryApi.svelte.js"; +import { setupBrowserMocks } from "../../testing/test-utils.js"; + +describe("StockHistoryApi", () => { + const initialUrl = "http://example.com/"; + let historyApi: StockHistoryApi; + let browserMocks: ReturnType; + + beforeEach(() => { + browserMocks = setupBrowserMocks(initialUrl); + historyApi = new StockHistoryApi(); + }); + + afterEach(() => { + historyApi.dispose(); + browserMocks.cleanup(); + }); + + describe("constructor", () => { + test("Should create a new instance with the expected default values.", () => { + // Assert. + expect(historyApi.url.href).toBe(initialUrl); + expect(historyApi.state).toEqual({ + path: undefined, + hash: {} + }); + }); + + test("Should accept initial URL and state parameters.", () => { + // Arrange. + const customUrl = "http://example.com/custom"; + const customState = { + path: { custom: "value" }, + hash: { single: { data: "test" } } + }; + + // Act. + const customHistoryApi = new StockHistoryApi(customUrl, customState); + + // Assert. + expect(customHistoryApi.url.href).toBe(customUrl); + expect(customHistoryApi.state).toEqual(customState); + + // Cleanup. + customHistoryApi.dispose(); + }); + + test("Should set up event listeners for popstate and hashchange when window is available.", () => { + // Arrange. + const addEventListenerSpy = vi.spyOn(globalThis.window, 'addEventListener'); + + // Act. + const testApi = new StockHistoryApi(); + + // Assert. + expect(addEventListenerSpy).toHaveBeenCalledWith('popstate', expect.any(Function), expect.any(Object)); + expect(addEventListenerSpy).toHaveBeenCalledWith('hashchange', expect.any(Function), expect.any(Object)); + + // Cleanup. + testApi.dispose(); + addEventListenerSpy.mockRestore(); + }); + }); + + describe("Browser History API delegation", () => { + test("Should delegate length property to window.history.length.", () => { + // Arrange. + const mockLength = 5; + Object.defineProperty(globalThis.window.history, 'length', { + value: mockLength, + configurable: true + }); + + // Assert. + expect(historyApi.length).toBe(mockLength); + }); + + test("Should delegate scrollRestoration getter to window.history.scrollRestoration.", () => { + // Arrange. + globalThis.window.history.scrollRestoration = 'manual'; + + // Assert. + expect(historyApi.scrollRestoration).toBe('manual'); + }); + + test("Should delegate scrollRestoration setter to window.history.scrollRestoration.", () => { + // Act. + historyApi.scrollRestoration = 'manual'; + + // Assert. + expect(globalThis.window.history.scrollRestoration).toBe('manual'); + }); + + test("Should delegate back() to window.history.back().", () => { + // Arrange. + const backSpy = vi.spyOn(globalThis.window.history, 'back'); + + // Act. + historyApi.back(); + + // Assert. + expect(backSpy).toHaveBeenCalled(); + }); + + test("Should delegate forward() to window.history.forward().", () => { + // Arrange. + const forwardSpy = vi.spyOn(globalThis.window.history, 'forward'); + + // Act. + historyApi.forward(); + + // Assert. + expect(forwardSpy).toHaveBeenCalled(); + }); + + test("Should delegate go() to window.history.go().", () => { + // Arrange. + const goSpy = vi.spyOn(globalThis.window.history, 'go'); + const delta = 2; + + // Act. + historyApi.go(delta); + + // Assert. + expect(goSpy).toHaveBeenCalledWith(delta); + }); + }); + + describe("pushState", () => { + test("Should call window.history.pushState with the provided state.", () => { + // Arrange. + const pushStateSpy = vi.spyOn(globalThis.window.history, 'pushState'); + const testState = { path: { custom: "data" }, hash: {} }; + const testUrl = "/new/path"; + + // Act. + historyApi.pushState(testState, "", testUrl); + + // Assert. + expect(pushStateSpy).toHaveBeenCalledWith( + testState, + "", + testUrl + ); + }); + + test("Should update internal URL and state after pushState.", () => { + // Arrange. + const testState = { path: { custom: "data" }, hash: {} }; + const testUrl = "http://example.com/new/path"; + + // Act. + historyApi.pushState(testState, "", testUrl); + + // Assert. + expect(historyApi.url.href).toBe(testUrl); + expect(historyApi.state).toEqual(testState); + }); + }); + + describe("replaceState", () => { + test("Should call window.history.replaceState with the provided state.", () => { + // Arrange. + const replaceStateSpy = vi.spyOn(globalThis.window.history, 'replaceState'); + const testState = { path: { custom: "data" }, hash: {} }; + const testUrl = "/replaced/path"; + + // Act. + historyApi.replaceState(testState, "", testUrl); + + // Assert. + expect(replaceStateSpy).toHaveBeenCalledWith( + testState, + "", + testUrl + ); + }); + + test("Should update internal URL and state after replaceState.", () => { + // Arrange. + const testState = { path: { custom: "data" }, hash: {} }; + const testUrl = "http://example.com/replaced/path"; + + // Act. + historyApi.replaceState(testState, "", testUrl); + + // Assert. + expect(historyApi.url.href).toBe(testUrl); + expect(historyApi.state).toEqual(testState); + }); + }); + + describe("Event handling", () => { + test("Should update URL and state when popstate event occurs.", () => { + // Arrange. + const newUrl = "http://example.com/popstate-test"; + const newState = { + path: { popped: "data" }, + hash: { single: { test: "value" } } + }; + + // Act. + browserMocks.simulateHistoryChange(newState, newUrl); + + // Assert. + expect(historyApi.url.href).toBe(newUrl); + expect(historyApi.state).toEqual(newState); + }); + + test("Should preserve state when popstate event has null state.", () => { + // Arrange. + const initialState = { + path: { preserved: "data" }, + hash: { single: { preserved: "hash-data" } } + }; + historyApi.replaceState(initialState.path, "", historyApi.url.href); + + // Set hash state separately + const newState = { ...initialState }; + historyApi['state'] = newState; + + const newUrl = "http://example.com/null-state-test"; + + // Act. + browserMocks.simulateHistoryChange(null, newUrl); + + // Assert. + expect(historyApi.url.href).toBe(newUrl); + expect(historyApi.state.path).toEqual(initialState.path); + expect(historyApi.state.hash).toEqual(initialState.hash); + }); + + test("Should handle hashchange event by updating URL and clearing hash state.", () => { + // Arrange. + const initialState = { + path: { preserved: "path-data" }, + hash: { single: { cleared: "will-be-cleared" } } + }; + historyApi['state'] = initialState; + + const newUrl = "http://example.com/test#newhash"; + const replaceStateSpy = vi.spyOn(globalThis.window.history, 'replaceState'); + + // Act. + browserMocks.setUrl(newUrl); + globalThis.window.dispatchEvent(new HashChangeEvent('hashchange')); + + // Assert. + expect(historyApi.url.href).toBe(newUrl); + expect(historyApi.state).toEqual({ + path: initialState.path, + hash: {} + }); + expect(replaceStateSpy).toHaveBeenCalledWith( + { path: initialState.path, hash: {} }, + '', + newUrl + ); + }); + }); + + describe("dispose", () => { + test("Should remove event listeners when disposed.", () => { + // Arrange. + const removeEventListenerSpy = vi.spyOn(globalThis.window, 'removeEventListener'); + + // Act. + historyApi.dispose(); + + // Assert. + expect(removeEventListenerSpy).toHaveBeenCalledWith('popstate', expect.any(Function), expect.any(Object)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('hashchange', expect.any(Function), expect.any(Object)); + }); + + test("Should not throw when disposed multiple times.", () => { + // Act & Assert. + expect(() => { + historyApi.dispose(); + historyApi.dispose(); + }).not.toThrow(); + }); + }); + + describe("State normalization", () => { + test("Should handle invalid state by normalizing to default state.", () => { + // Arrange. + const pushStateSpy = vi.spyOn(globalThis.window.history, 'pushState'); + const invalidState = "not a valid state"; + + // Act. + historyApi.pushState(invalidState, "", "/test"); + + // Assert. + expect(pushStateSpy).toHaveBeenCalledWith( + { path: undefined, hash: {} }, + "", + "/test" + ); + }); + + test("Should handle URL objects in pushState.", () => { + // Arrange. + const pushStateSpy = vi.spyOn(globalThis.window.history, 'pushState'); + const testState = { path: { test: "data" }, hash: {} }; + const urlObject = new URL("http://example.com/url-object"); + + // Act. + historyApi.pushState(testState, "", urlObject); + + // Assert. + expect(pushStateSpy).toHaveBeenCalledWith( + testState, + "", + urlObject + ); + expect(historyApi.url.href).toBe(urlObject.href); + }); + }); + + describe("Error handling", () => { + test("Should handle missing window gracefully.", () => { + // Arrange. + const originalWindow = globalThis.window; + // @ts-ignore + delete globalThis.window; + + // Act & Assert. + expect(() => { + const testApi = new StockHistoryApi(); + expect(testApi.length).toBe(0); + expect(testApi.scrollRestoration).toBe('auto'); + testApi.scrollRestoration = 'manual'; + testApi.back(); + testApi.forward(); + testApi.go(1); + testApi.pushState({}, "", "/test"); + testApi.replaceState({}, "", "/test"); + testApi.dispose(); + }).not.toThrow(); + + // Restore. + globalThis.window = originalWindow; + }); + }); + describe("Event Synchronization", () => { + test("Should preserve history state when popstate is triggered carrying a non-conformant state value.", () => { + // Arrange. + const initialState = { + path: { preserved: "path-data" }, + hash: { + single: { preserved: "hash-data" }, + multi1: { preserved: "multi-data" } + } + }; + browserMocks.simulateHistoryChange(initialState); + + // Act. + browserMocks.triggerPopstate({}); + + // Assert - LocationLite should reflect the state preserved by its HistoryApi + expect(historyApi.state).toEqual(initialState); + }); + + test("Should reflect HistoryApi hash state clearing on hashchange.", () => { + // Arrange. + const initialState = { + path: { preserved: "path-data" }, + hash: { + single: { preserved: "hash-data" }, + multi1: { preserved: "multi-data" } + } + }; + browserMocks.simulateHistoryChange(initialState); + + // Act. + browserMocks.triggerHashChange(); + + // Assert - LocationLite should reflect hash state clearing by HistoryApi + expect(historyApi.state).toEqual({ + ...initialState, + hash: {} + }); + }); + + test("Should reflect HistoryApi state updates from browser events.", () => { + // Arrange - Set up initial state + const initialState = { + path: { initial: "data" }, + hash: { single: { initial: "hash" } } + }; + browserMocks.simulateHistoryChange(initialState); + + // Act - Trigger popstate with new state + const newState = { + path: { updated: "data" }, + hash: { single: { updated: "hash" } } + }; + const newUrl = "http://example.com/updated"; + browserMocks.simulateHistoryChange(newState, newUrl); + + // Assert - LocationLite should reflect HistoryApi state changes + expect(historyApi.url.href).toBe(newUrl); + expect(historyApi.state).toEqual(newState); + }); + + test.each([ + undefined, + null + ])("Should reflect HistoryApi state preservation when history.state is %s.", (stateValue) => { + // Arrange - Set up state through LocationLite + const initialState = { + path: { preserved: "path" }, + hash: { single: { preserved: "single" } } + }; + browserMocks.simulateHistoryChange(initialState); + + // Act - Simulate browser event with undefined state + browserMocks.history.state = undefined; + const event = new PopStateEvent('popstate', { state: stateValue }); + browserMocks.window.dispatchEvent(event); + + // Assert - LocationLite should reflect preserved state from HistoryApi + expect(historyApi.state).toEqual(initialState); + }); + }); +}); diff --git a/src/lib/core/StockHistoryApi.svelte.ts b/src/lib/core/StockHistoryApi.svelte.ts new file mode 100644 index 0000000..1dc52e0 --- /dev/null +++ b/src/lib/core/StockHistoryApi.svelte.ts @@ -0,0 +1,88 @@ +import { on } from "svelte/events"; +import type { HistoryApi, State } from "$lib/types.js"; +import { LocationState } from "./LocationState.svelte.js"; + +/** + * Standard implementation of HistoryApi that uses the browser's native History API + * and window.location. This is the default implementation used in normal browser environments. + */ +export class StockHistoryApi extends LocationState implements HistoryApi { + #cleanupFunctions: (() => void)[] = []; + + constructor(initialUrl?: string, initialState?: State) { + super(initialUrl, initialState); + if (typeof globalThis.window !== 'undefined') { + this.#cleanupFunctions.push( + on(globalThis.window, 'popstate', this.#handlePopstateEvent), + on(globalThis.window, 'hashchange', this.#handleHashChangeEvent) + ); + } + } + + #handlePopstateEvent = (event: PopStateEvent): void => { + this.url.href = globalThis.window.location.href; + this.state = this.normalizeState(event.state, this.state); + } + + #handleHashChangeEvent = (event: HashChangeEvent): void => { + this.url.href = globalThis.window.location.href; + this.state = { + path: this.state.path, + hash: {} + }; + // Synchronize the environment's history state with a replace call. + globalThis.window.history.replaceState($state.snapshot(this.state), '', this.url.href); + } + + // History API implementation + get length(): number { + return globalThis.window?.history?.length ?? 0; + } + + get scrollRestoration(): ScrollRestoration { + return globalThis.window?.history?.scrollRestoration ?? 'auto'; + } + + set scrollRestoration(value: ScrollRestoration) { + if (globalThis.window?.history) { + globalThis.window.history.scrollRestoration = value; + } + } + + back(): void { + globalThis.window?.history?.back(); + } + + forward(): void { + globalThis.window?.history?.forward(); + } + + go(delta?: number): void { + globalThis.window?.history?.go(delta); + } + + #updateHistory( + historyMethod: 'replaceState' | 'pushState', + data: any, + unused: string, + url?: string | URL | null + ): void { + const normalizedState = this.normalizeState(data); + globalThis.window?.history[historyMethod](normalizedState, unused, url); + this.url.href = globalThis.window?.location?.href ?? new URL(url ?? '', this.url).href; + this.state = normalizedState; + } + + pushState(data: any, unused: string, url?: string | URL | null): void { + this.#updateHistory('pushState', data, unused, url); + } + + replaceState(data: any, unused: string, url?: string | URL | null): void { + this.#updateHistory('replaceState', data, unused, url); + } + + dispose(): void { + this.#cleanupFunctions.forEach(cleanup => cleanup()); + this.#cleanupFunctions = []; + } +} \ No newline at end of file diff --git a/src/lib/core/index.test.ts b/src/lib/core/index.test.ts index 7315e1b..d7245cc 100644 --- a/src/lib/core/index.test.ts +++ b/src/lib/core/index.test.ts @@ -10,6 +10,11 @@ describe('index', () => { 'isConformantState', 'calculateHref', 'calculateState', + 'LocationState', + 'StockHistoryApi', + 'InterceptedHistoryApi', + 'LocationLite', + 'LocationFull', ]; // Act. diff --git a/src/lib/core/index.ts b/src/lib/core/index.ts index f1b465b..3eb9202 100644 --- a/src/lib/core/index.ts +++ b/src/lib/core/index.ts @@ -3,3 +3,8 @@ export { RouterEngine, joinPaths } from "./RouterEngine.svelte.js"; export { isConformantState } from "./isConformantState.js"; export { calculateHref } from "./calculateHref.js"; export { calculateState } from "./calculateState.js"; +export { LocationState } from "./LocationState.svelte.js"; +export { StockHistoryApi } from "./StockHistoryApi.svelte.js"; +export { InterceptedHistoryApi } from "./InterceptedHistoryApi.svelte.js"; +export { LocationLite } from "./LocationLite.svelte.js"; +export { LocationFull } from "./LocationFull.js"; diff --git a/src/lib/core/options.ts b/src/lib/core/options.ts index 7bffe47..31974d6 100644 --- a/src/lib/core/options.ts +++ b/src/lib/core/options.ts @@ -13,6 +13,16 @@ const defaultRoutingOptions: Required = { */ export const routingOptions: Required = structuredClone(defaultRoutingOptions); +/** + * Sets routing options, merging with current values. + * This function is useful for extension packages that need to configure routing options. + * + * @param options Partial routing options to set + */ +export function setRoutingOptions(options: Partial): void { + Object.assign(routingOptions, options); +} + /** * Resets routing options to their default values. */ diff --git a/src/lib/index.test.ts b/src/lib/index.test.ts index b39f89e..8444ec7 100644 --- a/src/lib/index.test.ts +++ b/src/lib/index.test.ts @@ -13,6 +13,7 @@ describe('index', () => { 'Fallback', 'location', 'RouterTrace', + 'initCore', 'init', 'initFull', 'getRouterContext', diff --git a/src/lib/init.ts b/src/lib/init.ts index 708ea99..e7faffb 100644 --- a/src/lib/init.ts +++ b/src/lib/init.ts @@ -2,15 +2,28 @@ import { setLocation } from "./core/Location.js"; import { LocationFull } from "./core/LocationFull.js"; import { LocationLite } from "./core/LocationLite.svelte.js"; import { resetLogger, setLogger } from "./core/Logger.js"; -import { resetRoutingOptions, routingOptions } from "./core/options.js"; +import { resetRoutingOptions, routingOptions, setRoutingOptions } from "./core/options.js"; import { resetTraceOptions, setTraceOptions } from "./core/trace.svelte.js"; import type { InitOptions, Location } from "./types.js"; -function initInternal(options: InitOptions, location: Location) { +/** + * Core initialization function used by both the main package and extension packages. + * This ensures consistent initialization logic across different environments. + * + * Extension packages can use this function to provide their own Location implementations + * (e.g., SvelteKit-compatible implementations) while maintaining consistent setup. + * + * @param options Initialization options for the routing library + * @param location The Location implementation to use + * @returns A cleanup function that reverts the initialization process + */ +export function initCore(options: InitOptions, location: Location) { setTraceOptions(options?.trace); setLogger(options?.logger ?? true); - routingOptions.hashMode = options?.hashMode ?? routingOptions.hashMode; - routingOptions.implicitMode = options?.implicitMode ?? routingOptions.implicitMode; + setRoutingOptions({ + hashMode: options?.hashMode, + implicitMode: options?.implicitMode + }); const newLocation = setLocation(location); return () => { newLocation?.dispose(); @@ -40,7 +53,7 @@ function initInternal(options: InitOptions, location: Location) { * @returns A cleanup function that reverts the initialization process. */ export function init(options?: InitOptions) { - return initInternal(options ?? {}, new LocationLite()); + return initCore(options ?? {}, new LocationLite()); } /** @@ -52,5 +65,5 @@ export function init(options?: InitOptions) { * @returns A cleanup function that reverts the initialization process. */ export function initFull(options?: InitOptions) { - return initInternal(options ?? {}, new LocationFull()); + return initCore(options ?? {}, new LocationFull()); } diff --git a/src/lib/types.ts b/src/lib/types.ts index f1560eb..1fbdeb6 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -200,6 +200,21 @@ export interface Location { * @param options Options for navigation. */ navigate(url: string, options?: NavigateOptions): void; + /** + * Moves back one entry in the history stack. + * This is equivalent to calling `history.back()` but uses the configured HistoryApi implementation. + */ + back(): void; + /** + * Moves forward one entry in the history stack. + * This is equivalent to calling `history.forward()` but uses the configured HistoryApi implementation. + */ + forward(): void; + /** + * Moves to a specific entry in the history stack by its relative position. + * @param delta The number of entries to move. Negative values go back, positive values go forward. + */ + go(delta: number): void; /** * Disposes of the location object, cleaning up any resources. */ @@ -419,3 +434,36 @@ export type InitOptions = RoutingOptions & { */ logger?: boolean | ILogger; } + +/** + * Defines an abstraction over the browser's History API that provides consistent navigation + * and state management across different environments (browser, SvelteKit, memory-only, etc.). + * + * This interface extends the standard History API with a reactive URL tracking capability + * needed for the routing library. + */ +export interface HistoryApi extends History { + /** + * Reactive URL object that reflects the current location. + * Implementations should ensure this stays synchronized with navigation changes. + */ + readonly url: SvelteURL; + + /** + * Cleans up event listeners and resources used by the HistoryApi implementation. + * Should be called when the implementation is no longer needed to prevent memory leaks. + */ + dispose(): void; +} + +/** + * Extended HistoryApi interface for full-mode routing that supports navigation events. + * Used by LocationFull to provide beforeNavigate and navigationCancelled event capabilities. + */ +export interface FullModeHistoryApi extends HistoryApi { + /** + * Subscribe to navigation events. + */ + on(event: 'beforeNavigate', callback: (event: BeforeNavigateEvent) => void): () => void; + on(event: 'navigationCancelled', callback: (event: NavigationCancelledEvent) => void): () => void; +} diff --git a/src/testing/test-utils.ts b/src/testing/test-utils.ts index b4ba54b..191b66b 100644 --- a/src/testing/test-utils.ts +++ b/src/testing/test-utils.ts @@ -6,15 +6,26 @@ import { resolveHashValue } from "$lib/core/resolveHashValue.js"; import { vi } from "vitest"; /** - * Standard routing universe test configurations + * Defines the necessary information to call the library's `init()` function for testing, plus additional metadata. */ -export const ROUTING_UNIVERSES: { +export type RoutingUniverse = { hash: Hash | undefined; implicitMode: RoutingOptions['implicitMode']; hashMode: Exclude; + /** + * Short universe identifier. Used in test titles and descriptions. + */ text: string; + /** + * Descriptive universe name. More of a document-by-code property. Not commonly used as it makes text very long. + */ name: string; -}[] = [ +}; + +/** + * Standard routing universe test configurations + */ +export const ROUTING_UNIVERSES: RoutingUniverse[] = [ { hash: undefined, implicitMode: 'path', hashMode: 'single', text: "IMP", name: "Implicit Path Routing" }, { hash: undefined, implicitMode: 'hash', hashMode: 'single', text: "IMH", name: "Implicit Hash Routing" }, { hash: false, implicitMode: 'path', hashMode: 'single', text: "PR", name: "Path Routing" }, @@ -279,9 +290,6 @@ export function setupBrowserMocks(initialUrl = "http://example.com/", libraryLoc if (libraryLocation) { libraryLocation.url.href = url; } - // Trigger popstate to notify location service (simulates natural browser behavior) - const event = new PopStateEvent('popstate', { state: windowMock.history.state }); - windowMock.dispatchEvent(event); }, setState: (state: any) => { @@ -292,7 +300,12 @@ export function setupBrowserMocks(initialUrl = "http://example.com/", libraryLoc const event = new PopStateEvent('popstate', { state: state ?? windowMock.history.state }); windowMock.dispatchEvent(event); }, - + + triggerHashChange: () => { + const event = new HashChangeEvent('hashchange'); + windowMock.dispatchEvent(event); + }, + // For tests that need to simulate external history changes simulateHistoryChange: (state: any, url?: string) => { if (url) {