From 5e273374abbf1cdfa413ad244d1c3f3c7d601945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ramirez=20Vargas=2C=20Jos=C3=A9=20Pablo?= Date: Thu, 6 Mar 2025 20:27:51 -0600 Subject: [PATCH] feat!: Isolate state data BREAKING CHANGE --- src/lib/Fallback/Fallback.svelte | 10 +- src/lib/Fallback/README.md | 2 +- src/lib/Link/Link.svelte | 13 ++- src/lib/Link/README.md | 2 +- src/lib/Route/README.md | 2 +- src/lib/Route/Route.svelte | 7 +- src/lib/Router/README.md | 2 +- src/lib/Router/Router.svelte | 8 +- src/lib/core/LocationFull.svelte.test.ts | 21 ++-- src/lib/core/LocationFull.ts | 2 +- src/lib/core/LocationLite.svelte.test.ts | 125 +++++++++++++++++++---- src/lib/core/LocationLite.svelte.ts | 41 ++++++-- src/lib/core/LocationState.svelte.ts | 3 +- src/lib/core/LocationState.test.ts | 14 +++ src/lib/core/RouterEngine.svelte.test.ts | 76 ++++++++++++-- src/lib/core/RouterEngine.svelte.ts | 25 +++-- src/lib/core/index.test.ts | 1 + src/lib/core/index.ts | 1 + src/lib/core/isConformantState.ts | 17 +++ src/lib/types.ts | 34 +++++- 20 files changed, 332 insertions(+), 74 deletions(-) create mode 100644 src/lib/core/LocationState.test.ts create mode 100644 src/lib/core/isConformantState.ts diff --git a/src/lib/Fallback/Fallback.svelte b/src/lib/Fallback/Fallback.svelte index 5775a5c..324eb21 100644 --- a/src/lib/Fallback/Fallback.svelte +++ b/src/lib/Fallback/Fallback.svelte @@ -1,7 +1,8 @@ {#if router?.noMatches} - {@render children?.()} + {@render children?.(router.state, router.routeStatus)} {/if} diff --git a/src/lib/Fallback/README.md b/src/lib/Fallback/README.md index 49caeca..a579995 100644 --- a/src/lib/Fallback/README.md +++ b/src/lib/Fallback/README.md @@ -11,7 +11,7 @@ route status data is calculated. | Property | Type | Default Value | Bindable | Description | |-|-|-|-|-| | `hash` | `boolean \| string` | `undefined` | | Sets the hash mode of the component. | -| `children` | `Snippet` | `undefined` | | Renders the children of the component. | +| `children` | `Snippet<[any, Record]>` | `undefined` | | Renders the children of the component. | [Online Documentation](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/components/fallback) diff --git a/src/lib/Link/Link.svelte b/src/lib/Link/Link.svelte index 97bdb5b..0b7c4cb 100644 --- a/src/lib/Link/Link.svelte +++ b/src/lib/Link/Link.svelte @@ -3,7 +3,7 @@ import { joinPaths, resolveHashValue } from '$lib/core/RouterEngine.svelte.js'; import { getLinkContext, type ILinkContext } from '$lib/LinkContext/LinkContext.svelte'; import { getRouterContext } from '$lib/Router/Router.svelte'; - import type { ActiveState } from '$lib/types.js'; + import type { ActiveState, RouteStatus } from '$lib/types.js'; import { type Snippet } from 'svelte'; import type { HTMLAnchorAttributes } from 'svelte/elements'; @@ -59,8 +59,12 @@ activeState?: ActiveState; /** * Renders the children of the component. + * @param state The state object stored in in the window's History API for the universe the link is + * associated to. + * @param routeStatus The router's route status data, if the `Link` component is within the context of a + * router. */ - children?: Snippet; + children?: Snippet<[any, Record | undefined]>; }; let { @@ -77,7 +81,8 @@ ...restProps }: Props = $props(); - const router = getRouterContext(resolveHashValue(hash)); + const resolvedHash = resolveHashValue(hash); + const router = getRouterContext(resolvedHash); const linkContext = getLinkContext(); const calcReplace = $derived(replace ?? linkContext?.replace ?? false); @@ -152,5 +157,5 @@ onclick={handleClick} {...restProps} > - {@render children?.()} + {@render children?.(location.getState(resolvedHash), router?.routeStatus)} diff --git a/src/lib/Link/README.md b/src/lib/Link/README.md index c270a4f..16fd5e0 100644 --- a/src/lib/Link/README.md +++ b/src/lib/Link/README.md @@ -14,7 +14,7 @@ SPA-friendly navigation (navigation without reloading). | `activeState` | `ActiveState` | `undefined` | | Sets the various options that are used to automatically style the anchor tag whenever a particular route becomes active. | | `prependBasePath` | `boolean` | `false` | | Configures the component to prepend the parent router's base path to the `href` property. | | `preserveQuery` | `boolean \| string \| string[]` | `false` | | Configures the component to preserve the query string whenever it triggers navigation. | -| `children` | `Snippet` | `undefined` | | Renders the children of the component. | +| `children` | `Snippet<[any, Record \| undefined]>` | `undefined` | | Renders the children of the component. | [Online Documentation](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/components/link) diff --git a/src/lib/Route/README.md b/src/lib/Route/README.md index 536c4f1..cbf7bd3 100644 --- a/src/lib/Route/README.md +++ b/src/lib/Route/README.md @@ -17,7 +17,7 @@ they can be embedded anywhere down the hierarchy, including being children of ot | `caseSensitive` | `boolean` | `false` | | Sets whether the route's path pattern should be matched case-sensitively. | | `hash` | `boolean \| string` | `undefined` | | Sets the hash mode of the route. | | `params` | `Record, ParameterValue>` | `undefined` | Yes | Provides a way to obtain a route's parameters through property binding. | -| `children` | `Snippet<[Record, ParameterValue> \| undefined]>` | `undefined` | | Renders the children of the route. | +| `children` | `Snippet<[Record, ParameterValue> \| undefined, any, Record]>` | `undefined` | | Renders the children of the route. | [Online Documentation](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/components/route) diff --git a/src/lib/Route/Route.svelte b/src/lib/Route/Route.svelte index 8118b45..796d06c 100644 --- a/src/lib/Route/Route.svelte +++ b/src/lib/Route/Route.svelte @@ -135,8 +135,11 @@ /** * Renders the children of the route. * @param params The route's parameters. + * @param state The state object stored in in the window's History API for the universe the route is associated + * to. + * @param routeStatus The router's route status object. */ - children?: Snippet<[Record, ParameterValue> | undefined]>; + children?: Snippet<[Record, ParameterValue> | undefined, any, Record]>; }; let { @@ -184,5 +187,5 @@ {#if (router.routeStatus[key]?.match ?? true) && (untrack(() => router.routes)[key]?.when?.(router.routeStatus) ?? true)} - {@render children?.(params)} + {@render children?.(params, router.state, router.routeStatus)} {/if} diff --git a/src/lib/Router/README.md b/src/lib/Router/README.md index c2de60a..b8e86db 100644 --- a/src/lib/Router/README.md +++ b/src/lib/Router/README.md @@ -11,7 +11,7 @@ children via context. | `basePath` | `string` | `'/'` | | Sets the router's base path, which is a segment of the URL that is implicitly added to all routes. | | `id` | `string` | `undefined` | | Gives the router an identifier that shows up in `RouterTrace` components. | | `hash` | `boolean \| string` | `undefined` | | Sets the hash mode of the router. | -| `children` | `Snippet` | `undefined` | | Renders the children of the router. | +| `children` | `Snippet<[any, Record]>` | `undefined` | | Renders the children of the router. | [Online Documentation](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/components/router) diff --git a/src/lib/Router/Router.svelte b/src/lib/Router/Router.svelte index bdf7256..1dfb322 100644 --- a/src/lib/Router/Router.svelte +++ b/src/lib/Router/Router.svelte @@ -1,5 +1,6 @@ -{@render children?.()} +{@render children?.(router.state, router.routeStatus)} diff --git a/src/lib/core/LocationFull.svelte.test.ts b/src/lib/core/LocationFull.svelte.test.ts index aae0b90..38334e8 100644 --- a/src/lib/core/LocationFull.svelte.test.ts +++ b/src/lib/core/LocationFull.svelte.test.ts @@ -1,16 +1,19 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { LocationFull } from "./LocationFull.js"; -import type { Events, Location } from "$lib/types.js"; +import type { State, Location } from "$lib/types.js"; import { flushSync } from "svelte"; +import { joinPaths } from "./RouterEngine.svelte.js"; describe("LocationFull", () => { const initialUrl = "http://example.com/"; - let interceptedState: any = null; + let interceptedState: State; const pushStateMock = vi.fn((state, _, url) => { + url = !url.startsWith('http://') ? joinPaths(initialUrl, url) : url; globalThis.window.location.href = new URL(url).href; interceptedState = state; }); const replaceStateMock = vi.fn((state, _, url) => { + url = !url.startsWith('http://') ? joinPaths(initialUrl, url) : url; globalThis.window.location.href = new URL(url).href; interceptedState = state; }); @@ -37,6 +40,7 @@ describe("LocationFull", () => { }); beforeEach(() => { globalThis.window.location.href = initialUrl; + interceptedState = { path: undefined, hash: {} }; pushStateMock.mockReset(); replaceStateMock.mockReset(); location = new LocationFull(); @@ -48,7 +52,6 @@ describe("LocationFull", () => { test("Should create a new instance with the expected default values.", () => { // Assert. expect(location.url.href).toBe(initialUrl); - expect(location.state).toBe(null); }); }); describe('on', () => { @@ -171,7 +174,7 @@ describe("LocationFull", () => { ] 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); flushSync(); @@ -180,20 +183,22 @@ describe("LocationFull", () => { expect(location.url.href).toBe(newUrl); }); }); - describe('state', () => { + describe('getState', () => { test.each([ 'pushState', 'replaceState', ] satisfies (keyof History)[])("Should update whenever an external call to %s is made.", (fn) => { // Arrange. - const state = { test: 'value' }; - + const state: State = { path: { test: 'value' }, hash: { single: '/abc', p1: '/def' } }; + // Act. globalThis.window.history[fn](state, '', 'http://example.com/new'); flushSync(); // Assert. - expect(location.state).toBe(state); + expect(location.getState(false)).toEqual(state.path); + expect(location.getState(true)).toEqual(state.hash.single); + expect(location.getState('p1')).toEqual(state.hash.p1); }); }); }); \ No newline at end of file diff --git a/src/lib/core/LocationFull.ts b/src/lib/core/LocationFull.ts index 4e94688..d3713c3 100644 --- a/src/lib/core/LocationFull.ts +++ b/src/lib/core/LocationFull.ts @@ -55,7 +55,7 @@ export class LocationFull extends LocationLite { } } else { const navFn = method === 'push' ? this.#originalPushState : this.#originalReplaceState; - navFn(state, '', url); + navFn(event.state, '', url); this.url.href = globalThis.window?.location.href; this.#innerState.state = state; } diff --git a/src/lib/core/LocationLite.svelte.test.ts b/src/lib/core/LocationLite.svelte.test.ts index 6804001..d68f61f 100644 --- a/src/lib/core/LocationLite.svelte.test.ts +++ b/src/lib/core/LocationLite.svelte.test.ts @@ -1,16 +1,20 @@ import { describe, test, expect, beforeEach, beforeAll, vi, afterEach } from "vitest"; import { LocationLite } from "./LocationLite.svelte.js"; import { LocationState } from "./LocationState.svelte.js"; -import type { Location } from "$lib/types.js"; +import type { Hash, Location, State } from "$lib/types.js"; +import { joinPaths } from "./RouterEngine.svelte.js"; +import { init } from "$lib/index.js"; describe("LocationLite", () => { const initialUrl = "http://example.com/"; - let interceptedState: any; + let interceptedState: State; const pushStateMock = vi.fn((state, _, url) => { + url = !url.startsWith('http://') ? joinPaths(initialUrl, url) : url; globalThis.window.location.href = new URL(url).href; interceptedState = state; }); const replaceStateMock = vi.fn((state, _, url) => { + url = !url.startsWith('http://') ? joinPaths(initialUrl, url) : url; globalThis.window.location.href = new URL(url).href; interceptedState = state; }); @@ -28,13 +32,16 @@ describe("LocationLite", () => { }; // @ts-expect-error Many missing features. globalThis.window.history = { - state: null, + get state() { + return interceptedState; + }, pushState: pushStateMock, replaceState: replaceStateMock }; }) beforeEach(() => { globalThis.window.location.href = initialUrl; + interceptedState = { path: undefined, hash: {} }; pushStateMock.mockReset(); replaceStateMock.mockReset(); location = new LocationLite(); @@ -46,7 +53,6 @@ describe("LocationLite", () => { test("Should create a new instance with the expected default values.", () => { // Assert. expect(location.url.href).toBe(initialUrl); - expect(location.state).toBe(null); }); test("Should use the provided LocationState instance.", () => { // Arrange. @@ -84,18 +90,57 @@ describe("LocationLite", () => { expect(location.url.href).toBe(newUrl); }); }); - describe("state", () => { + describe("getState", () => { + test.each<{ hash: Hash; expectedState: any; }>([ + { + hash: false, + expectedState: 1, + }, + { + hash: true, + expectedState: 2, + }, + { + hash: 'abc', + expectedState: 3, + }, + ])(`Should return the state associated with the "$hash" hash value.`, ({ hash, expectedState }) => { + // Arrange. + interceptedState = { + path: 1, + hash: { + single: 2, + abc: 3 + } + }; + location = new LocationLite(); + + // Act. + const state = location.getState(hash); + + // Assert. + expect(state).toBe(expectedState); + }); test("Should update whenever a popstate event is triggered.", () => { // Arrange. - const newState = { some: "state" }; + const pathState = 1; + const singleHashState = 2; + const abcHashState = 3; + interceptedState = { + path: pathState, + hash: { + single: singleHashState, + abc: abcHashState + } + }; // Act. - // @ts-expect-error Property is read-only in the browser. - globalThis.window.history.state = newState; globalThis.window.dispatchEvent(new PopStateEvent('popstate')); // Assert. - expect(location.state).toBe(newState); + expect(location.getState(false)).toBe(pathState); + expect(location.getState(true)).toBe(singleHashState); + expect(location.getState('abc')).toBe(abcHashState); }); }); describe("navigate", () => { @@ -104,34 +149,76 @@ describe("LocationLite", () => { replace: false, expectedMethod: pushStateMock, text: 'pushState', + hash: false, }, { replace: true, expectedMethod: replaceStateMock, text: 'replaceState', - } - ])("Should call History.$text whenever the 'replace' option is $replace .", ({ replace, expectedMethod }) => { + hash: false, + }, + { + replace: false, + expectedMethod: pushStateMock, + text: 'pushState', + hash: true, + }, + { + replace: true, + expectedMethod: replaceStateMock, + text: 'replaceState', + hash: true, + }, + ])("Should call $text whenever the 'replace' option is $replace .", ({ replace, expectedMethod, hash }) => { // Arrange. - const newUrl = "http://example.com/new"; + const newUrl = (hash ? "#" : '') + "/new"; const newState = { some: "state" }; + const expectedArg = hash ? { hash: { single: newState } } : { path: newState, hash: {} }; // Act. location.navigate(newUrl, { replace, state: newState }); // Assert. - expect(expectedMethod).toHaveBeenCalledWith(newState, '', newUrl); + expect(expectedMethod).toHaveBeenCalledWith(expectedArg, '', newUrl); }); - test("Should update the URL and state properties.", () => { + test("Should trigger an update on the location's URL and state values when doing path routing navigation.", () => { // Arrange. - const newUrl = "http://example.com/new"; - const newState = { some: "state" }; + const newPath = '/new'; + const state = 123; // Act. - location.navigate(newUrl, { state: newState }); + location.navigate(newPath, { state }); // Assert. - expect(location.url.href).toBe(newUrl); - expect(location.state).toBe(newState); + expect(location.url.pathname).toBe(newPath); + expect(location.getState(false)).toBe(state); + }); + test("Should trigger an update on the location's URL and state values when doing hash routing navigation.", () => { + // Arrange. + const newPath = '#/new'; + const state = 456; + + // Act. + location.navigate(newPath, { state }); + + // Assert. + expect(location.url.hash).toBe(newPath); + expect(location.getState(true)).toBe(state); + }); + test("Should trigger an update on the location's URL and state values when doing multi hash routing navigation.", () => { + // Arrange. + const hash = 'abc'; + const newPath = '/new'; + const state = 456; + location.dispose(); + init({ hashMode: 'multi' }); + + // Act. + location.navigate(newPath, hash, { state }); + + // Assert. + expect(location.url.hash).toBe(`#${hash}=${newPath}`); + expect(location.getState(hash)).toBe(state); }); }); }); diff --git a/src/lib/core/LocationLite.svelte.ts b/src/lib/core/LocationLite.svelte.ts index d51ae38..8c3bbfc 100644 --- a/src/lib/core/LocationLite.svelte.ts +++ b/src/lib/core/LocationLite.svelte.ts @@ -1,4 +1,4 @@ -import type { BeforeNavigateEvent, Location, NavigateOptions, NavigationCancelledEvent } from "../types.js"; +import type { BeforeNavigateEvent, Hash, Location, NavigateOptions, NavigationCancelledEvent, State } from "../types.js"; import { on } from "svelte/events"; import { LocationState } from "./LocationState.svelte.js"; import { routingOptions } from "./options.js"; @@ -53,13 +53,34 @@ export class LocationLite implements Location { return this.#innerState.url; } - get state() { - return this.#innerState.state; + getState(hash: Hash) { + if (typeof hash === 'string') { + return this.#innerState.state.hash[hash]; + } + if (hash) { + return this.#innerState.state.hash.single; + } + return this.#innerState.state.path; + } + + #newState(hash: Hash, state: any) { + const newState = $state.snapshot(this.#innerState.state); + if (typeof hash === 'string') { + newState.hash[hash] = state; + } + else if (hash) { + newState.hash.single = state; + } + else { + newState.path = state; + } + return newState; } - navigate(url: string | URL, options?: NavigateOptions): void; - navigate(url: string | URL, hashId: string, options?: NavigateOptions): void; - navigate(url: string | URL, hashIdOrOptions?: string | NavigateOptions, options?: NavigateOptions) { + navigate(url: string, options?: NavigateOptions): void; + navigate(url: string, hashId: string, options?: NavigateOptions): void; + navigate(url: string, hashIdOrOptions?: string | NavigateOptions, options?: NavigateOptions) { + let newState: State; if (typeof hashIdOrOptions === 'string') { let idExists = false; let finalUrl = ''; @@ -75,15 +96,17 @@ export class LocationLite implements Location { finalUrl += `;${hashIdOrOptions}=${url}`; } url = '#' + finalUrl.substring(1); + newState = this.#newState(hashIdOrOptions, options?.state); } else { - options = hashIdOrOptions + options = hashIdOrOptions; + newState = this.#newState(url.startsWith('#'), options?.state); } (options?.replace ? globalThis.window?.history.replaceState : - globalThis.window?.history.pushState).bind(globalThis.window?.history)(options?.state, '', url); + globalThis.window?.history.pushState).bind(globalThis.window?.history)(newState, '', url); this.#innerState.url.href = globalThis.window?.location.href; - this.#innerState.state = options?.state; + this.#innerState.state = newState; } dispose() { diff --git a/src/lib/core/LocationState.svelte.ts b/src/lib/core/LocationState.svelte.ts index ce02a2d..a9ad818 100644 --- a/src/lib/core/LocationState.svelte.ts +++ b/src/lib/core/LocationState.svelte.ts @@ -1,6 +1,7 @@ +import type { State } from "$lib/types.js"; import { SvelteURL } from "svelte/reactivity"; export class LocationState { url = new SvelteURL(globalThis.window?.location?.href); - state = globalThis.window?.history?.state; + state = $state((globalThis.window?.history?.state ?? { hash: {} }) as State); } diff --git a/src/lib/core/LocationState.test.ts b/src/lib/core/LocationState.test.ts new file mode 100644 index 0000000..af0f7a4 --- /dev/null +++ b/src/lib/core/LocationState.test.ts @@ -0,0 +1,14 @@ +import { describe, test, expect } from "vitest"; +import { LocationState } from "./LocationState.svelte.js"; + +describe('LocationState', () => { + describe('constructor', () => { + test("Should create a new instance with the expected default values.", () => { + // Act. + const ls = new LocationState(); + + // Assert. + expect(ls.state).toEqual({ hash: {} }); + }); + }); +}); diff --git a/src/lib/core/RouterEngine.svelte.test.ts b/src/lib/core/RouterEngine.svelte.test.ts index 3b9e15f..c542113 100644 --- a/src/lib/core/RouterEngine.svelte.test.ts +++ b/src/lib/core/RouterEngine.svelte.test.ts @@ -1,8 +1,9 @@ import { describe, test, expect, beforeAll, afterAll, vi } from "vitest"; import { routePatternsKey, RouterEngine } from "./RouterEngine.svelte.js"; -import { init, type RouteInfo } from "$lib/index.js"; +import { init, type Hash, type RouteInfo } from "$lib/index.js"; import { registerRouter } from "./trace.svelte.js"; import { location } from "./Location.js"; +import type { State } from "$lib/types.js"; describe("RouterEngine", () => { describe('constructor', () => { @@ -45,10 +46,12 @@ describe("RouterEngine", () => { const pushStateMock = vi.fn((state, _, url) => { globalThis.window.location.href = new URL(url).href; interceptedState = state; + globalThis.window.dispatchEvent(new globalThis.PopStateEvent('popstate')); }); const replaceStateMock = vi.fn((state, _, url) => { globalThis.window.location.href = new URL(url).href; interceptedState = state; + globalThis.window.dispatchEvent(new globalThis.PopStateEvent('popstate')); }); beforeAll(() => { cleanup = init(); @@ -71,7 +74,7 @@ describe("RouterEngine", () => { }; }); afterAll(() => { - location.dispose(); + cleanup(); }); describe('basePath', () => { test("Should be '/' by default", () => { @@ -120,16 +123,25 @@ describe("RouterEngine", () => { }); }); describe('state', () => { - test("Should return the current state.", () => { + test.each<{ hash: Hash; getter: (state: State) => any }>([ + { + hash: false, + getter: (state) => state.path, + }, + { + hash: true, + getter: (state) => state.hash.single, + }, + ])("Should return the current state for hash $hash .", ({ hash, getter }) => { // Arrange. - const router = new RouterEngine(); - const state = { key: "value" }; + const router = new RouterEngine({ hash }); + const state: State = { path: 1, hash: { single: 2, custom: 3 } }; // Act. globalThis.window.history.pushState(state, '', 'http://example.com/other'); // Assert. - expect(router.state).toBe(location.state); + expect(router.state).toBe(getter(globalThis.window.history.state)); }); }); describe('routes', () => { @@ -486,3 +498,55 @@ describe("RouterEngine", () => { }); }); }); + +describe("RouterEngine", () => { + let _href: string; + let cleanup: () => void; + let interceptedState: any = null; + const pushStateMock = vi.fn((state, _, url) => { + globalThis.window.location.href = new URL(url).href; + interceptedState = state; + globalThis.window.dispatchEvent(new globalThis.PopStateEvent('popstate')); + }); + const replaceStateMock = vi.fn((state, _, url) => { + globalThis.window.location.href = new URL(url).href; + interceptedState = state; + globalThis.window.dispatchEvent(new globalThis.PopStateEvent('popstate')); + }); + beforeAll(() => { + cleanup = init({ hashMode: 'multi' }); + // @ts-expect-error Many missing features. + globalThis.window.location = { + get href() { + return _href; + }, + set href(value) { + _href = value; + } + }; + // @ts-expect-error Many missing features. + globalThis.window.history = { + get state() { + return interceptedState; + }, + pushState: pushStateMock, + replaceState: replaceStateMock + }; + }); + afterAll(() => { + cleanup(); + }); + describe('state', () => { + test("Should return the current state for a named hash path.", () => { + // Arrange. + const router = new RouterEngine({ hash: 'custom' }); + const state: State = { path: 1, hash: { single: 2, custom: 3 } }; + + // Act. + globalThis.window.history.pushState(state, '', 'http://example.com/other'); + + // Assert. + expect(router.state).toBe(state.hash.custom); + }); + }); +}); diff --git a/src/lib/core/RouterEngine.svelte.ts b/src/lib/core/RouterEngine.svelte.ts index c2054b2..1a697de 100644 --- a/src/lib/core/RouterEngine.svelte.ts +++ b/src/lib/core/RouterEngine.svelte.ts @@ -1,4 +1,4 @@ -import type { AndUntyped, PatternRouteInfo, RegexRouteInfo, RouteInfo, RouteStatus } from "$lib/types.js"; +import type { AndUntyped, Hash, PatternRouteInfo, RegexRouteInfo, RouteInfo, RouteStatus } from "$lib/types.js"; import { traceOptions, registerRouter, unregisterRouter } from "./trace.svelte.js"; import { location } from "./Location.js"; import { routingOptions } from "./options.js"; @@ -102,6 +102,7 @@ export const routePatternsKey = Symbol(); export class RouterEngine { #cleanup = false; #parent: RouterEngine | undefined; + #resolvedHash: Hash; #hashId: string | undefined; /** * Gets or sets the router's identifier. This is displayed by the `RouterTracer` component. @@ -202,30 +203,34 @@ export class RouterEngine { /** * Initializes a new instance of this class with the specified options. */ - constructor(options: RouterEngineOptions); + constructor(options?: RouterEngineOptions); /** * Initializes a new instance of this class with the specified parent router. */ - constructor(parent?: RouterEngine); + constructor(parent: RouterEngine); constructor(parentOrOpts?: RouterEngine | RouterEngineOptions) { if (!location) { throw new Error("The routing library hasn't been initialized. Execute init() before creating routers."); } if (isRouterEngine(parentOrOpts)) { + this.#resolvedHash = parentOrOpts.#resolvedHash; this.#parent = parentOrOpts; } else { this.#parent = parentOrOpts?.parent; - const hash = resolveHashValue(parentOrOpts?.hash); - if (routingOptions.hashMode === 'multi' && hash && typeof hash !== 'string') { + this.#resolvedHash = this.#parent && parentOrOpts?.hash === undefined ? this.#parent.#resolvedHash : resolveHashValue(parentOrOpts?.hash); + if (this.#parent && this.#resolvedHash !== this.#parent.#resolvedHash) { + throw new Error("The parent router's hash mode must match the child router's hash mode."); + } + if (routingOptions.hashMode === 'multi' && this.#resolvedHash && typeof this.#resolvedHash !== 'string') { throw new Error("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."); } - if (routingOptions.hashMode !== 'multi' && typeof hash === 'string') { + if (routingOptions.hashMode !== 'multi' && typeof this.#resolvedHash === 'string') { throw new Error("A hash path ID was given, but is only allowed when the library's hash mode has been set to 'multi'."); } - this.#hashId = typeof hash === 'string' ? - hash : - (hash ? 'single' : undefined); + this.#hashId = typeof this.#resolvedHash === 'string' ? + this.#resolvedHash : + (this.#resolvedHash ? 'single' : undefined); } if (traceOptions.routerHierarchy) { registerRouter(this); @@ -246,7 +251,7 @@ export class RouterEngine { * This is a shortcut for `location.state`. */ get state() { - return location.state; + return location.getState(this.#resolvedHash); } /** * Gets or sets the router's base path. diff --git a/src/lib/core/index.test.ts b/src/lib/core/index.test.ts index ae71872..a72d7f2 100644 --- a/src/lib/core/index.test.ts +++ b/src/lib/core/index.test.ts @@ -7,6 +7,7 @@ describe('index', () => { 'location', 'RouterEngine', 'joinPaths', + 'isConformantState', ]; // Act. diff --git a/src/lib/core/index.ts b/src/lib/core/index.ts index 281c11e..13a8895 100644 --- a/src/lib/core/index.ts +++ b/src/lib/core/index.ts @@ -1,2 +1,3 @@ export { location } from "./Location.js"; export { RouterEngine, joinPaths } from "./RouterEngine.svelte.js"; +export { isConformantState } from "./isConformantState.js"; diff --git a/src/lib/core/isConformantState.ts b/src/lib/core/isConformantState.ts new file mode 100644 index 0000000..08f6ea9 --- /dev/null +++ b/src/lib/core/isConformantState.ts @@ -0,0 +1,17 @@ +import type { State } from "$lib/types.js"; + +/** + * Tests the given state data to see if it conforms to the expected `State` structure. + * + * Use this while in full mode and handling the `beforeNavigate` event, or in code that has to do with directly pushing + * state to the window's History API. + * @param state State data to test. + * @returns `true` if the state conforms to the expected `State` structure, or `false` otherwise. + */ +export function isConformantState(state: unknown): state is State { + return typeof state === 'object' + && state !== null + && 'hash' in state + && typeof state.hash === 'object' + && state.hash !== null; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index c5352cb..ae29ba5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,29 @@ import type { HTMLAnchorAttributes } from "svelte/elements"; import type { SvelteURL } from "svelte/reactivity"; +/** + * Defines the data type of all `hash` properties found in almost all of the library's components. + */ +export type Hash = boolean | string; + +/** + * Defines the valid shape of the state object that must be in the windows History API at all times for proper + * operation of the library. + */ +export type State = { + /** + * Holds the state data associated to path routing. + */ + path: any; + /** + * Holds the state data associated to hash routing. + * + * For single (or traditional) hash routing, the value is stored using the `single` key. For multi-hash routing, + * the value is stored using the hash identifier as the key. + */ + hash: Record; +} + /** * Defines the possible data types for route parameter values. */ @@ -103,10 +126,6 @@ export interface Location { * Gets a reactive URL object with the current window's URL. */ readonly url: SvelteURL; - /** - * Gets the current state object. - */ - readonly state: any; /** * Gets the current hash path or paths, depending on how the library was initialized. * @@ -115,6 +134,11 @@ export interface Location { * `location.hashPaths.`, where `` is the wanted path' identifier. */ readonly hashPaths: Record; + /** + * Gets the current state object associated with the current URL that responds to the given hash value. + * @param hash The hash value to get the state for. + */ + getState(hash: Hash): any; /** * Navigates to the specified URL. * @@ -176,7 +200,7 @@ export type NavigationEvent = { /** * The state object that was specified along with the URL. */ - state: any; + state: unknown; /** * The method of navigation that was used. */