Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/lib/core/InMemoryHistoryApi.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { HistoryApi, State } from "$lib/types.js";
import { LocationState } from "./LocationState.svelte.js";

/**
* In-memory implementation of the History API.
*/
export class InMemoryHistoryApi extends LocationState implements HistoryApi {
scrollRestoration: ScrollRestoration = 'manual';
#history = $state<{ state: State; title: string; url: string }[]>([]);
#currentIndex = $state(-1);
#cleanup: (() => void) | undefined;

constructor() {
super();
this.#cleanup = $effect.root(() => {
$effect(() => {
const entry = this.#getCurrentEntry();
if (entry) {
this.url.href = new URL(entry.url, 'http://mem.local').href;
this.state = entry.state;
}
})
})
}

dispose(): void {
this.#cleanup?.();
this.#cleanup = undefined;
}

#getCurrentEntry() {
return this.#currentIndex < 0 ? null : this.#history[this.#currentIndex];
}

get length() {
return this.#history.length;
}

pushState(state: State, title: string, url: string): void {
this.#history.splice(this.#currentIndex + 1);
this.#history.push({ state, title, url });
++this.#currentIndex;
}

replaceState(state: State, title: string, url: string): void {
if (this.#currentIndex >= 0) {
this.#history[this.#currentIndex] = { state, title, url };
}
else {
this.#history.push({ state, title, url });
}
}

go(delta: number): void {
const newIndex = this.#currentIndex + delta;
if (newIndex >= 0 && newIndex < this.#history.length) {
this.#currentIndex = newIndex;
}
}

back(): void {
this.go(-1);
}

forward(): void {
this.go(1);
}
}
3 changes: 1 addition & 2 deletions src/lib/core/LocationFull.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,12 @@ describe("LocationFull", () => {
])("Should provide the URL, state and method $method via the event object of 'beforeNavigate'.", ({ method, stateFn }) => {
// Arrange.
const callback = vi.fn();
const state = { test: 'value' };
const state = { path: { test: 'value' }, hash: {} };
location.on('beforeNavigate', callback);

// Act.
// @ts-expect-error stateFn cannot enumerate history.
globalThis.window.history[stateFn](state, '', 'http://example.com/other');
flushSync();

// Assert.
expect(callback).toHaveBeenCalledWith({
Expand Down
20 changes: 11 additions & 9 deletions src/lib/core/LocationFull.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { BeforeNavigateEvent, Events, NavigationCancelledEvent, NavigationEvent } from "$lib/types.js";
import type { BeforeNavigateEvent, Events, HistoryApi, NavigationCancelledEvent, NavigationEvent } from "$lib/types.js";
import { LocationLite } from "./LocationLite.svelte.js";
import { LocationState } from "./LocationState.svelte.js";
import { StockHistoryApi } from "./StockHistoryApi.svelte.js";
import { assertValidState } from "./LocationLite.svelte.js";

/**
* Location implementation of the library's full mode feature.
Expand All @@ -13,12 +14,11 @@ export class LocationFull extends LocationLite {
#nextSubId = 0;
#originalPushState = globalThis.window?.history.pushState.bind(globalThis.window?.history);
#originalReplaceState = globalThis.window?.history.replaceState.bind(globalThis.window?.history);
#innerState;
#historyApi;
constructor() {
const innerState = new LocationState();
// @ts-expect-error Base class constructor purposely hides the fact that takes one argument.
super(innerState);
this.#innerState = innerState;
const historyApi = new StockHistoryApi();
super(historyApi);
this.#historyApi = historyApi;
globalThis.window.history.pushState = this.#navigate.bind(this, 'push');
globalThis.window.history.replaceState = this.#navigate.bind(this, 'replace');
}
Expand All @@ -29,7 +29,7 @@ export class LocationFull extends LocationLite {
super.dispose();
}

#navigate(method: NavigationEvent['method'], state: any, _: string, url: string) {
#navigate(method: NavigationEvent['method'], state: unknown, _: string, url: string) {
const event: BeforeNavigateEvent = {
url,
state,
Expand Down Expand Up @@ -57,10 +57,12 @@ export class LocationFull extends LocationLite {
});
}
} else {
event.state ??= { path: undefined, hash: {} };
assertValidState(event.state);
const navFn = method === 'push' ? this.#originalPushState : this.#originalReplaceState;
navFn(event.state, '', url);
this.url.href = globalThis.window?.location.href;
this.#innerState.state = state;
this.#historyApi.state = event.state;
}
}

Expand Down
11 changes: 5 additions & 6 deletions src/lib/core/LocationLite.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, test, expect, beforeEach, beforeAll, vi, afterEach, afterAll } from "vitest";
import { LocationLite } from "./LocationLite.svelte.js";
import { LocationState } from "./LocationState.svelte.js";
import type { Hash, Location, State } from "$lib/types.js";
import { joinPaths } from "./RouterEngine.svelte.js";
import { init } from "$lib/index.js";
import { location as iLoc } from "./Location.js";
import { StockHistoryApi } from "./StockHistoryApi.svelte.js";

describe("LocationLite", () => {
const initialUrl = "http://example.com/";
Expand Down Expand Up @@ -55,15 +55,14 @@ describe("LocationLite", () => {
// 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 = new StockHistoryApi();
// Act.
// @ts-expect-error Parameter not disclosed.
const location = new LocationLite(locationState);
const location = new LocationLite(historyApi);

// Assert.
expect(location.url).toBe(locationState.url);
expect(location.url).toBe(historyApi.url);

// Cleanup.
location.dispose();
Expand Down
86 changes: 34 additions & 52 deletions src/lib/core/LocationLite.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
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 { routingOptions } from "./options.js";
import { resolveHashValue } from "./resolveHashValue.js";
import { calculateHref } from "./calculateHref.js";
import { calculateState } from "./calculateState.js";
import { preserveQueryInUrl } from "./preserveQuery.js";
import { StockHistoryApi } from "./StockHistoryApi.svelte.js";
import { isConformantState } from "./isConformantState.js";

export function assertValidState(state: unknown): asserts state is State {
if (!isConformantState(state)) {
throw new Error('Invalid history state.');
}
}


/**
* A lite version of the location object. It does not support event listeners or state-setting call interceptions,
* 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<string, string>;
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) {
Expand All @@ -31,29 +37,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, () => {
console.log(event);
this.#innerState.url.href = globalThis.window?.location?.href;
this.#innerState.state = globalThis.window?.history?.state;
}));
});
return () => {
for (let cleanup of cleanups) {
cleanup();
}
};
});
constructor(historyApi?: HistoryApi) {
this.#historyApi = historyApi ?? new StockHistoryApi();
}

on(event: "beforeNavigate", callback: (event: BeforeNavigateEvent) => void): () => void;
Expand All @@ -63,34 +48,36 @@ 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;
}

#goTo(url: string, replace: boolean, state: State | undefined) {
#goTo(url: string, replace: boolean, state: unknown) {
state ??= { path: undefined, hash: {} };
assertValidState(state);
if (url === '') {
// 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.replaceState :
this.#historyApi.pushState
).bind(this.#historyApi)(state, '', url);
window.location.href = this.#historyApi.url.href;
this.#historyApi.state = state;
}

goTo(url: string, options?: GoToOptions): void {
if (url === '') {
// Shallow routing.
url = this.url.href;
}
if (options?.preserveQuery) {
url = preserveQueryInUrl(url, options.preserveQuery);
}
Expand All @@ -99,11 +86,7 @@ export class LocationLite implements Location {

navigate(url: string, options?: NavigateOptions): void {
const resolvedHash = resolveHashValue(options?.hash);
if (url === '') {
// Shallow routing.
url = this.url.href;
}
else {
if (url !== '') {
url = calculateHref({
...options,
hash: resolvedHash,
Expand All @@ -114,11 +97,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();
}
}
14 changes: 10 additions & 4 deletions src/lib/core/LocationState.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import { SvelteURL } from "svelte/reactivity";
import { isConformantState } from "./isConformantState.js";
import type { State } from "$lib/types.js";

/**
* Helper class used to manage the reactive data of Location implementations.
* Base class for implementations of HistoryApi classes.
*/
export class LocationState {
url = new SvelteURL(globalThis.window?.location?.href);
#url;
state;

constructor() {
constructor(initialHref?: string, initialState?: State) {
this.#url = new SvelteURL(initialHref || globalThis.window?.location?.href);
// Get the current state from History API
let historyState = globalThis.window?.history?.state;
let historyState = initialState ?? globalThis.window?.history?.state;
let validState = false;
this.state = $state((validState = isConformantState(historyState)) ? historyState : { path: undefined, hash: {} });
if (!validState && historyState != null) {
console.warn('Non-conformant state data detected in History API. Resetting to clean state.');
}
}

get url() {
return this.#url;
}
}
Loading
Loading