@virtualstate/app-history
Version:
Native JavaScript [app-history](https://github.com/WICG/app-history) implementation
245 lines (200 loc) • 6.74 kB
text/typescript
import {AppHistory, AppHistoryEntry, AppHistoryResult} from "./spec/app-history";
import {AppHistoryTransitionCommittedDeferred} from "./app-history-transition";
import {Deferred} from "./util/deferred";
export interface AppHistoryLocationOptions {
appHistory: AppHistory;
initialUrl?: URL | string;
}
type WritableURLKey =
| "hash"
| "host"
| "hostname"
| "href"
| "pathname"
| "port"
| "protocol"
| "search"
export const AppLocationCheckChange = Symbol.for("@virtualstate/app-history/location/checkChange");
export const AppLocationAwaitFinished = Symbol.for("@virtualstate/app-history/location/awaitFinished");
export const AppLocationTransitionURL = Symbol.for("@virtualstate/app-history/location/transitionURL");
export interface AppHistoryLocation extends Location {
}
const baseUrl = "https://html.spec.whatwg.org/";
/**
* @experimental
*/
export class AppHistoryLocation implements Location {
readonly #options: AppHistoryLocationOptions;
readonly #appHistory: AppHistory;
constructor(options: AppHistoryLocationOptions) {
this.#options = options;
this.#appHistory = options.appHistory;
const reset = () => {
this.#transitioningURL = undefined;
this.#initialURL = undefined;
};
this.#appHistory.addEventListener("navigate", () => {
const transition = this.#appHistory.transition;
if (transition && isCommittedAvailable(transition)) {
transition[AppHistoryTransitionCommittedDeferred]
.promise
.then(reset, reset)
}
function isCommittedAvailable(transition: object): transition is { [AppHistoryTransitionCommittedDeferred]: Deferred<AppHistoryEntry> } {
return AppHistoryTransitionCommittedDeferred in transition;
}
})
this.#appHistory.addEventListener("currentchange", reset);
}
readonly ancestorOrigins: DOMStringList;
#urls = new WeakMap<object, URL>();
#transitioningURL: URL | undefined;
#initialURL: URL | undefined;
get #url() {
if (this.#transitioningURL) {
return this.#transitioningURL;
}
const { current } = this.#appHistory;
if (!current) {
const initialUrl = this.#options.initialUrl ?? "/";
this.#initialURL = typeof initialUrl === "string" ? new URL(initialUrl, baseUrl) : initialUrl;
return this.#initialURL;
}
const existing = this.#urls.get(current);
if (existing) return existing;
const next = new URL(current.url, baseUrl);
this.#urls.set(current, next);
return next;
}
get hash() {
return this.#url.hash;
}
set hash(value) {
this.#setUrlValue("hash", value);
}
get host() {
return this.#url.host;
}
set host(value) {
this.#setUrlValue("host", value);
}
get hostname() {
return this.#url.hostname;
}
set hostname(value) {
this.#setUrlValue("hostname", value);
}
get href() {
return this.#url.href;
}
set href(value) {
this.#setUrlValue("href", value);
}
get origin() {
return this.#url.origin;
}
get pathname() {
return this.#url.pathname;
}
set pathname(value) {
this.#setUrlValue("pathname", value);
}
get port() {
return this.#url.port;
}
set port(value) {
this.#setUrlValue("port", value);
}
get protocol() {
return this.#url.protocol;
}
set protocol(value) {
this.#setUrlValue("protocol", value);
}
get search() {
return this.#url.search;
}
set search(value) {
this.#setUrlValue("search", value);
}
#setUrlValue = (key: WritableURLKey, value: string) => {
const currentUrlString = this.#url.toString();
const nextUrl = new URL(currentUrlString);
nextUrl[key] = value;
const nextUrlString = nextUrl.toString();
if (currentUrlString === nextUrlString) {
return;
}
void this.#transitionURL(
nextUrl,
() => this.#appHistory.navigate(nextUrlString)
);
}
replace(url: string | URL): Promise<void>
replace(url: string | URL): void
async replace(url: string | URL): Promise<void> {
return this.#transitionURL(
url,
(url) => this.#appHistory.navigate(url.toString(), {
replace: true
})
);
}
reload(): Promise<void>
reload(): void
async reload(): Promise<void> {
return this.#awaitFinished(this.#appHistory.reload());
}
assign(url: string | URL): Promise<void>
assign(url: string | URL): void
async assign(url: string | URL): Promise<void> {
await this.#transitionURL(
url,
(url) => this.#appHistory.navigate(url.toString())
)
}
protected [AppLocationTransitionURL](url: URL | string, fn: (url: URL) => AppHistoryResult) {
return this.#transitionURL(url, fn);
}
#transitionURL = async (url: URL | string, fn: (url: URL) => AppHistoryResult) => {
const instance = this.#transitioningURL = typeof url === "string" ? new URL(url, this.#url.toString()) : url;
try {
await this.#awaitFinished(fn(instance));
} finally {
if (this.#transitioningURL === instance) {
this.#transitioningURL = undefined;
}
}
}
protected [AppLocationAwaitFinished](result: AppHistoryResult) {
return this.#awaitFinished(result);
}
#awaitFinished = async (result?: AppHistoryResult) => {
this.#initialURL = undefined;
if (!result) return;
const { committed, finished } = result;
await Promise.all([
committed || Promise.resolve(undefined),
finished || Promise.resolve(undefined)
]);
}
#triggerIfUrlChanged = () => {
const current = this.#url;
const currentUrl = current.toString();
const expectedUrl = this.#appHistory.current.url;
if (currentUrl !== expectedUrl) {
return this.#transitionURL(
current,
() => this.#appHistory.navigate(currentUrl)
);
}
}
/**
* This is needed if you have changed searchParams using its mutating methods
*
* TODO replace get searchParams with an observable change to auto trigger this function
*/
[AppLocationCheckChange]() {
return this.#triggerIfUrlChanged();
}
}