@virtualstate/app-history
Version:
Native JavaScript [app-history](https://github.com/WICG/app-history) implementation
214 lines (193 loc) • 7.96 kB
text/typescript
import AbortController from "abort-controller";
import {InvalidStateError} from "./app-history-errors";
import {WritableProps} from "./util/writable";
import {
AppHistoryCurrentChangeEvent,
AppHistoryDestination,
AppHistoryNavigateEvent as AppHistoryNavigateEventPrototype,
AppHistoryNavigateOptions as AppHistoryNavigateOptionsPrototype,
AppHistoryNavigationType
} from "./spec/app-history";
import {AppHistoryEntry} from "./app-history-entry";
import {
AppHistoryTransition, AppHistoryTransitionAbort,
AppHistoryTransitionEntry,
AppHistoryTransitionInitialEntries, AppHistoryTransitionKnown,
AppHistoryTransitionNavigationType, AppHistoryTransitionWhile,
InternalAppHistoryNavigationType,
Rollback
} from "./app-history-transition";
import {createEvent} from "./event-target/create-event";
export const AppHistoryFormData = Symbol.for("@virtualstate/app-history/formData");
export const AppHistoryCanTransition = Symbol.for("@virtualstate/app-history/canTransition");
export const AppHistoryUserInitiated = Symbol.for("@virtualstate/app-history/userInitiated");
const baseUrl = "https://html.spec.whatwg.org/";
export interface AppHistoryNavigateOptions extends AppHistoryNavigateOptionsPrototype {
[AppHistoryFormData]?: FormData;
[AppHistoryCanTransition]?: boolean;
[AppHistoryUserInitiated]?: boolean;
}
export const EventAbortController = Symbol.for("@virtualstate/app-history/event/abortController");
export interface AbortControllerEvent {
[EventAbortController]: AbortController
}
export interface AppHistoryNavigateEvent extends AppHistoryNavigateEventPrototype, AbortControllerEvent {
}
export interface InternalAppHistoryNavigateOptions extends AppHistoryNavigateOptions {
entries?: AppHistoryEntry[];
index?: number;
known?: Set<AppHistoryEntry>;
navigationType?: AppHistoryNavigationType;
}
export interface AppHistoryTransitionContext {
transition: AppHistoryTransition;
options?: InternalAppHistoryNavigateOptions;
currentIndex: number;
known: Set<AppHistoryEntry>;
startTime?: number;
current?: AppHistoryEntry;
}
export interface AppHistoryTransitionResult {
entries: AppHistoryEntry[];
index: number;
known: Set<AppHistoryEntry>;
destination: AppHistoryDestination;
navigate: AppHistoryNavigateEvent;
currentChange: AppHistoryCurrentChangeEvent;
navigationType: InternalAppHistoryNavigationType;
}
function getEntryIndex(entries: AppHistoryEntry[], entry: AppHistoryEntry) {
const knownIndex = entry.index;
if (knownIndex !== -1) {
return knownIndex;
}
// TODO find an entry if it has changed id
return -1;
}
export function createAppHistoryTransition(context: AppHistoryTransitionContext): AppHistoryTransitionResult {
const {
currentIndex,
options,
known: initialKnown,
current,
transition,
transition: {
[AppHistoryTransitionInitialEntries]: previousEntries,
[AppHistoryTransitionEntry]: entry,
[AppHistoryTransitionWhile]: transitionWhile
}
} = context;
let {
transition: {
[AppHistoryTransitionNavigationType]: navigationType
}
} = context;
let resolvedEntries = [...previousEntries];
const known = new Set(initialKnown);
let destinationIndex = -1,
nextIndex = currentIndex;
if (navigationType === Rollback) {
const { index } = options ?? { index: undefined };
if (typeof index !== "number") throw new InvalidStateError("Expected index to be provided for rollback");
destinationIndex = index;
nextIndex = index;
} else if (navigationType === "traverse" || navigationType === "reload") {
destinationIndex = getEntryIndex(previousEntries, entry);
nextIndex = destinationIndex;
} else if ((navigationType === "replace") && currentIndex !== -1) {
destinationIndex = currentIndex;
nextIndex = currentIndex;
} else if (navigationType === "replace") {
navigationType = "push";
destinationIndex = currentIndex + 1;
nextIndex = destinationIndex;
} else {
destinationIndex = currentIndex + 1;
nextIndex = destinationIndex;
}
if (typeof destinationIndex !== "number" || destinationIndex === -1) {
throw new InvalidStateError("Could not resolve next index");
}
// console.log({ navigationType, entry, options });
if (!entry.url) {
console.trace({ navigationType, entry, options });
throw new InvalidStateError("Expected entry url");
}
const destination: WritableProps<AppHistoryDestination> = {
url: entry.url,
key: entry.key,
index: destinationIndex,
sameDocument: entry.sameDocument,
getState() {
return entry.getState()
}
};
let hashChange = false;
const currentUrlInstance = new URL(current?.url ?? "/", baseUrl);
const destinationUrlInstance = new URL(destination.url, baseUrl);
const currentHash = currentUrlInstance.hash;
const destinationHash = destinationUrlInstance.hash;
if (currentHash !== destinationHash) {
const currentUrlInstanceWithoutHash = new URL(currentUrlInstance.toString());
currentUrlInstanceWithoutHash.hash = "";
const destinationUrlInstanceWithoutHash = new URL(destinationUrlInstance.toString());
destinationUrlInstanceWithoutHash.hash = "";
hashChange = currentUrlInstanceWithoutHash.toString() === destinationUrlInstanceWithoutHash.toString();
}
const navigateController = new AbortController();
const navigate: AppHistoryNavigateEvent = createEvent({
[EventAbortController]: navigateController,
signal: navigateController.signal,
info: undefined,
...options,
canTransition: options?.[AppHistoryCanTransition] ?? true,
formData: options?.[AppHistoryFormData] ?? undefined,
hashChange,
navigationType: options?.navigationType ?? (
typeof navigationType === "string" ? navigationType : "replace"
),
userInitiated: options?.[AppHistoryUserInitiated] ?? false,
destination,
preventDefault: transition[AppHistoryTransitionAbort].bind(transition),
transitionWhile,
type: "navigate"
});
const currentChange: AppHistoryCurrentChangeEvent = createEvent({
from: current,
type: "currentchange",
navigationType: navigate.navigationType,
transitionWhile
});
if (navigationType === Rollback) {
const { entries } = options ?? { entries: undefined };
if (!entries) throw new InvalidStateError("Expected entries to be provided for rollback");
resolvedEntries = entries;
resolvedEntries.forEach(entry => known.add(entry));
} else
// Default next index is current entries length, aka
// console.log({ navigationType, givenNavigationType, index: this.#currentIndex, resolvedNextIndex });
if (navigationType === "replace" || navigationType === "traverse" || navigationType === "reload") {
resolvedEntries[destination.index] = entry;
if (navigationType === "replace") {
resolvedEntries = resolvedEntries.slice(0, destination.index + 1);
}
} else if (navigationType === "push") {
// Trim forward, we have reset our stack
if (resolvedEntries[destination.index]) {
// const before = [...this.#entries];
resolvedEntries = resolvedEntries.slice(0, destination.index);
// console.log({ before, after: [...this.#entries]})
}
resolvedEntries.push(entry);
}
known.add(entry);
return {
entries: resolvedEntries,
known,
index: nextIndex,
currentChange,
destination,
navigate,
navigationType
};
}