├── example ├── .gitignore ├── no-intercept-esm.html ├── no-intercept-native.html ├── esm.html ├── index.html └── webcomponents.html ├── scripts ├── imported │ ├── config.js │ ├── package.json │ └── index.js ├── example.js ├── log-version.js ├── nop │ ├── package.json │ └── index.js └── correct-import-extensions.js ├── src ├── state │ ├── index.ts │ └── state.ts ├── util │ ├── import-types.ts │ ├── writable.ts │ ├── deno-import-types.ts │ ├── uuid-or-random.ts │ ├── serialization.ts │ ├── parse-dom-deno.ts │ ├── parse-dom.ts │ ├── deferred.ts │ └── warnings.ts ├── types │ ├── app-history.d.ts │ ├── event-target.d.ts │ └── dom-parse.d.ts ├── tests │ ├── wpt │ │ ├── index.ts │ │ ├── current-entry │ │ │ └── index.ts │ │ └── history │ │ │ └── index.ts │ ├── node-process.ts │ ├── performance.ts │ ├── default-process.ts │ ├── dynamic │ │ ├── test.ts │ │ ├── another.ts │ │ └── index.ts │ ├── examples │ │ ├── index.ts │ │ ├── hash-change.ts │ │ ├── fetch.ts │ │ ├── jsx.tsx │ │ ├── url-pattern.ts │ │ ├── sync-legacy.ts │ │ └── demo-1.ts │ ├── navigation.class.ts │ ├── config.ts │ ├── navigation.imported.ts │ ├── util.ts │ ├── same-document.ts │ ├── dependencies-input.ts │ ├── destination-key-from-key.ts │ ├── original-event.ts │ ├── custom-state.ts │ ├── dependencies.ts │ ├── transition.ts │ ├── await │ │ └── index.ts │ ├── routes │ │ └── jsx.tsx │ ├── navigation-type-auto-replace.ts │ ├── navigation.scope.faker.ts │ ├── entrieschange.ts │ ├── commit.ts │ ├── index.ts │ ├── navigation.server.wpt.ts │ ├── state │ │ └── index.tsx │ └── navigation.tsx ├── global-self.ts ├── events │ ├── index.ts │ ├── navigation-current-entry-change-event.ts │ └── navigate-event.ts ├── global-abort-controller.ts ├── routes │ ├── index.ts │ ├── url-pattern-global.ts │ ├── types.ts │ ├── url-pattern.ts │ ├── route.ts │ └── transition.ts ├── import-abort-controller.ts ├── event-target │ ├── index.ts │ ├── descriptor.ts │ ├── parallel-event.ts │ ├── context.ts │ ├── create-event.ts │ ├── respond-event.ts │ ├── event-target-options.ts │ ├── intercept-event.ts │ ├── event.ts │ ├── callback.ts │ ├── event-target.ts │ ├── global.ts │ ├── signal-event.ts │ ├── sync-event-target.ts │ ├── async-event-target.ts │ └── event-target-listeners.ts ├── get-navigation.ts ├── base-url.ts ├── global-navigation.ts ├── await │ ├── events.ts │ ├── index.ts │ └── create-promise.ts ├── transition.ts ├── polyfill.ts ├── is.ts ├── apply-polyfill.ts ├── index.ts ├── navigation-errors.ts ├── defer.ts ├── noop-navigation.ts ├── navigation-event-target.ts ├── global-window.ts ├── navigation-transition-planner.ts ├── navigation-entry.ts ├── dynamic │ └── index.ts ├── history.ts └── navigation-navigation.ts ├── .gitignore ├── .npmignore ├── .nycrc ├── CONTRIBUTING.md ├── .github └── workflows │ ├── web-platform-test-actions.yml │ ├── spec-browser-test-actions.yml │ ├── test-actions.yml │ ├── codeql-analysis.yml │ └── release-actions.yml ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── tsconfig.json ├── LICENSE.md ├── CHANGELOG.md ├── CODE-OF-CONDUCT.md └── package.json /example/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/imported/config.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./state"; -------------------------------------------------------------------------------- /src/util/import-types.ts: -------------------------------------------------------------------------------- 1 | export default 1; 2 | -------------------------------------------------------------------------------- /scripts/example.js: -------------------------------------------------------------------------------- 1 | import "../esnext/example/index.js"; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .idea 3 | node_modules 4 | esnext 5 | coverage -------------------------------------------------------------------------------- /src/types/app-history.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@virtualstate/navigation-imported"; 2 | -------------------------------------------------------------------------------- /src/tests/wpt/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./history"; 2 | export * from "./current-entry"; -------------------------------------------------------------------------------- /src/types/event-target.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@virtualstate/navigation/event-target"; 2 | -------------------------------------------------------------------------------- /src/global-self.ts: -------------------------------------------------------------------------------- 1 | export const globalSelf = typeof self === "undefined" ? undefined : self; -------------------------------------------------------------------------------- /src/util/writable.ts: -------------------------------------------------------------------------------- 1 | export type WritableProps = { -readonly [P in keyof T]: T[P] }; 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .idea 3 | node_modules 4 | coverage 5 | src/tests 6 | esnext/tests 7 | *.lock -------------------------------------------------------------------------------- /src/events/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./navigation-current-entry-change-event" 2 | export * from "./navigate-event"; -------------------------------------------------------------------------------- /src/global-abort-controller.ts: -------------------------------------------------------------------------------- 1 | export const GlobalAbortController = 2 | typeof AbortController !== "undefined" ? AbortController : undefined; 3 | -------------------------------------------------------------------------------- /scripts/log-version.js: -------------------------------------------------------------------------------- 1 | import("fs").then(({ promises }) => promises.readFile("package.json")).then(JSON.parse).then(({ version }) => console.log(version)) -------------------------------------------------------------------------------- /src/util/deno-import-types.ts: -------------------------------------------------------------------------------- 1 | // @deno-types="https://unpkg.com/rollup@2.36.1/dist/rollup.d.ts" 2 | import "https://unpkg.com/rollup@2.36.1/dist/rollup.browser.js"; 3 | -------------------------------------------------------------------------------- /src/tests/node-process.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start */ 2 | import defaultProcess from "./default-process"; 3 | 4 | export default typeof process === "undefined" ? defaultProcess : process; 5 | -------------------------------------------------------------------------------- /scripts/nop/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.0", 3 | "main": "./index.js", 4 | "type": "module", 5 | "exports": { 6 | ".": "./index.js", 7 | "./": "./index.js" 8 | } 9 | } -------------------------------------------------------------------------------- /scripts/imported/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.0", 3 | "main": "./index.js", 4 | "type": "module", 5 | "exports": { 6 | ".": "./index.js", 7 | "./": "./index.js" 8 | } 9 | } -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./router"; 2 | export * from "./route"; 3 | export * from "./types"; 4 | export * from "./transition"; 5 | export { enableURLPatternCache } from "./url-pattern" -------------------------------------------------------------------------------- /src/types/dom-parse.d.ts: -------------------------------------------------------------------------------- 1 | declare module "deno:deno_dom/deno-dom-wasm.ts" { 2 | export class DOMParser { 3 | parseFromString(input: string, type: string): Document | undefined; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/tests/performance.ts: -------------------------------------------------------------------------------- 1 | const { PerformanceObserver } = await import("perf_hooks").catch((): { PerformanceObserver: undefined } => ({ 2 | PerformanceObserver: undefined, 3 | })); 4 | 5 | export default PerformanceObserver; 6 | -------------------------------------------------------------------------------- /src/tests/default-process.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start */ 2 | const env: Record = {}; 3 | export default { 4 | env, 5 | on(event: string | symbol, listener: (...args: unknown[]) => void) {}, 6 | exit(v: number) {}, 7 | } as const; 8 | -------------------------------------------------------------------------------- /src/tests/dynamic/test.ts: -------------------------------------------------------------------------------- 1 | import {NavigateEvent, Navigation} from "../../spec/navigation"; 2 | 3 | export async function intercept(event: NavigateEvent, navigation: Navigation) { 4 | console.log("Test", { intercept: { event, navigation }}); 5 | } -------------------------------------------------------------------------------- /src/tests/dynamic/another.ts: -------------------------------------------------------------------------------- 1 | import {NavigateEvent, Navigation} from "../../spec/navigation"; 2 | 3 | export async function intercept(event: NavigateEvent, navigation: Navigation) { 4 | console.log("Another", { intercept: { event, navigation }}); 5 | } -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | 4 | ], 5 | "reporter": [ 6 | "clover", 7 | "json-summary", 8 | "html", 9 | "text-summary" 10 | ], 11 | "branches": 80, 12 | "lines": 80, 13 | "functions": 80, 14 | "statements": 80 15 | } 16 | -------------------------------------------------------------------------------- /src/import-abort-controller.ts: -------------------------------------------------------------------------------- 1 | import { GlobalAbortController } from "./global-abort-controller"; 2 | 3 | if (!GlobalAbortController) { 4 | throw new Error("AbortController expected to be available or polyfilled"); 5 | } 6 | 7 | export const AbortController = GlobalAbortController; -------------------------------------------------------------------------------- /scripts/nop/index.js: -------------------------------------------------------------------------------- 1 | export const listenAndServe = undefined; 2 | export const createServer = undefined; 3 | 4 | /** 5 | * @type {any} 6 | */ 7 | const on = () => {}; 8 | /** 9 | * @type {any} 10 | */ 11 | const env = {}; 12 | 13 | /*** 14 | * @type {any} 15 | */ 16 | export default { 17 | env, 18 | on, 19 | exit(arg) {} 20 | }; -------------------------------------------------------------------------------- /src/tests/dynamic/index.ts: -------------------------------------------------------------------------------- 1 | import { DynamicNavigation } from "../../dynamic"; 2 | 3 | export async function dynamicNavigation() { 4 | const navigation = new DynamicNavigation({ 5 | baseURL: import.meta.url 6 | }); 7 | 8 | await navigation.navigate("./test").finished; 9 | await navigation.navigate("./another").finished; 10 | } -------------------------------------------------------------------------------- /src/event-target/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./event-target"; 2 | export * from "./event"; 3 | export * from "./descriptor"; 4 | export * from "./callback"; 5 | export * from "./event-target-options"; 6 | export * from "./async-event-target"; 7 | export * from "./sync-event-target"; 8 | export * from "./signal-event"; 9 | export * from "./intercept-event"; 10 | -------------------------------------------------------------------------------- /src/event-target/descriptor.ts: -------------------------------------------------------------------------------- 1 | import { EventCallback } from "./callback"; 2 | 3 | export const EventDescriptorSymbol = Symbol.for( 4 | "@opennetwork/environment/events/descriptor" 5 | ); 6 | 7 | export interface EventDescriptor { 8 | [EventDescriptorSymbol]: true; 9 | type: string | symbol; 10 | callback: EventCallback; 11 | once?: boolean; 12 | } 13 | -------------------------------------------------------------------------------- /src/event-target/parallel-event.ts: -------------------------------------------------------------------------------- 1 | import { Event, isEvent } from "./event"; 2 | 3 | export interface ParallelEvent 4 | extends Event { 5 | parallel: true | undefined; 6 | } 7 | 8 | export function isParallelEvent(value: object): value is ParallelEvent { 9 | return isEvent(value) && value.parallel !== false; 10 | } 11 | -------------------------------------------------------------------------------- /src/tests/examples/index.ts: -------------------------------------------------------------------------------- 1 | declare var Bun: undefined; 2 | 3 | export * from "./readme-detailed"; 4 | export * from "./jsx"; 5 | let demo1; 6 | if (typeof Bun === "undefined") { 7 | const mod = await import("./demo-1"); 8 | demo1 = mod?.demo1; 9 | } 10 | export { demo1 }; 11 | export * from "./sync-legacy"; 12 | export * from "./url-pattern"; 13 | export * from "./hash-change"; 14 | -------------------------------------------------------------------------------- /src/tests/navigation.class.ts: -------------------------------------------------------------------------------- 1 | import { Navigation } from "../navigation"; 2 | import { NavigationAssertFn, assertNavigation } from "./navigation"; 3 | 4 | function getNavigationByClass() { 5 | return new Navigation(); 6 | } 7 | const fn: NavigationAssertFn = await assertNavigation(getNavigationByClass); 8 | fn(getNavigationByClass); 9 | console.log("PASS assertNavigation:local:new Navigation"); 10 | -------------------------------------------------------------------------------- /src/event-target/context.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "./event"; 2 | import { EventDescriptor } from "./descriptor"; 3 | 4 | export interface DispatchedEvent { 5 | descriptor?: EventDescriptor; 6 | event: Event; 7 | target: unknown; 8 | timestamp: number; 9 | } 10 | 11 | export interface EventListener { 12 | isListening(): boolean; 13 | descriptor: EventDescriptor; 14 | timestamp: number; 15 | } 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make by way of an issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | We are open to all ideas big or small, and are greatly appreciative of any and all contributions. 7 | 8 | Please note we have a code of conduct, please follow it in all your interactions with the project. 9 | -------------------------------------------------------------------------------- /src/tests/config.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start */ 2 | import process from "./node-process"; 3 | 4 | export function getConfig(): Record { 5 | return { 6 | ...getNodeConfig(), 7 | }; 8 | } 9 | 10 | function getNodeConfig(): Record { 11 | if (typeof process === "undefined") return {}; 12 | return { 13 | FLAGS: process.env.FLAGS, 14 | ...JSON.parse(process.env.TEST_CONFIG ?? "{}"), 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/util/uuid-or-random.ts: -------------------------------------------------------------------------------- 1 | const isWebCryptoSupported = "crypto" in globalThis && typeof globalThis.crypto.randomUUID === "function"; 2 | 3 | export const v4: () => string = isWebCryptoSupported 4 | ? globalThis.crypto.randomUUID.bind(globalThis.crypto) 5 | : () => Array.from( 6 | { length: 5 }, 7 | () => `${Math.random()}`.replace(/^0\./, "") 8 | ) 9 | .join("-") 10 | .replace(".", ""); -------------------------------------------------------------------------------- /src/get-navigation.ts: -------------------------------------------------------------------------------- 1 | import { globalNavigation } from "./global-navigation"; 2 | import type { Navigation } from "./spec/navigation"; 3 | import { Navigation as NavigationPolyfill } from "./navigation"; 4 | 5 | let navigation: Navigation; 6 | 7 | export function getNavigation(): Navigation { 8 | if (globalNavigation) { 9 | return globalNavigation; 10 | } 11 | if (navigation) { 12 | return navigation; 13 | } 14 | return (navigation = new NavigationPolyfill()); 15 | } -------------------------------------------------------------------------------- /src/base-url.ts: -------------------------------------------------------------------------------- 1 | function getWindowBaseURL() { 2 | try { 3 | if (typeof window !== "undefined" && window.location) { 4 | return window.location.href; 5 | } 6 | } catch {} 7 | } 8 | 9 | export function getBaseURL(url?: string | URL) { 10 | const baseURL = getWindowBaseURL() ?? "https://html.spec.whatwg.org/"; 11 | return new URL( 12 | // Deno wants this to be always a string 13 | (url ?? "").toString(), 14 | baseURL 15 | ); 16 | } -------------------------------------------------------------------------------- /src/util/serialization.ts: -------------------------------------------------------------------------------- 1 | export interface Serializer { 2 | stringify(value: unknown): string; 3 | parse(value: string): unknown 4 | } 5 | 6 | let GLOBAL_SERIALIZER: Serializer = JSON; 7 | 8 | export function setSerializer(serializer: Serializer) { 9 | GLOBAL_SERIALIZER = serializer; 10 | } 11 | 12 | export function stringify(value: unknown) { 13 | return GLOBAL_SERIALIZER.stringify(value); 14 | } 15 | 16 | export function parse(value: string) { 17 | return GLOBAL_SERIALIZER.parse(value); 18 | } -------------------------------------------------------------------------------- /src/global-navigation.ts: -------------------------------------------------------------------------------- 1 | import type { Navigation } from "./spec/navigation"; 2 | 3 | export let globalNavigation: Navigation | undefined = undefined; 4 | if (typeof window !== "undefined" && (window as any).navigation) { 5 | const navigation = (window as any).navigation; 6 | assertNavigation(navigation); 7 | globalNavigation = navigation; 8 | } 9 | 10 | function assertNavigation(value: unknown): asserts value is Navigation { 11 | if (!value) { 12 | throw new Error("Expected Navigation"); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/await/events.ts: -------------------------------------------------------------------------------- 1 | import { createNavigationEvent } from "./create-promise"; 2 | 3 | export const navigate = createNavigationEvent("navigate"); 4 | export const navigateError = createNavigationEvent("navigateerror"); 5 | export const error = navigateError; 6 | export const navigateSuccess = createNavigationEvent("navigatesuccess"); 7 | export const success = navigateSuccess; 8 | export const currentEntryChange = createNavigationEvent("currententrychange"); 9 | export const entriesChange = createNavigationEvent("entrieschange"); 10 | -------------------------------------------------------------------------------- /src/transition.ts: -------------------------------------------------------------------------------- 1 | import {Navigation, NavigationTransition} from "./spec/navigation"; 2 | 3 | export async function transition(navigation: Navigation) { 4 | let transition: NavigationTransition | undefined = undefined; 5 | let finalPromise; 6 | while (navigation.transition && transition !== navigation.transition) { 7 | transition = navigation.transition; 8 | finalPromise = transition.finished; 9 | await finalPromise.catch((error: unknown): void => void error); 10 | } 11 | return finalPromise; 12 | } -------------------------------------------------------------------------------- /src/util/parse-dom-deno.ts: -------------------------------------------------------------------------------- 1 | import { DOMParser } from "deno:deno_dom/deno-dom-wasm.ts"; 2 | 3 | export async function parseDOM(input: string, querySelector: string) { 4 | const doc = new DOMParser().parseFromString(input, "text/html"); 5 | if (!doc) { 6 | throw new Error("Expected valid document"); 7 | } 8 | const element = doc.querySelector(querySelector); 9 | if (!element) { 10 | throw new Error("Expected elemenet"); 11 | } 12 | return { 13 | title: doc.title, 14 | innerHTML: element.innerHTML, 15 | } as const; 16 | } 17 | -------------------------------------------------------------------------------- /scripts/imported/index.js: -------------------------------------------------------------------------------- 1 | /* c8 ignore start */ 2 | 3 | const initialImportPath = getConfig()["@virtualstate/navigation/test/imported/path"] ?? "@virtualstate/navigation"; 4 | 5 | if (typeof initialImportPath !== "string") throw new Error("Expected string import path"); 6 | 7 | export const { Navigation } = await import(initialImportPath); 8 | 9 | export function getConfig() { 10 | return { 11 | ...getNodeConfig(), 12 | }; 13 | } 14 | 15 | function getNodeConfig() { 16 | if (typeof process === "undefined") return {}; 17 | return JSON.parse(process.env.TEST_CONFIG ?? "{}"); 18 | } 19 | /* c8 ignore end */ -------------------------------------------------------------------------------- /src/event-target/create-event.ts: -------------------------------------------------------------------------------- 1 | import { assertEvent, Event } from "./event"; 2 | 3 | export function createEvent( 4 | event: E 5 | ): E { 6 | if (typeof CustomEvent !== "undefined" && typeof event.type === "string") { 7 | if (event instanceof CustomEvent) { 8 | return event; 9 | } 10 | const { type, detail, ...rest } = event; 11 | const customEvent: unknown = new CustomEvent(type, { 12 | detail: detail ?? rest, 13 | }); 14 | Object.assign(customEvent, rest); 15 | assertEvent(customEvent, event.type); 16 | return customEvent; 17 | } 18 | return event; 19 | } 20 | -------------------------------------------------------------------------------- /src/polyfill.ts: -------------------------------------------------------------------------------- 1 | import { getNavigation } from "./get-navigation"; 2 | import { applyPolyfill, shouldApplyPolyfill } from "./apply-polyfill"; 3 | import { setSerializer } from "./util/serialization"; 4 | import { setIgnoreWarnings, setTraceWarnings } from "./util/warnings"; 5 | 6 | const navigation = getNavigation(); 7 | 8 | if (shouldApplyPolyfill(navigation)) { 9 | try { 10 | applyPolyfill({ 11 | navigation 12 | }); 13 | } catch (error) { 14 | console.error("Failed to apply polyfill"); 15 | console.error(error); 16 | } 17 | } 18 | 19 | export { 20 | setSerializer, 21 | setIgnoreWarnings, 22 | setTraceWarnings 23 | } -------------------------------------------------------------------------------- /src/tests/navigation.imported.ts: -------------------------------------------------------------------------------- 1 | import { NavigationAssertFn, assertNavigation } from "./navigation"; 2 | 3 | try { 4 | const { Navigation } = (await import( 5 | "@virtualstate/navigation-imported" 6 | )) ?? { Navigation: undefined }; 7 | if (Navigation) { 8 | function getNavigationByImported() { 9 | return new Navigation(); 10 | } 11 | const fn: NavigationAssertFn = await assertNavigation(getNavigationByImported); 12 | fn(getNavigationByImported); 13 | console.log(`PASS assertNavigation:imported:new Navigation`); 14 | } 15 | } catch { 16 | console.warn(`WARN FAILED assertNavigation:imported:new Navigation`); 17 | } 18 | -------------------------------------------------------------------------------- /src/event-target/respond-event.ts: -------------------------------------------------------------------------------- 1 | import { Event, isEvent } from "./event"; 2 | 3 | export interface RespondEvent< 4 | Name extends string | symbol = string, 5 | T = unknown 6 | > extends Event { 7 | /** 8 | * @param value 9 | * @throws InvalidStateError 10 | */ 11 | respondWith(value: T | Promise): void; 12 | } 13 | 14 | export function isRespondEvent( 15 | value: object 16 | ): value is RespondEvent { 17 | function isRespondEventLike( 18 | value: object 19 | ): value is Partial> { 20 | return isEvent(value); 21 | } 22 | return isRespondEventLike(value) && typeof value.respondWith === "function"; 23 | } 24 | -------------------------------------------------------------------------------- /src/routes/url-pattern-global.ts: -------------------------------------------------------------------------------- 1 | import "urlpattern-polyfill"; 2 | import type {URLPattern as URLPatternPolyfill} from "urlpattern-polyfill"; 3 | 4 | export interface URLPatternInit { 5 | baseURL?: string; 6 | username?: string; 7 | password?: string; 8 | protocol?: string; 9 | hostname?: string; 10 | port?: string; 11 | pathname?: string; 12 | search?: string; 13 | hash?: string; 14 | } 15 | 16 | declare var URLPattern: { 17 | new (init?: URLPatternInit | string, baseURL?: string): URLPatternPolyfill; 18 | }; 19 | 20 | if (typeof URLPattern === "undefined") { 21 | throw new Error("urlpattern-polyfill did not import correctly"); 22 | } 23 | 24 | export const globalURLPattern = URLPattern; -------------------------------------------------------------------------------- /.github/workflows/web-platform-test-actions.yml: -------------------------------------------------------------------------------- 1 | name: web-platform-test-actions 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-22.04 7 | env: 8 | NO_COVERAGE_BADGE_UPDATE: 1 9 | FLAGS: FETCH_SERVICE_DISABLE,POST_CONFIGURE_TEST,WEB_PLATFORM_TESTS,CONTINUE_ON_ERROR 10 | steps: 11 | - uses: actions/checkout@v5 12 | - uses: actions/setup-node@v5 13 | with: 14 | node-version: '24.x' 15 | registry-url: 'https://registry.npmjs.org' 16 | cache: "yarn" 17 | - run: | 18 | yarn install 19 | yarn add --dev https://github.com/web-platform-tests/wpt.git 20 | npx playwright install-deps 21 | - run: yarn build 22 | - run: yarn test:node 23 | -------------------------------------------------------------------------------- /src/util/parse-dom.ts: -------------------------------------------------------------------------------- 1 | export async function parseDOM(input: string, querySelector: string) { 2 | if (typeof DOMParser !== "undefined") { 3 | const doc = new DOMParser().parseFromString(input, "text/html")!; 4 | const element = doc.querySelector(querySelector); 5 | if (!element) { 6 | throw new Error("Expected elemenet"); 7 | } 8 | return { 9 | title: doc.title, 10 | innerHTML: element.innerHTML, 11 | } as const; 12 | } else { 13 | const Cheerio = await import("cheerio"); 14 | if (!Cheerio.load) throw new Error("Could not parse html"); 15 | const $ = Cheerio.load(input); 16 | return { 17 | title: $("title").text() ?? "", 18 | innerHTML: $("main").html() ?? "", 19 | } as const; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/is.ts: -------------------------------------------------------------------------------- 1 | export function isPromise(value: unknown): value is Promise { 2 | return ( 3 | like>(value) && 4 | typeof value.then === "function" 5 | ) 6 | } 7 | 8 | export function ok(value: unknown, message?: string): asserts value 9 | export function ok(value: unknown, message?: string): asserts value is T 10 | export function ok(value: unknown, message = "Expected value"): asserts value { 11 | if (!value) { 12 | throw new Error(message); 13 | } 14 | } 15 | 16 | export function isPromiseRejectedResult(value: PromiseSettledResult): value is PromiseRejectedResult { 17 | return value.status === "rejected"; 18 | } 19 | 20 | export function like(value: unknown): value is T { 21 | return !!value; 22 | } -------------------------------------------------------------------------------- /src/apply-polyfill.ts: -------------------------------------------------------------------------------- 1 | import {DEFAULT_POLYFILL_OPTIONS, getCompletePolyfill, getPolyfill, NavigationPolyfillOptions} from "./get-polyfill"; 2 | import {getNavigation} from "./get-navigation"; 3 | import {globalNavigation} from "./global-navigation"; 4 | 5 | export function applyPolyfill(options: NavigationPolyfillOptions = DEFAULT_POLYFILL_OPTIONS) { 6 | const { apply, navigation } = getCompletePolyfill(options); 7 | apply(); 8 | return navigation; 9 | } 10 | 11 | export function shouldApplyPolyfill(navigation = getNavigation()) { 12 | const globalThat: { Element?: unknown, navigation?: unknown } = globalThis; 13 | return ( 14 | navigation !== globalNavigation && 15 | !Object.hasOwn(globalThat, 'navigation') && 16 | typeof window !== "undefined" 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/event-target/event-target-options.ts: -------------------------------------------------------------------------------- 1 | export interface EventTargetAddListenerOptions { 2 | once?: boolean; 3 | } 4 | 5 | /** 6 | * @experimental 7 | */ 8 | export const EventTargetListeners = Symbol.for( 9 | "@opennetwork/environment/events/target/listeners" 10 | ); 11 | 12 | /** 13 | * @experimental 14 | */ 15 | export const EventTargetListenersIgnore = Symbol.for( 16 | "@opennetwork/environment/events/target/listeners/ignore" 17 | ); 18 | 19 | /** 20 | * @experimental 21 | */ 22 | export const EventTargetListenersMatch = Symbol.for( 23 | "@opennetwork/environment/events/target/listeners/match" 24 | ); 25 | 26 | /** 27 | * @experimental 28 | */ 29 | export const EventTargetListenersThis = Symbol.for( 30 | "@opennetwork/environment/events/target/listeners/this" 31 | ); 32 | -------------------------------------------------------------------------------- /src/event-target/intercept-event.ts: -------------------------------------------------------------------------------- 1 | import { isEvent, Event } from "./event"; 2 | 3 | export interface InterceptEvent< 4 | Name extends string | symbol = string, 5 | T = unknown 6 | > extends Event { 7 | /** 8 | * @param value 9 | * @throws InvalidStateError 10 | */ 11 | intercept(value: T | Promise): void; 12 | transitionWhile?(value: T | Promise): void; 13 | } 14 | 15 | export function isInterceptEvent( 16 | value: object 17 | ): value is InterceptEvent { 18 | function isInterceptEventLike( 19 | value: object 20 | ): value is Partial> { 21 | return isEvent(value); 22 | } 23 | return ( 24 | isInterceptEventLike(value) && typeof value.intercept === "function" 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/tests/util.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start */ 2 | import { getConfig } from "./config"; 3 | import { Navigation } from "../spec/navigation"; 4 | 5 | export function ok(value: unknown, message?: string) { 6 | assert(value, message); 7 | } 8 | 9 | export function assert( 10 | value: unknown, 11 | message?: string 12 | ): asserts value is T { 13 | if (!value) throw new Error(message); 14 | } 15 | 16 | export function debug(...messages: unknown[]) { 17 | if (getConfig().FLAGS?.includes("DEBUG")) { 18 | console.log(...messages); 19 | } 20 | } 21 | 22 | // declare global { 23 | // const navigation: Navigation; 24 | // } 25 | 26 | export function isWindowNavigation(navigation: Navigation): boolean { 27 | return typeof window !== "undefined" && window.navigation === navigation; 28 | } 29 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./navigation"; 2 | export * from "./spec/navigation"; 3 | export { NavigationTransitionFinally } from "./navigation-transition"; 4 | export * from "./history"; 5 | export * from "./location"; 6 | export { EventTarget } from "./event-target"; 7 | export { 8 | NavigationFormData, 9 | NavigationCanIntercept, 10 | NavigationUserInitiated, 11 | NavigationNavigateOptions, 12 | } from "./create-navigation-transition"; 13 | export * from "./transition"; 14 | export * from "./event-target/intercept-event"; 15 | export { 16 | NavigationCurrentEntryChangeEvent 17 | } from "./events"; 18 | export { applyPolyfill } from "./apply-polyfill" 19 | export { getPolyfill, getCompletePolyfill } from "./get-polyfill"; 20 | export { setSerializer, Serializer } from "./util/serialization"; 21 | export { setIgnoreWarnings, setTraceWarnings } from "./util/warnings"; -------------------------------------------------------------------------------- /.github/workflows/spec-browser-test-actions.yml: -------------------------------------------------------------------------------- 1 | name: spec-browser-test-actions 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | timeout-minutes: 15 7 | runs-on: ubuntu-22.04 8 | env: 9 | NO_COVERAGE_BADGE_UPDATE: 1 10 | FLAGS: FETCH_SERVICE_DISABLE,POST_CONFIGURE_TEST,PLAYWRIGHT,CONTINUE_ON_ERROR,SPEC_BROWSER 11 | steps: 12 | - uses: actions/checkout@v5 13 | - uses: actions/setup-node@v5 14 | with: 15 | node-version: '24.x' 16 | registry-url: 'https://registry.npmjs.org' 17 | cache: "yarn" 18 | - uses: denoland/setup-deno@v2 19 | with: 20 | deno-version: 'v2.x' 21 | - run: | 22 | yarn remove wpt || echo "no wpt" 23 | yarn install 24 | npx playwright install-deps 25 | - run: yarn build 26 | # yarn coverage === c8 + yarn test 27 | - run: yarn coverage 28 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.192.0/containers/typescript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version: 16, 14, 12 4 | ARG VARIANT="16-buster" 5 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | # [Optional] Uncomment if you want to install an additional version of node using nvm 12 | # ARG EXTRA_NODE_VERSION=10 13 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 14 | 15 | # [Optional] Uncomment if you want to install more global node packages 16 | # RUN su node -c "npm install -g " 17 | -------------------------------------------------------------------------------- /src/util/deferred.ts: -------------------------------------------------------------------------------- 1 | export interface Deferred { 2 | resolve(value: T): void; 3 | reject(reason: unknown): void; 4 | promise: Promise; 5 | } 6 | 7 | /** 8 | * @param handleCatch rejected promises automatically to allow free usage 9 | */ 10 | export function deferred( 11 | handleCatch?: () => T | Promise 12 | ): Deferred { 13 | let resolve: Deferred["resolve"] | undefined = undefined, 14 | reject: Deferred["reject"] | undefined = undefined; 15 | const promise = new Promise((resolveFn, rejectFn) => { 16 | resolve = resolveFn; 17 | reject = rejectFn; 18 | }); 19 | ok(resolve); 20 | ok(reject); 21 | return { 22 | resolve, 23 | reject, 24 | promise: handleCatch ? promise.catch(handleCatch) : promise, 25 | }; 26 | } 27 | 28 | function ok(value: unknown): asserts value { 29 | if (!value) { 30 | throw new Error("Value not provided"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/test-actions.yml: -------------------------------------------------------------------------------- 1 | name: test-actions 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | test: 6 | runs-on: ubuntu-22.04 7 | env: 8 | NO_COVERAGE_BADGE_UPDATE: 1 9 | FLAGS: FETCH_SERVICE_DISABLE,POST_CONFIGURE_TEST,CONTINUE_ON_ERROR 10 | steps: 11 | - uses: actions/checkout@v5 12 | - uses: actions/setup-node@v5 13 | with: 14 | node-version: '24.x' 15 | registry-url: 'https://registry.npmjs.org' 16 | cache: "yarn" 17 | - uses: denoland/setup-deno@v2 18 | with: 19 | deno-version: 'v2.x' 20 | - uses: antongolub/action-setup-bun@v1 21 | - run: | 22 | yarn remove wpt || echo "no wpt" 23 | yarn install 24 | npx playwright install-deps 25 | - run: yarn build 26 | # yarn coverage === c8 + yarn test 27 | - run: yarn coverage 28 | - run: yarn test:deno 29 | - run: yarn test:bun 30 | -------------------------------------------------------------------------------- /src/events/navigation-current-entry-change-event.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NavigationCurrentEntryChangeEvent as Spec, 3 | NavigationCurrentEntryChangeEventInit, 4 | NavigationHistoryEntry, 5 | NavigationNavigationType 6 | } from "../spec/navigation"; 7 | 8 | export class NavigationCurrentEntryChangeEvent implements Spec { 9 | [key: number]: unknown; 10 | [key: string]: unknown; 11 | 12 | readonly from: NavigationHistoryEntry; 13 | readonly navigationType?: NavigationNavigationType; 14 | 15 | constructor(public type: "currententrychange", init: NavigationCurrentEntryChangeEventInit) { 16 | if (!init) { 17 | throw new TypeError("init required"); 18 | } 19 | if (!init.from) { 20 | throw new TypeError("from required"); 21 | } 22 | this.from = init.from; 23 | this.navigationType = init.navigationType ?? undefined; 24 | } 25 | } -------------------------------------------------------------------------------- /src/tests/examples/hash-change.ts: -------------------------------------------------------------------------------- 1 | import { Navigation, NavigateEvent } from "../../spec/navigation"; 2 | import { ok } from "../util"; 3 | 4 | export async function hashChangeExample(navigation: Navigation) { 5 | const navigate = new Promise((resolve, reject) => { 6 | navigation.addEventListener("navigate", resolve); 7 | navigation.addEventListener("navigateerror", (event) => 8 | reject(event.error) 9 | ); 10 | }); 11 | 12 | const expectedHash = `#h${Math.random()}`; 13 | 14 | navigation.navigate(expectedHash); 15 | 16 | const event = await navigate; 17 | 18 | console.log({ hashChange: event.hashChange, url: event.destination.url }); 19 | 20 | ok(event); 21 | ok(event.hashChange); 22 | 23 | ok(navigation.currentEntry.url); 24 | // console.log(navigation.current.url); 25 | ok(new URL(navigation.currentEntry.url).hash); 26 | ok(new URL(navigation.currentEntry.url).hash === expectedHash); 27 | } 28 | -------------------------------------------------------------------------------- /src/tests/same-document.ts: -------------------------------------------------------------------------------- 1 | import {NavigateEvent, Navigation} from "../navigation"; 2 | import {ok} from "../is"; 3 | 4 | { 5 | const navigation = new Navigation(); 6 | 7 | await navigation.navigate("/").finished 8 | 9 | const promise = new Promise( 10 | resolve => navigation.addEventListener( 11 | "navigate", 12 | (event) => { 13 | event.intercept({ 14 | async handler() { 15 | resolve(event); 16 | } 17 | }) 18 | }, 19 | { once: true } 20 | ) 21 | ); 22 | 23 | const other = `https://${Math.random()}.com/example`; 24 | 25 | await navigation.navigate(other).finished; 26 | 27 | const event = await promise; 28 | 29 | console.log(event.destination); 30 | 31 | ok(event.destination.url === other); 32 | ok(event.destination.sameDocument === false); 33 | 34 | 35 | } -------------------------------------------------------------------------------- /src/event-target/event.ts: -------------------------------------------------------------------------------- 1 | export interface Event { 2 | type: Name; 3 | parallel?: boolean; 4 | signal?: { 5 | aborted: boolean; 6 | }; 7 | [key: string]: unknown; 8 | [key: number]: unknown; 9 | 10 | originalEvent?: Event 11 | } 12 | 13 | export function isEvent(value: unknown): value is Event { 14 | function isLike(value: unknown): value is { type: unknown } { 15 | return !!value; 16 | } 17 | return ( 18 | isLike(value) && 19 | (typeof value.type === "string" || typeof value.type === "symbol") 20 | ); 21 | } 22 | 23 | export function assertEvent( 24 | value: unknown, 25 | type?: E["type"] 26 | ): asserts value is E { 27 | if (!isEvent(value)) { 28 | throw new Error("Expected event"); 29 | } 30 | if (typeof type !== "undefined" && value.type !== type) { 31 | throw new Error( 32 | `Expected event type ${String(type)}, got ${value.type.toString()}` 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "lib": [ 5 | "es2018", 6 | "esnext", 7 | "dom", 8 | "dom.iterable" 9 | ], 10 | "types": [ 11 | "node", 12 | "urlpattern-polyfill" 13 | ], 14 | "esModuleInterop": true, 15 | "target": "esnext", 16 | "noImplicitAny": true, 17 | "downlevelIteration": true, 18 | "moduleResolution": "node", 19 | "declaration": true, 20 | "sourceMap": true, 21 | "outDir": "esnext", 22 | "baseUrl": "./src", 23 | "jsx": "react", 24 | "jsxFactory": "h", 25 | "jsxFragmentFactory": "createFragment", 26 | "allowJs": true, 27 | "paths": { 28 | } 29 | }, 30 | "include": [ 31 | "src/routes/*", 32 | "src/**/*" 33 | ], 34 | "typeRoots": [ 35 | "src/types" 36 | ], 37 | "exclude": [ 38 | 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /src/tests/wpt/current-entry/index.ts: -------------------------------------------------------------------------------- 1 | import {Navigation} from "../../../navigation"; 2 | import {NavigationSync} from "../../../history"; 3 | import {ok} from "../../util"; 4 | import {transition} from "../../../transition"; 5 | 6 | export default 1; 7 | 8 | { 9 | interface State { 10 | key: string; 11 | } 12 | 13 | const navigation = new Navigation(); 14 | 15 | await navigation.navigate("/").finished; 16 | 17 | const history = new NavigationSync({ 18 | navigation 19 | }); 20 | 21 | ok(!history.state); 22 | 23 | const newState = { 24 | key: `${Math.random()}` 25 | } 26 | 27 | navigation.updateCurrentEntry({ state: newState }); 28 | 29 | const state = navigation.currentEntry.getState(); 30 | 31 | console.log({ state }); 32 | 33 | ok(state); 34 | ok(state.key === newState.key); 35 | ok(state !== newState); // Should be cloning our state object 36 | 37 | // updateCurrentEntry should have no effect on history.state 38 | ok(!history.state); 39 | 40 | await transition(navigation); 41 | 42 | } -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.192.0/containers/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 12, 14, 16 8 | "args": { 9 | "VARIANT": "16" 10 | } 11 | }, 12 | 13 | // Set *default* container specific settings.json values on container create. 14 | "settings": {}, 15 | 16 | 17 | // Add the IDs of extensions you want installed when the container is created. 18 | "extensions": [ 19 | "dbaeumer.vscode-eslint" 20 | ], 21 | 22 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 23 | // "forwardPorts": [], 24 | 25 | // Use 'postCreateCommand' to run commands after the container is created. 26 | // "postCreateCommand": "yarn install", 27 | 28 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 29 | "remoteUser": "node" 30 | } 31 | -------------------------------------------------------------------------------- /src/tests/examples/fetch.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from "@opennetwork/http-representation"; 2 | import { RespondEvent } from "../../event-target/respond-event"; 3 | import { deferred } from "../../util/deferred"; 4 | import { dispatchEvent } from "../../event-target/global"; 5 | 6 | export interface FetchEvent extends RespondEvent<"fetch", Response> { 7 | request: Request; 8 | } 9 | 10 | export interface RequestInit { 11 | body?: string; 12 | headers?: Record; 13 | method?: "get" | "post" | "put" | "delete"; 14 | signal?: AbortSignal; 15 | } 16 | 17 | export async function fetch(url: string, init?: RequestInit) { 18 | const request = new Request( 19 | new URL(url, "https://example.com").toString(), 20 | init 21 | ); 22 | const { resolve, reject, promise } = deferred(); 23 | const event: FetchEvent = { 24 | type: "fetch", 25 | request, 26 | signal: init?.signal, 27 | respondWith(value) { 28 | Promise.resolve(value).then(resolve, reject); 29 | }, 30 | }; 31 | await dispatchEvent(event); 32 | return await promise; 33 | } 34 | -------------------------------------------------------------------------------- /src/navigation-errors.ts: -------------------------------------------------------------------------------- 1 | export interface AbortError extends Error { 2 | // Shown here 3 | // https://dom.spec.whatwg.org/#aborting-ongoing-activities 4 | // https://webidl.spec.whatwg.org/#aborterror 5 | name: "AbortError"; 6 | } 7 | 8 | export class AbortError extends Error { 9 | constructor(message?: string) { 10 | super(`AbortError${message ? `: ${message}` : ""}`); 11 | this.name = "AbortError"; 12 | } 13 | } 14 | 15 | export function isAbortError(error: unknown): error is AbortError { 16 | return error instanceof Error && error.name === "AbortError"; 17 | } 18 | 19 | export interface InvalidStateError extends Error { 20 | // Following how AbortError is named 21 | name: "InvalidStateError"; 22 | } 23 | 24 | export class InvalidStateError extends Error { 25 | constructor(message?: string) { 26 | super(`InvalidStateError${message ? `: ${message}` : ""}`); 27 | this.name = "InvalidStateError"; 28 | } 29 | } 30 | 31 | export function isInvalidStateError( 32 | error: unknown 33 | ): error is InvalidStateError { 34 | return error instanceof Error && error.name === "InvalidStateError"; 35 | } 36 | -------------------------------------------------------------------------------- /src/tests/dependencies-input.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start */ 2 | export const DefaultDependencies = [ 3 | "@opennetwork/http-representation", 4 | "@virtualstate/astro-renderer", 5 | "@virtualstate/dom", 6 | "@virtualstate/examples", 7 | "@virtualstate/fringe", 8 | "@virtualstate/promise", 9 | "@virtualstate/promise/the-thing", 10 | "@virtualstate/focus", 11 | "@virtualstate/kdl", 12 | "@virtualstate/focus", 13 | "@virtualstate/focus/static-h", 14 | "@virtualstate/hooks", 15 | "@virtualstate/hooks-extended", 16 | "@virtualstate/union", 17 | "@virtualstate/x", 18 | "@virtualstate/navigation", 19 | "@virtualstate/navigation/polyfill", 20 | "@virtualstate/navigation/event-target/sync", 21 | "@virtualstate/navigation/event-target/async", 22 | "@virtualstate/composite-key", 23 | "dom-lite", 24 | "iterable", 25 | "urlpattern-polyfill" 26 | ] as const; 27 | export const DefaultImportMap = Object.fromEntries( 28 | DefaultDependencies.filter( 29 | (key: string) => typeof key === "string" && key 30 | ).map((key: string) => [key, `https://cdn.skypack.dev/${key}`]) 31 | ); 32 | 33 | export default DefaultDependencies; 34 | -------------------------------------------------------------------------------- /src/tests/wpt/history/index.ts: -------------------------------------------------------------------------------- 1 | import {defer} from "@virtualstate/promise"; 2 | import {Navigation} from "../../../navigation"; 3 | import {NavigationSync} from "../../../history"; 4 | import {ok} from "../../util"; 5 | import {EventTargetListenersMatch} from "../../../event-target"; 6 | import {transition} from "../../../transition"; 7 | 8 | export default 1; 9 | 10 | { 11 | const { resolve, promise } = defer(); 12 | 13 | const navigation = new Navigation(); 14 | 15 | await navigation.navigate("/").finished; 16 | 17 | const history = new NavigationSync({ 18 | navigation 19 | }); 20 | 21 | navigation.oncurrententrychange = resolve; 22 | 23 | const listeners = navigation[EventTargetListenersMatch]("currententrychange"); 24 | console.log(listeners); 25 | ok(listeners.length) 26 | 27 | const randomData = Math.random(); 28 | 29 | history.replaceState(randomData, "", "#1"); 30 | 31 | await promise; 32 | 33 | await transition(navigation); 34 | 35 | const { hash } = new URL(navigation.currentEntry.url); 36 | 37 | ok(hash === "#1"); 38 | ok(navigation.currentEntry.getState() === randomData); 39 | } -------------------------------------------------------------------------------- /src/defer.ts: -------------------------------------------------------------------------------- 1 | import { ok } from "./is"; 2 | 3 | export type DeferredStatus = "pending" | "fulfilled" | "rejected"; 4 | 5 | export interface Deferred { 6 | resolve(value: T): void; 7 | reject(reason: unknown): void; 8 | promise: Promise; 9 | readonly settled: boolean; 10 | readonly status: DeferredStatus; 11 | } 12 | 13 | export function defer(): Deferred { 14 | let resolve: Deferred["resolve"] | undefined = undefined, 15 | reject: Deferred["reject"] | undefined = undefined, 16 | settled = false, 17 | status: DeferredStatus = "pending"; 18 | const promise = new Promise((resolveFn, rejectFn) => { 19 | resolve = (value) => { 20 | status = "fulfilled"; 21 | settled = true; 22 | resolveFn(value); 23 | }; 24 | reject = (reason) => { 25 | status = "rejected"; 26 | settled = true; 27 | rejectFn(reason); 28 | }; 29 | }); 30 | ok(resolve); 31 | ok(reject); 32 | return { 33 | get settled() { 34 | return settled; 35 | }, 36 | get status() { 37 | return status; 38 | }, 39 | resolve, 40 | reject, 41 | promise, 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Axiom Applied Technologies and Development Limited 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/tests/destination-key-from-key.ts: -------------------------------------------------------------------------------- 1 | import {Navigation} from "../navigation"; 2 | import {ok} from "./util"; 3 | 4 | { 5 | 6 | const navigation = new Navigation(); 7 | 8 | let eventReceived = false, 9 | eventAsserted = false, 10 | error = undefined 11 | 12 | navigation.addEventListener("navigate", (event) => { 13 | 14 | eventReceived = true; 15 | const { transition: { from: { key: fromKey } }, currentEntry: { key: currentKey } } = navigation; 16 | 17 | try { 18 | ok(fromKey, "Expected navigation.transition.from.key"); 19 | ok(currentKey, "Expected navigation.currentEntry.key"); 20 | ok(currentKey === fromKey, "Expected event.destination.key to match navigation.currentEntry.key"); 21 | eventAsserted = true; 22 | } catch (caught) { 23 | error = caught; 24 | } 25 | 26 | event.intercept({ 27 | async handler() { 28 | 29 | } 30 | }) 31 | 32 | }); 33 | 34 | await navigation.navigate("/").finished; 35 | 36 | ok(eventReceived, "Event not received") 37 | ok(eventAsserted, error ?? "Event not asserted") 38 | 39 | } -------------------------------------------------------------------------------- /src/event-target/callback.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "./event"; 2 | import { EventDescriptor, EventDescriptorSymbol } from "./descriptor"; 3 | 4 | export interface SyncEventCallback { 5 | (event: TargetEvent): void; 6 | } 7 | 8 | export interface EventCallback { 9 | (event: E): Promise | unknown | void; 10 | } 11 | 12 | export function matchEventCallback( 13 | type: string | symbol, 14 | callback?: EventCallback | Function, 15 | options?: unknown 16 | ): (descriptor: EventDescriptor) => boolean { 17 | const optionsDescriptor = isOptionsDescriptor(options) ? options : undefined; 18 | return (descriptor) => { 19 | if (optionsDescriptor) { 20 | return optionsDescriptor === descriptor; 21 | } 22 | return ( 23 | (!callback || callback === descriptor.callback) && 24 | type === descriptor.type 25 | ); 26 | }; 27 | 28 | function isOptionsDescriptor(options: unknown): options is EventDescriptor { 29 | function isLike(options: unknown): options is Partial { 30 | return !!options; 31 | } 32 | return isLike(options) && options[EventDescriptorSymbol] === true; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/util/warnings.ts: -------------------------------------------------------------------------------- 1 | const THIS_WILL_BE_REMOVED = "This will be removed when the first major release of @virtualstate/navigation is published" 2 | 3 | const WARNINGS = { 4 | EVENT_INTERCEPT_HANDLER: `You are using a non standard interface, please update your code to use event.intercept({ async handler() {} })\n${THIS_WILL_BE_REMOVED}` 5 | } as const; 6 | 7 | type WarningKey = keyof typeof WARNINGS; 8 | 9 | let GLOBAL_IS_WARNINGS_IGNORED: boolean = false; 10 | let GLOBAL_IS_WARNINGS_TRACED: boolean = true; 11 | 12 | export function setIgnoreWarnings(ignore: boolean) { 13 | GLOBAL_IS_WARNINGS_IGNORED = ignore; 14 | } 15 | 16 | export function setTraceWarnings(ignore: boolean) { 17 | GLOBAL_IS_WARNINGS_TRACED = ignore; 18 | } 19 | 20 | export function logWarning(warning: WarningKey,...message: unknown[]) { 21 | if (GLOBAL_IS_WARNINGS_IGNORED) { 22 | return; 23 | } 24 | try { 25 | if (GLOBAL_IS_WARNINGS_TRACED) { 26 | console.trace(WARNINGS[warning], ...message); 27 | } else { 28 | console.warn(WARNINGS[warning], ...message); 29 | } 30 | } catch { 31 | // We don't want attempts to log causing issues 32 | // maybe we don't have a console 33 | } 34 | } -------------------------------------------------------------------------------- /src/event-target/event-target.ts: -------------------------------------------------------------------------------- 1 | import { AsyncEventTarget } from "./async-event-target"; 2 | 3 | const defaultEventTargetModule = { 4 | EventTarget: AsyncEventTarget, 5 | AsyncEventTarget, 6 | SyncEventTarget: AsyncEventTarget, 7 | } as const; 8 | 9 | let eventTargetModule: Record = defaultEventTargetModule; 10 | // 11 | // try { 12 | // eventTargetModule = await import("@virtualstate/navigation/event-target"); 13 | // console.log("Using @virtualstate/navigation/event-target", eventTargetModule); 14 | // } catch { 15 | // console.log("Using defaultEventTargetModule"); 16 | // eventTargetModule = defaultEventTargetModule; 17 | // } 18 | 19 | const EventTargetImplementation = 20 | eventTargetModule.EventTarget || eventTargetModule.SyncEventTarget || eventTargetModule.AsyncEventTarget; 21 | 22 | function assertEventTarget( 23 | target: unknown 24 | ): asserts target is AsyncEventTarget { 25 | if (typeof target !== "function") { 26 | throw new Error("Could not load EventTarget implementation"); 27 | } 28 | } 29 | 30 | export class EventTarget extends AsyncEventTarget { 31 | constructor(...args: unknown[]) { 32 | super(); 33 | if (EventTargetImplementation) { 34 | assertEventTarget(EventTargetImplementation); 35 | const { dispatchEvent } = new EventTargetImplementation(...args); 36 | this.dispatchEvent = dispatchEvent; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/tests/original-event.ts: -------------------------------------------------------------------------------- 1 | import {Navigation} from "../navigation"; 2 | import {defer} from "../defer"; 3 | import {NavigationOriginalEvent} from "../create-navigation-transition"; 4 | 5 | { 6 | 7 | const navigation = new Navigation(); 8 | 9 | navigation.addEventListener( 10 | "navigate", 11 | ({ intercept }) => { 12 | intercept() 13 | } 14 | ); 15 | 16 | const originalEventDeferred = defer() 17 | const originalEvent = { 18 | preventDefault: originalEventDeferred.resolve 19 | }; 20 | 21 | await navigation.navigate("/1", { 22 | [NavigationOriginalEvent]: originalEvent 23 | }).finished; 24 | 25 | await originalEventDeferred.promise; 26 | 27 | console.log("Original event preventDefault called"); 28 | 29 | } 30 | 31 | { 32 | 33 | const navigation = new Navigation(); 34 | 35 | navigation.addEventListener( 36 | "navigate", 37 | ({ preventDefault }) => { 38 | preventDefault() 39 | } 40 | ); 41 | 42 | const originalEventDeferred = defer() 43 | const originalEvent = { 44 | preventDefault: originalEventDeferred.resolve 45 | }; 46 | 47 | // preventDefault will abort the navigation 48 | navigation.navigate("/1", { 49 | [NavigationOriginalEvent]: originalEvent 50 | }); 51 | 52 | await originalEventDeferred.promise; 53 | 54 | console.log("Original event preventDefault called"); 55 | 56 | } -------------------------------------------------------------------------------- /src/event-target/global.ts: -------------------------------------------------------------------------------- 1 | import { EventTarget } from "./event-target"; 2 | import { Event } from "./event"; 3 | import { FetchEvent } from "../tests/examples/fetch"; 4 | import { EventTargetAddListenerOptions } from "./event-target-options"; 5 | import { EventCallback } from "./callback"; 6 | 7 | const globalEventTarget = new EventTarget(); 8 | 9 | export function dispatchEvent(event: Event) { 10 | return globalEventTarget.dispatchEvent(event); 11 | } 12 | 13 | export function addEventListener( 14 | type: "fetch", 15 | callback: EventCallback, 16 | options?: EventTargetAddListenerOptions 17 | ): void; 18 | export function addEventListener( 19 | type: string | symbol, 20 | callback: EventCallback, 21 | options?: EventTargetAddListenerOptions 22 | ): void; 23 | export function addEventListener( 24 | type: string | symbol, 25 | callback: Function, 26 | options?: EventTargetAddListenerOptions 27 | ): void { 28 | return globalEventTarget.addEventListener(type, callback, options); 29 | } 30 | 31 | export function removeEventListener( 32 | type: string | symbol, 33 | callback: EventCallback, 34 | options?: EventTargetAddListenerOptions 35 | ): void; 36 | export function removeEventListener( 37 | type: string | symbol, 38 | callback: Function, 39 | options?: EventTargetAddListenerOptions 40 | ): void; 41 | export function removeEventListener( 42 | type: string | symbol, 43 | callback: Function, 44 | options?: EventTargetAddListenerOptions 45 | ): void { 46 | return globalEventTarget.removeEventListener(type, callback, options); 47 | } 48 | -------------------------------------------------------------------------------- /src/await/index.ts: -------------------------------------------------------------------------------- 1 | import { NavigateEvent, Navigation, NavigationInterceptOptions } from "../spec/navigation"; 2 | import { getNavigation } from "../get-navigation"; 3 | import { createNavigationPromise } from "./create-promise"; 4 | import {isPromise, ok} from "../is"; 5 | 6 | export * from "./events"; 7 | export * from "./create-promise"; 8 | 9 | export function intercept(options?: NavigationInterceptOptions, navigation: Navigation = getNavigation()) { 10 | return createNavigationPromise( 11 | "navigate", 12 | navigation, 13 | options?.handler ? onNavigateWithHandler : onNavigateDirectIntercept 14 | ); 15 | 16 | function onNavigateDirectIntercept(event: NavigateEvent) { 17 | event.intercept(options); 18 | } 19 | 20 | function onNavigateWithHandler(event: NavigateEvent) { 21 | ok(options?.handler, "Expected options.handler"); 22 | const { handler, ...rest } = options; 23 | return new Promise( 24 | (resolve, reject) => { 25 | event.intercept({ 26 | ...rest, 27 | async handler() { 28 | try { 29 | const result = handler(); 30 | if (isPromise(result)) { 31 | await result; 32 | } 33 | resolve(); 34 | } catch (error) { 35 | reject(error) 36 | } 37 | } 38 | }); 39 | } 40 | ) 41 | } 42 | } -------------------------------------------------------------------------------- /src/event-target/signal-event.ts: -------------------------------------------------------------------------------- 1 | import { Event, isEvent } from "./event"; 2 | import { isAbortError } from "../navigation-errors"; 3 | 4 | export interface SignalEvent 5 | extends Event { 6 | signal: AbortSignal; 7 | } 8 | 9 | export function isAbortSignal(value: unknown): value is AbortSignal { 10 | function isAbortSignalLike( 11 | value: unknown 12 | ): value is Partial> { 13 | return typeof value === "object"; 14 | } 15 | return ( 16 | isAbortSignalLike(value) && 17 | typeof value.aborted === "boolean" && 18 | typeof value.addEventListener === "function" 19 | ); 20 | } 21 | 22 | export function isAbortController(value: unknown): value is AbortController { 23 | function isAbortControllerLike( 24 | value: unknown 25 | ): value is Partial> { 26 | return typeof value === "object"; 27 | } 28 | return ( 29 | isAbortControllerLike(value) && 30 | typeof value.abort === "function" && 31 | isAbortSignal(value.signal) 32 | ); 33 | } 34 | 35 | export function isSignalEvent(value: object): value is SignalEvent { 36 | function isSignalEventLike(value: object): value is { signal?: unknown } { 37 | return value.hasOwnProperty("signal"); 38 | } 39 | return ( 40 | isEvent(value) && isSignalEventLike(value) && isAbortSignal(value.signal) 41 | ); 42 | } 43 | 44 | export function isSignalHandled(event: Event, error: unknown) { 45 | if ( 46 | isSignalEvent(event) && 47 | event.signal.aborted && 48 | error instanceof Error && 49 | isAbortError(error) 50 | ) { 51 | return true; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /example/no-intercept-esm.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Polyfill Example 6 | 7 | 8 | 9 | 50 | 51 |

Current Url

52 | 53 | Current Page 54 | Navigation Page 55 | 56 |
57 | 58 | -------------------------------------------------------------------------------- /example/no-intercept-native.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Polyfill Example 6 | 7 | 8 | 9 | 52 | 53 |

Current Url

54 | 55 | Current Page 56 | Navigation Page 57 | 58 |
59 | 60 | -------------------------------------------------------------------------------- /src/noop-navigation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Navigation, 3 | NavigationEventMap, 4 | NavigationHistoryEntry, 5 | NavigationNavigateOptions, 6 | NavigationNavigationOptions, 7 | NavigationReloadOptions, 8 | NavigationResult, 9 | NavigationUpdateCurrentOptions, 10 | } from "./spec/navigation"; 11 | import { NavigationEventTarget } from "./navigation-event-target"; 12 | 13 | class NoOperationNavigationResult implements NavigationResult { 14 | committed: Promise = new Promise(() => {}); 15 | finished: Promise = new Promise(() => {}); 16 | } 17 | 18 | export class NoOperationNavigation 19 | extends NavigationEventTarget 20 | implements Navigation 21 | { 22 | readonly canGoBack: boolean = false; 23 | readonly canGoForward: boolean = false; 24 | 25 | back(options?: NavigationNavigationOptions): NavigationResult { 26 | return new NoOperationNavigationResult(); 27 | } 28 | 29 | entries(): NavigationHistoryEntry[] { 30 | return []; 31 | } 32 | 33 | forward(options?: NavigationNavigationOptions): NavigationResult { 34 | return new NoOperationNavigationResult(); 35 | } 36 | 37 | traverseTo(key: string, options?: NavigationNavigationOptions): NavigationResult { 38 | return new NoOperationNavigationResult(); 39 | } 40 | 41 | navigate(url: string, options?: NavigationNavigateOptions): NavigationResult { 42 | return new NoOperationNavigationResult(); 43 | } 44 | 45 | reload(options?: NavigationReloadOptions): NavigationResult { 46 | return new NoOperationNavigationResult(); 47 | } 48 | 49 | updateCurrentEntry(options: NavigationUpdateCurrentOptions): Promise; 50 | updateCurrentEntry(options: NavigationUpdateCurrentOptions): void; 51 | async updateCurrentEntry( 52 | options: NavigationUpdateCurrentOptions 53 | ): Promise { 54 | return undefined; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/tests/custom-state.ts: -------------------------------------------------------------------------------- 1 | import {Navigation} from "../navigation"; 2 | import {ok} from "../is"; 3 | 4 | { 5 | const sessionStorageMap = new Map(); 6 | 7 | const navigation = new Navigation({ 8 | getState({ id }) { 9 | console.log("getState"); 10 | const maybe = sessionStorageMap.get(id); 11 | if (!maybe) return undefined; // Or can return null here 12 | return maybe 13 | }, 14 | setState(entry) { 15 | console.log("setState"); 16 | const state = entry.getState(); 17 | const { id } = entry; 18 | sessionStorageMap.set(id, state); 19 | }, 20 | disposeState({ id }) { 21 | console.log("disposeState"); 22 | sessionStorageMap.delete(id); 23 | } 24 | }) 25 | 26 | ok(!sessionStorageMap.size); 27 | 28 | const initialState = `Test ${Math.random()}`; 29 | const { id: initialId } = await navigation.navigate("/1", { 30 | state: initialState 31 | }).finished; 32 | 33 | ok(sessionStorageMap.size); 34 | const storedValue = sessionStorageMap.get(initialId); 35 | ok(storedValue === initialState); 36 | 37 | const nextState = `Another ${Math.random()}`; 38 | const { id: nextId, key: nextKey } = await navigation.navigate("/2", { 39 | state: nextState 40 | }).finished; 41 | ok(sessionStorageMap.size === 2); 42 | const nextStoredValue = sessionStorageMap.get(nextId); 43 | ok(nextStoredValue === nextState); 44 | 45 | const { id: finalId, key: finalKey } = await navigation.navigate("/2", { 46 | state: Math.random(), 47 | history: "replace" 48 | }).finished; 49 | ok(nextKey === finalKey); 50 | ok(nextId !== finalId); 51 | ok(sessionStorageMap.size === 2); 52 | ok(sessionStorageMap.has(initialId)); 53 | ok(!sessionStorageMap.has(nextId)); 54 | ok(sessionStorageMap.has(finalId)); 55 | } -------------------------------------------------------------------------------- /src/tests/dependencies.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start */ 2 | import { DefaultImportMap } from "./dependencies-input"; 3 | 4 | export interface DependenciesContentOptions { 5 | imports?: Record; 6 | } 7 | 8 | export const DependenciesJSON = { 9 | imports: { 10 | ...DefaultImportMap, 11 | "urlpattern-polyfill": "https://cdn.skypack.dev/urlpattern-polyfill@v8.0.2", 12 | "deno:std@latest": "https://cdn.skypack.dev/@edwardmx/noop", 13 | "@virtualstate/nop": "https://cdn.skypack.dev/@edwardmx/noop", 14 | "@virtualstate/navigation/event-target": 15 | "https://cdn.skypack.dev/@virtualstate/navigation/event-target/async-event-target", 16 | iterable: "https://cdn.skypack.dev/iterable@6.0.1-beta.5", 17 | "https://cdn.skypack.dev/-/iterable@v5.7.0-CNtyuMJo9f2zFu6CuB1D/dist=es2019,mode=imports/optimized/iterable.js": 18 | "https://cdn.skypack.dev/iterable@6.0.1-beta.5", 19 | }, 20 | }; 21 | export const DependenciesHTML = ``; 26 | 27 | export const DependenciesSyncJSON = { 28 | imports: { 29 | ...DefaultImportMap, 30 | "urlpattern-polyfill": "https://cdn.skypack.dev/urlpattern-polyfill@v8.0.2", 31 | "deno:std@latest": "https://cdn.skypack.dev/@edwardmx/noop", 32 | "@virtualstate/nop": "https://cdn.skypack.dev/@edwardmx/noop", 33 | "@virtualstate/navigation/event-target": 34 | "https://cdn.skypack.dev/@virtualstate/navigation/event-target/sync-event-target", 35 | iterable: "https://cdn.skypack.dev/iterable@6.0.1-beta.5", 36 | "https://cdn.skypack.dev/-/iterable@v5.7.0-CNtyuMJo9f2zFu6CuB1D/dist=es2019,mode=imports/optimized/iterable.js": 37 | "https://cdn.skypack.dev/iterable@6.0.1-beta.5", 38 | }, 39 | }; 40 | export const DependenciesSyncHTML = ``; 45 | -------------------------------------------------------------------------------- /src/navigation-event-target.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Event, 3 | EventCallback, 4 | EventTarget, 5 | EventTargetAddListenerOptions, 6 | } from "./event-target"; 7 | 8 | export class NavigationEventTarget extends EventTarget { 9 | addEventListener( 10 | type: K, 11 | listener: EventCallback, 12 | options?: boolean | EventTargetAddListenerOptions 13 | ): void; 14 | addEventListener( 15 | type: string | symbol, 16 | listener: EventCallback, 17 | options?: boolean | EventTargetAddListenerOptions 18 | ): void; 19 | addEventListener( 20 | type: string | symbol, 21 | listener: Function | EventCallback, 22 | options?: boolean | EventTargetAddListenerOptions 23 | ): void { 24 | assertEventCallback(listener); 25 | return super.addEventListener( 26 | type, 27 | listener, 28 | typeof options === "boolean" ? { once: options } : options 29 | ); 30 | function assertEventCallback( 31 | listener: unknown 32 | ): asserts listener is EventCallback { 33 | if (typeof listener !== "function") 34 | throw new Error("Please us the function variant of event listener"); 35 | } 36 | } 37 | 38 | removeEventListener( 39 | type: string | symbol, 40 | listener: EventCallback, 41 | options?: unknown 42 | ): void; 43 | removeEventListener( 44 | type: string | symbol, 45 | callback: Function, 46 | options?: unknown 47 | ): void; 48 | removeEventListener( 49 | type: string | symbol, 50 | listener: Function | EventCallback, 51 | options?: boolean | EventTargetAddListenerOptions 52 | ): void { 53 | assertEventCallback(listener); 54 | return super.removeEventListener(type, listener); 55 | function assertEventCallback( 56 | listener: unknown 57 | ): asserts listener is EventCallback { 58 | if (typeof listener !== "function") 59 | throw new Error("Please us the function variant of event listener"); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/tests/transition.ts: -------------------------------------------------------------------------------- 1 | import {Navigation} from "../navigation"; 2 | import {transition} from "../transition"; 3 | import {ok} from "./util"; 4 | 5 | { 6 | const navigation = new Navigation(); 7 | 8 | navigation.navigate("/a"); // This one should be cancelled 9 | 10 | const promise = transition(navigation); 11 | 12 | navigation.navigate("/b"); // This one should be the final entry 13 | 14 | // This should resolve all transitions, so should wait until /b 15 | await promise; 16 | 17 | console.log(navigation.currentEntry); 18 | ok(navigation.currentEntry.url.endsWith("/b")); 19 | 20 | } 21 | 22 | { 23 | const navigation = new Navigation(); 24 | 25 | navigation.addEventListener("navigate", event => event.intercept({ 26 | handler: () => new Promise(resolve => setTimeout(resolve, 10)) 27 | })); 28 | 29 | navigation.navigate("/a"); // This one should be cancelled 30 | navigation.navigate("/b"); // This one should be cancelled 31 | 32 | 33 | queueMicrotask(() => { 34 | navigation.navigate("/c"); // This one should be the final entry 35 | }) 36 | 37 | await transition(navigation); 38 | 39 | console.log(navigation.currentEntry); 40 | ok(navigation.currentEntry.url.endsWith("/c")); 41 | 42 | 43 | } 44 | 45 | { 46 | 47 | 48 | const navigation = new Navigation(); 49 | 50 | navigation.navigate("/initial"); 51 | 52 | await transition(navigation); 53 | 54 | navigation.addEventListener("navigate", event => event.preventDefault()); 55 | 56 | navigation.navigate("/a"); // This one should be cancelled 57 | navigation.navigate("/b"); // This one should be cancelled 58 | navigation.navigate("/c"); // This one should be the final entry 59 | 60 | await transition(navigation); 61 | 62 | console.log(navigation.currentEntry); 63 | ok(navigation.currentEntry.url.endsWith("/initial")); 64 | 65 | 66 | } 67 | { 68 | const navigation = new Navigation<{ withState: true }>() 69 | 70 | await transition(navigation); 71 | } -------------------------------------------------------------------------------- /src/tests/await/index.ts: -------------------------------------------------------------------------------- 1 | import {isWindowNavigation} from "../util"; 2 | import {getNavigation} from "../../get-navigation"; 3 | import {currentEntryChange, intercept, navigate, navigateSuccess} from "../../await"; 4 | import {defer} from "../../defer"; 5 | import {Navigation} from "../../navigation"; 6 | 7 | if (!isWindowNavigation(getNavigation())) { 8 | 9 | const navigation = getNavigation() 10 | 11 | 12 | async function app() { 13 | 14 | console.log("Waiting for navigate"); 15 | 16 | await Promise.all([ 17 | navigate, 18 | currentEntryChange 19 | ]); 20 | } 21 | 22 | const promise = app().catch(console.error); 23 | 24 | console.log("navigate"); 25 | await new Promise(resolve => { 26 | queueMicrotask(() => { 27 | navigation.navigate("/").finished.then(resolve) 28 | }) 29 | }); 30 | 31 | await promise; 32 | 33 | console.log("Done"); 34 | 35 | } 36 | 37 | { 38 | const navigation = new Navigation() 39 | 40 | const promise = intercept({ 41 | async handler() { 42 | // Do every navigate loading, refresh indicator is spinning 43 | // Url might change during this time 44 | }, 45 | commit: "after-transition" 46 | }, navigation); 47 | 48 | async function page() { 49 | 50 | const { commit } = await promise; 51 | 52 | // Do extra content loading, refresh indicator is spinning 53 | // Url has not changed 54 | 55 | // Setting to manual allows us to control when the url and entry actually changes 56 | commit(); 57 | 58 | // Do after content loaded, refresh indicator is not spinning 59 | // Url has changed 60 | 61 | } 62 | 63 | const pagePromise = page(); 64 | 65 | // Will happen at some other time 66 | await new Promise(resolve => { 67 | queueMicrotask(() => { 68 | navigation.navigate("/").finished.then(resolve) 69 | }) 70 | }); 71 | 72 | await pagePromise; 73 | 74 | } -------------------------------------------------------------------------------- /src/global-window.ts: -------------------------------------------------------------------------------- 1 | import {NavigationHistory} from "./history"; 2 | 3 | 4 | export interface ElementPrototype { 5 | new(): ElementPrototype; 6 | ownerDocument: unknown; 7 | parentElement?: ElementPrototype; 8 | host?: ElementPrototype; 9 | matches(string: string): boolean; 10 | getAttribute(name: string): string; 11 | setAttribute(name: string, value: string): void; 12 | cloneNode(): ElementPrototype; 13 | click(): void; 14 | submit(): void; 15 | getRootNode(): ElementPrototype | null; 16 | } 17 | 18 | export interface HTMLAnchorElementPrototype extends ElementPrototype { 19 | download: string; 20 | href: string; 21 | } 22 | 23 | export interface HTMLFormElementPrototype extends ElementPrototype { 24 | method: string; 25 | action: string; 26 | } 27 | 28 | export interface EventPrototype { 29 | target: ElementPrototype; 30 | composedPath?(): ElementPrototype[]; 31 | defaultPrevented: unknown; 32 | submitter: Record; 33 | } 34 | 35 | export interface MouseEventPrototype extends EventPrototype { 36 | button: number; 37 | metaKey: unknown; 38 | altKey: unknown; 39 | ctrlKey: unknown; 40 | shiftKey: unknown; 41 | } 42 | 43 | export interface SubmitEventPrototype extends EventPrototype { 44 | 45 | } 46 | 47 | export interface PopStateEventPrototype extends EventPrototype { 48 | state: object; 49 | originalState?: object; 50 | } 51 | 52 | export interface WindowLike { 53 | name?: string 54 | history?: NavigationHistory 55 | location?: { 56 | href?: string 57 | reload(): void 58 | } 59 | PopStateEvent?: { 60 | prototype: { 61 | state: object 62 | } 63 | } 64 | addEventListener(type: "submit", fn: (event: SubmitEventPrototype) => void): void; 65 | addEventListener(type: "click", fn: (event: MouseEventPrototype) => void): void; 66 | addEventListener(type: "popstate", fn: (event: PopStateEventPrototype) => void): void; 67 | document?: unknown 68 | } 69 | 70 | declare var window: WindowLike | undefined; 71 | 72 | export const globalWindow = typeof window === "undefined" ? undefined : window; 73 | -------------------------------------------------------------------------------- /src/event-target/sync-event-target.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "./event"; 2 | import { isSignalEvent, isSignalHandled } from "./signal-event"; 3 | import { AbortError } from "../navigation-errors"; 4 | import { 5 | EventTargetListenersMatch, 6 | EventTargetListenersThis, 7 | } from "./event-target-options"; 8 | import { EventTargetListeners } from "./event-target-listeners"; 9 | 10 | export interface SyncEventTarget extends EventTargetListeners { 11 | new (thisValue?: unknown): SyncEventTarget; 12 | dispatchEvent(event: Event): void | Promise; 13 | } 14 | 15 | export class SyncEventTarget 16 | extends EventTargetListeners 17 | implements SyncEventTarget 18 | { 19 | readonly [EventTargetListenersThis]: unknown; 20 | 21 | constructor(thisValue: unknown = undefined) { 22 | super(); 23 | this[EventTargetListenersThis] = thisValue; 24 | } 25 | 26 | dispatchEvent(event: Event) { 27 | const listeners = this[EventTargetListenersMatch]?.(event.type) ?? []; 28 | 29 | // Don't even dispatch an aborted event 30 | if (isSignalEvent(event) && event.signal.aborted) { 31 | throw new AbortError(); 32 | } 33 | 34 | for (let index = 0; index < listeners.length; index += 1) { 35 | const descriptor = listeners[index]; 36 | 37 | // Remove the listener before invoking the callback 38 | // This ensures that inside of the callback causes no more additional event triggers to this 39 | // listener 40 | if (descriptor.once) { 41 | // by passing the descriptor as the options, we get an internal redirect 42 | // that forces an instance level object equals, meaning 43 | // we will only remove _this_ descriptor! 44 | this.removeEventListener( 45 | descriptor.type, 46 | descriptor.callback, 47 | descriptor 48 | ); 49 | } 50 | 51 | try { 52 | descriptor.callback.call(this[EventTargetListenersThis] ?? this, event); 53 | } catch (error) { 54 | if (!isSignalHandled(event, error)) { 55 | throw error; 56 | } 57 | } 58 | if (isSignalEvent(event) && event.signal.aborted) { 59 | throw new AbortError(); 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/tests/routes/jsx.tsx: -------------------------------------------------------------------------------- 1 | import {NavigateEvent, Navigation} from "../../navigation"; 2 | import { Router } from "../../routes"; 3 | import { children, h, name, properties } from "@virtualstate/focus"; 4 | import { defer } from "@virtualstate/promise"; 5 | import { ok } from "../util"; 6 | 7 | declare global { 8 | namespace JSX { 9 | interface IntrinsicElements 10 | extends Record> {} 11 | } 12 | } 13 | 14 | { 15 | interface State { 16 | title: string; 17 | content: string; 18 | } 19 | 20 | const navigation = new Navigation(); 21 | const { route, then } = new Router>(navigation); 22 | 23 | const { resolve: render, promise } = defer(); 24 | 25 | route( 26 | "/post/:id", 27 | ( 28 | { destination }, 29 | { 30 | pathname: { 31 | groups: { id }, 32 | }, 33 | } 34 | ) => { 35 | const state = destination.getState(); 36 | return ( 37 |
38 | 39 |

{state.title}

40 |

{state.content}

41 |
42 | ); 43 | } 44 | ); 45 | 46 | then(render); 47 | 48 | const id = `${Math.random()}`; 49 | 50 | navigation.navigate(`/post/${id}`, { 51 | state: { 52 | title: "Hello!", 53 | content: "Here is some content for ya!", 54 | }, 55 | }); 56 | 57 | const [node] = await Promise.all([promise, navigation.transition?.finished]); 58 | 59 | console.log({ node }); 60 | 61 | ok(node); 62 | ok(name(node) === "main"); 63 | 64 | const { 65 | h1: [h1], 66 | p: [p], 67 | meta: [meta], 68 | } = children(node).group(name); 69 | 70 | const idMeta = properties(await meta); 71 | 72 | ok(idMeta); 73 | 74 | ok(idMeta.name === "id"); 75 | ok(idMeta.value === id); 76 | 77 | const [h1Text] = await children(h1); 78 | const [pText] = await children(p); 79 | 80 | console.log({ 81 | h1Text, 82 | pText, 83 | idMeta, 84 | }); 85 | 86 | ok(h1Text); 87 | ok(pText); 88 | 89 | const state: State = navigation.currentEntry.getState(); 90 | 91 | ok(state); 92 | 93 | ok(h1Text === state.title); 94 | ok(pText === state.content); 95 | } 96 | -------------------------------------------------------------------------------- /src/tests/examples/jsx.tsx: -------------------------------------------------------------------------------- 1 | import { Navigation } from "../../spec/navigation"; 2 | import { AsyncEventTarget } from "../../event-target"; 3 | import { ok } from "../util"; 4 | import { h } from "@virtualstate/focus/static-h"; 5 | import { toKDLString } from "@virtualstate/kdl"; 6 | 7 | const React = { 8 | createElement: h, 9 | }; 10 | 11 | export async function jsxExample(navigation: Navigation) { 12 | interface State { 13 | dateTaken?: string; 14 | caption?: string; 15 | } 16 | 17 | function Component({ caption, dateTaken }: State, input: unknown) { 18 | return h( 19 | "figure", 20 | {}, 21 | h("date", {}, dateTaken), 22 | h("figcaption", {}, caption), 23 | input 24 | ); 25 | // return ( 26 | //
27 | // {dateTaken} 28 | //
{caption}
29 | // {input} 30 | //
31 | // ) 32 | } 33 | 34 | const body: AsyncEventTarget & { innerKDL?: string } = new AsyncEventTarget(); 35 | 36 | let bodyUpdated!: Promise; 37 | 38 | navigation.addEventListener("currententrychange", async (event) => { 39 | await (bodyUpdated = handler()); 40 | async function handler() { 41 | body.innerKDL = await new Promise((resolve, reject) => 42 | toKDLString( 43 | ()} /> 44 | ).then(resolve, reject) 45 | ); 46 | console.log({ body: body.innerKDL }); 47 | } 48 | }); 49 | 50 | ok(!body.innerKDL); 51 | 52 | await navigation.navigate("/", { 53 | state: { 54 | dateTaken: new Date().toISOString(), 55 | caption: `Photo taken on the date ${new Date().toDateString()}`, 56 | }, 57 | }).finished; 58 | 59 | // console.log(body.innerHTML); 60 | ok(bodyUpdated); 61 | await bodyUpdated; 62 | 63 | ok(body.innerKDL); 64 | 65 | const updatedCaption = `Photo ${Math.random()}`; 66 | 67 | ok(bodyUpdated); 68 | await bodyUpdated; 69 | ok(!body.innerKDL?.includes(updatedCaption)); 70 | 71 | await navigation.updateCurrentEntry({ 72 | state: { 73 | ...navigation.currentEntry?.getState(), 74 | caption: updatedCaption, 75 | }, 76 | }); 77 | 78 | ok(bodyUpdated); 79 | await bodyUpdated; 80 | // This test will fail if async resolution is not supported. 81 | ok(body.innerKDL?.includes(updatedCaption)); 82 | } 83 | -------------------------------------------------------------------------------- /src/events/navigate-event.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NavigateEvent as Spec, 3 | NavigateEventInit, 4 | NavigationDestination, 5 | NavigationIntercept, 6 | NavigationNavigationType 7 | } from "../spec/navigation"; 8 | 9 | export class NavigateEvent implements Spec { 10 | [key: number]: unknown; 11 | [key: string]: unknown; 12 | 13 | readonly canIntercept: boolean; 14 | /** 15 | * @deprecated 16 | */ 17 | readonly canTransition: boolean; 18 | readonly destination: NavigationDestination; 19 | readonly downloadRequest?: string; 20 | readonly formData?: FormData; 21 | readonly hashChange: boolean; 22 | readonly info: unknown; 23 | readonly signal: AbortSignal; 24 | readonly userInitiated: boolean; 25 | readonly navigationType: NavigationNavigationType; 26 | 27 | constructor(public type: "navigate", init: NavigateEventInit) { 28 | if (!init) { 29 | throw new TypeError("init required"); 30 | } 31 | if (!init.destination) { 32 | throw new TypeError("destination required"); 33 | } 34 | if (!init.signal) { 35 | throw new TypeError("signal required"); 36 | } 37 | this.canIntercept = init.canIntercept ?? false; 38 | this.canTransition = init.canIntercept ?? false; 39 | this.destination = init.destination; 40 | this.downloadRequest = init.downloadRequest; 41 | this.formData = init.formData; 42 | this.hashChange = init.hashChange ?? false; 43 | this.info = init.info; 44 | this.signal = init.signal; 45 | this.userInitiated = init.userInitiated ?? false; 46 | this.navigationType = init.navigationType ?? "push"; 47 | } 48 | 49 | 50 | commit(): void { 51 | throw new Error("Not implemented"); 52 | } 53 | 54 | intercept(options?: NavigationIntercept): void { 55 | throw new Error("Not implemented"); 56 | } 57 | 58 | preventDefault(): void { 59 | throw new Error("Not implemented"); 60 | } 61 | 62 | reportError(reason: unknown): void { 63 | throw new Error("Not implemented"); 64 | } 65 | 66 | scroll(): void { 67 | throw new Error("Not implemented"); 68 | } 69 | 70 | /** 71 | * @deprecated 72 | */ 73 | transitionWhile(options?: NavigationIntercept): void { 74 | return this.intercept(options); 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /src/routes/types.ts: -------------------------------------------------------------------------------- 1 | import {URLPattern} from "urlpattern-polyfill"; 2 | import {Event} from "../event-target"; 3 | import {NavigateEvent} from "../spec/navigation"; 4 | import {Router} from "./router"; 5 | 6 | type NonNil = T extends null | undefined ? never : T; 7 | export type URLPatternResult = NonNil>; 8 | 9 | export type RouteFnReturn = Promise | R; 10 | 11 | export interface Fn { 12 | (...args: unknown[]): RouteFnReturn; 13 | } 14 | 15 | export interface RouteFn { 16 | (event: E, match?: URLPatternResult): RouteFnReturn; 17 | } 18 | 19 | export interface ErrorFn { 20 | ( 21 | error: unknown, 22 | event: E, 23 | match?: URLPatternResult 24 | ): RouteFnReturn; 25 | } 26 | 27 | export interface PatternErrorFn { 28 | ( 29 | error: unknown, 30 | event: E, 31 | match: URLPatternResult 32 | ): RouteFnReturn; 33 | } 34 | 35 | export interface ThenFn { 36 | (value: R, event: E, match?: URLPatternResult): RouteFnReturn; 37 | } 38 | 39 | export interface PatternThenFn { 40 | (value: R, event: E, match: URLPatternResult): RouteFnReturn; 41 | } 42 | 43 | export interface PatternRouteFn { 44 | (event: E, match: URLPatternResult): RouteFnReturn; 45 | } 46 | 47 | export interface Route { 48 | string?: string; 49 | pattern?: URLPattern; 50 | fn?: Fn; 51 | router?: Router; 52 | } 53 | 54 | export type RouteType = "route" | "reject" | "resolve"; 55 | 56 | export interface RouteRecord extends Record[]> { 57 | router: Route[]; 58 | } 59 | 60 | export interface RouterListeningFn { 61 | (event: E): RouteFnReturn 62 | } 63 | 64 | export interface RouterListenFn { 65 | (fn: RouterListeningFn): void; 66 | } 67 | 68 | export interface EventListenerTarget { 69 | addEventListener(type: E["type"], handler: RouterListeningFn): void; 70 | removeEventListener(type: E["type"], handler: RouterListeningFn): void; 71 | } 72 | 73 | export type RouterListenTarget = RouterListenFn | EventListenerTarget -------------------------------------------------------------------------------- /src/tests/examples/url-pattern.ts: -------------------------------------------------------------------------------- 1 | import { Navigation } from "../../navigation"; 2 | import { URLPattern } from "../../routes/url-pattern"; 3 | import { EventTarget } from "../../event-target"; 4 | import { assert, ok } from "../util"; 5 | 6 | export async function urlPatternExample(navigation: Navigation) { 7 | const unexpectedPage = `${Math.random()}`; 8 | const body: EventTarget & { innerHTML?: string } = new EventTarget(); 9 | body.innerHTML = ""; 10 | 11 | navigation.addEventListener( 12 | "navigate", 13 | ({ destination, intercept }) => { 14 | return intercept({ 15 | handler 16 | }); 17 | 18 | async function handler() { 19 | const identifiedTest = new URLPattern({ 20 | pathname: "/test/:id", 21 | }); 22 | if (identifiedTest.test(destination.url)) { 23 | body.innerHTML = destination.getState<{ 24 | innerHTML: string; 25 | }>().innerHTML; 26 | } else { 27 | throw new Error(unexpectedPage); 28 | } 29 | } 30 | } 31 | ); 32 | 33 | const expectedHTML = `${Math.random()}`; 34 | 35 | await navigation.navigate("/test/1", { 36 | state: { 37 | innerHTML: expectedHTML, 38 | }, 39 | }).finished; 40 | 41 | ok(body.innerHTML === expectedHTML); 42 | 43 | const error = await navigation 44 | .navigate("/photos/1") 45 | .finished.catch((error) => error); 46 | 47 | assert(error); 48 | ok(error.message === unexpectedPage); 49 | 50 | await navigation.navigate("/test/2", { 51 | state: { 52 | innerHTML: `${expectedHTML}.2`, 53 | }, 54 | }).finished; 55 | 56 | ok(body.innerHTML === `${expectedHTML}.2`); 57 | 58 | console.log({ body }, navigation); 59 | } 60 | 61 | export async function urlPatternLoadBooksExample(navigation: Navigation) { 62 | const booksPattern = new URLPattern({ pathname: "/books/:id" }); 63 | let bookId; 64 | navigation.addEventListener( 65 | "navigate", 66 | async ({ destination, intercept }) => { 67 | const match = booksPattern.exec(destination.url); 68 | if (match) { 69 | intercept({ 70 | handler: transition 71 | }); 72 | } 73 | 74 | async function transition() { 75 | console.log("load book", match.pathname.groups.id); 76 | bookId = match.pathname.groups.id; 77 | } 78 | } 79 | ); 80 | const id = `${Math.random()}`; 81 | await navigation.navigate(`/books/${id}`).finished; 82 | assert(id === bookId); 83 | } 84 | -------------------------------------------------------------------------------- /src/routes/url-pattern.ts: -------------------------------------------------------------------------------- 1 | import { globalURLPattern as URLPattern } from "./url-pattern-global"; 2 | import { URLPatternResult } from "./types"; 3 | import { compositeKey } from "@virtualstate/composite-key"; 4 | 5 | export { URLPatternInit } from "./url-pattern-global"; 6 | 7 | export { 8 | URLPattern 9 | } 10 | 11 | export function isURLPatternStringWildcard(pattern: string): pattern is "*" { 12 | return pattern === "*"; 13 | } 14 | 15 | const patternSymbols = Object.values({ 16 | // From https://wicg.github.io/urlpattern/#parsing-patterns 17 | open: "{", 18 | close: "}", 19 | regexpOpen: "(", 20 | regexpClose: ")", 21 | nameStart: ":", 22 | asterisk: "*" 23 | } as const); 24 | 25 | export const patternParts = [ 26 | "protocol", 27 | "hostname", 28 | "username", 29 | "password", 30 | "port", 31 | "pathname", 32 | "search", 33 | "hash" 34 | ] as const; 35 | 36 | export function isURLPatternStringPlain(pattern: string) { 37 | for (const symbol of patternSymbols) { 38 | if (pattern.includes(symbol)) { 39 | return false; 40 | } 41 | } 42 | return true; 43 | } 44 | 45 | export function isURLPatternPlainPathname(pattern: URLPattern) { 46 | if (!isURLPatternStringPlain(pattern.pathname)) { 47 | return false; 48 | } 49 | for (const part of patternParts) { 50 | if (part === "pathname") continue; 51 | if (!isURLPatternStringWildcard(pattern[part])) { 52 | return false; 53 | } 54 | } 55 | return true; 56 | } 57 | 58 | type CompositeObjectKey = Readonly<{ __proto__: null }>; 59 | 60 | // Note, this weak map will contain all urls 61 | // matched in the current process. 62 | // This may not be wanted by everyone 63 | let execCache: WeakMap | undefined = undefined; 64 | 65 | export function enableURLPatternCache() { 66 | execCache = execCache ?? new WeakMap(); 67 | } 68 | 69 | export function exec(pattern: URLPattern, url: URL): URLPatternResult | undefined { 70 | if (!execCache) { 71 | return pattern.exec(url); 72 | } 73 | const key = compositeKey( 74 | pattern, 75 | ...patternParts 76 | .filter(part => !isURLPatternStringWildcard(pattern[part])) 77 | .map(part => url[part]) 78 | ); 79 | const existing = execCache.get(key); 80 | if (existing) return existing; 81 | if (existing === false) return undefined; 82 | const result = pattern.exec(url); 83 | execCache.set(key, result ?? false); 84 | return result; 85 | } -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '23 2 * * 2' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v2 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v1 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v1 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v1 71 | -------------------------------------------------------------------------------- /src/routes/route.ts: -------------------------------------------------------------------------------- 1 | import { isRouter, Router } from "./router"; 2 | import { URLPattern } from "./url-pattern"; 3 | import { getNavigation } from "../get-navigation"; 4 | import {Event} from "../event-target"; 5 | import {NavigateEvent} from "../spec/navigation"; 6 | import {PatternRouteFn, RouteFn} from "./types"; 7 | 8 | let router: Router; 9 | 10 | export function getRouter(): Router { 11 | if (isRouter(router)) { 12 | return router; 13 | } 14 | const navigation = getNavigation(); 15 | const local = new Router(navigation, "navigate"); 16 | router = local; 17 | return local; 18 | } 19 | 20 | export function route( 21 | pattern: string | URLPattern, 22 | fn: PatternRouteFn 23 | ): Router; 24 | export function route( 25 | fn: RouteFn 26 | ): Router; 27 | export function route( 28 | ...args: [string | URLPattern, PatternRouteFn] | [RouteFn] 29 | ): Router { 30 | let pattern, fn; 31 | if (args.length === 1) { 32 | [fn] = args; 33 | } else if (args.length === 2) { 34 | [pattern, fn] = args; 35 | } 36 | return routes(pattern).route(fn); 37 | } 38 | 39 | export function routes( 40 | pattern: string | URLPattern, 41 | router: Router 42 | ): Router; 43 | export function routes( 44 | pattern: string | URLPattern 45 | ): Router; 46 | export function routes( 47 | router: Router 48 | ): Router; 49 | export function routes(): Router; 50 | export function routes( 51 | ...args: 52 | | [string | URLPattern] 53 | | [string | URLPattern, Router | undefined] 54 | | [Router | undefined] 55 | | [] 56 | ): Router { 57 | let router: Router; 58 | if (!args.length) { 59 | router = new Router(); 60 | getRouter().routes(router); 61 | } else if (args.length === 1) { 62 | const [arg] = args; 63 | if (isRouter(arg)) { 64 | router = arg; 65 | getRouter().routes(router); 66 | } else { 67 | const pattern = arg; 68 | router = new Router(); 69 | getRouter().routes(pattern, router); 70 | } 71 | } else if (args.length >= 2) { 72 | const [pattern, routerArg] = args; 73 | router = routerArg ?? new Router(); 74 | getRouter().routes(pattern, router); 75 | } 76 | return router; 77 | } 78 | -------------------------------------------------------------------------------- /src/navigation-transition-planner.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NavigationTransition, 3 | NavigationTransitionNavigationType, 4 | NavigationTransitionWait, 5 | NavigationIntercept, 6 | } from "./navigation-transition"; 7 | import { Navigation } from "./navigation"; 8 | import { NavigationHistoryEntry } from "./navigation-entry"; 9 | import { InvalidStateError } from "./navigation-errors"; 10 | 11 | export interface NavigationTransitionPlannerOptions { 12 | transition: NavigationTransition; 13 | currentPlan?: NavigationTransitionPlan; 14 | finishedTransitions?: Set; 15 | constructNavigation(): Navigation; 16 | } 17 | 18 | export const NavigationTransitionPlanNavigationSymbol = Symbol.for( 19 | "@virtualstate/navigation/transition/plan/Navigation" 20 | ); 21 | export const NavigationTransitionPlanWhile = Symbol.for( 22 | "@virtualstate/navigation/transition/plan/while" 23 | ); 24 | export const NavigationTransitionPlanWait = Symbol.for( 25 | "@virtualstate/navigation/transition/plan/wait" 26 | ); 27 | 28 | export interface NavigationTransitionPlan { 29 | [NavigationTransitionPlanNavigationSymbol]: Navigation; 30 | [NavigationTransitionPlanWhile](promise: Promise): void; 31 | [NavigationTransitionPlanWait](): Promise>; 32 | transitions: NavigationTransition[]; 33 | known: Set>; 34 | knownTransitions: Set; 35 | resolve(): Promise; 36 | } 37 | 38 | export function plan(options: NavigationTransitionPlannerOptions) { 39 | const { transition, currentPlan, constructNavigation, finishedTransitions } = 40 | options; 41 | const nextPlan: NavigationTransitionPlan = { 42 | [NavigationTransitionPlanNavigationSymbol]: 43 | currentPlan?.[NavigationTransitionPlanNavigationSymbol] ?? 44 | constructNavigation(), 45 | transitions: [], 46 | ...currentPlan, 47 | knownTransitions: new Set(currentPlan?.knownTransitions ?? []), 48 | [NavigationTransitionPlanWhile]: transition[NavigationIntercept], 49 | [NavigationTransitionPlanWait]: transition[NavigationTransitionWait], 50 | }; 51 | if (nextPlan.knownTransitions.has(transition)) { 52 | throw new InvalidStateError( 53 | "Transition already found in plan, this may lead to unexpected behaviour, please raise an issue at " 54 | ); 55 | } 56 | nextPlan.knownTransitions.add(transition); 57 | if (transition[NavigationTransitionNavigationType] === "traverse") { 58 | nextPlan.transitions.push(transition); 59 | } else { 60 | // Reset on non traversal 61 | nextPlan.transitions = [].concat([transition]); 62 | } 63 | nextPlan.transitions = nextPlan.transitions 64 | // Remove finished transitions 65 | .filter((transition) => finishedTransitions?.has(transition)); 66 | return nextPlan; 67 | } 68 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Changelog 4 | 5 | All notable changes to this project will be documented in this file. 6 | 7 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 8 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 9 | 10 | ## [1.0.1-alpha.212] - 2025-10-11 11 | 12 | ### Added 13 | 14 | - Include `NavigationTransitionBeforeCommit` symbol event on `NavigationTransition` to allow for last chance commit cancellation 15 | 16 | ### Changed 17 | 18 | - Polyfill uses `location.href` for same document `push` events that do not have an intercept added through the event lifecycle [Issue #42](https://github.com/virtualstate/navigation/issues/42) 19 | - Polyfill uses `location.reload()` for same document `reload` events that do not have an intercept added through the event lifecycle [Issue #42](https://github.com/virtualstate/navigation/issues/42) 20 | 21 | ## [1.0.1-alpha.211] - 2025-09-14 22 | 23 | > Publish change only, tags version as `latest` in npm 24 | 25 | ## [1.0.1-alpha.210] - 2025-09-14 26 | 27 | ### Added 28 | 29 | - Set `navigateType` to `"replace"` on `history: "replace"` or `history: "auto"` with same target URL as `currentEntry` [Issue #43](https://github.com/virtualstate/navigation/issues/43) 30 | - Set `key` to `currentEntry.key` on `"replace"` [Issue #43](https://github.com/virtualstate/navigation/issues/43), [See]() 31 | 32 | ### Changed 33 | 34 | - Include `destination.id` from `entry.id` [See](https://developer.mozilla.org/en-US/docs/Web/API/NavigationDestination/id) 35 | 36 | > Note, the `destination` entry `id` and `key` is always known in this implementation ahead of the 37 | > navigate event dispatching for this reason the values are always included rather than an empty string 38 | 39 | ## [1.0.1-alpha.209] - 2025-03-23 40 | 41 | ### Added 42 | 43 | - Dedicated `CHANGELOG.md` [Issue #20](https://github.com/virtualstate/navigation/issues/20) 44 | 45 | ## [1.0.1-alpha.208] - 2025-03-23 46 | 47 | ### Added 48 | 49 | - Ignore anchor elements with `target="otherWindow"` [Issue #38](https://github.com/virtualstate/navigation/issues/38) 50 | - Correct `navigation.transition.from`, now derived from `navigation.currentEntry` at the start of transition [Issue #31](https://github.com/virtualstate/navigation/issues/31) 51 | 52 | ## [1.0.1-alpha.207] - 2025-03-23 53 | 54 | ### Added 55 | 56 | - Include warning for old signature usage [Issue #37](https://github.com/virtualstate/navigation/issues/37) 57 | 58 | ### Changed 59 | 60 | - Update documentation to match latest spec [Issue #37](https://github.com/virtualstate/navigation/issues/37) 61 | - Use `!Object.hasOwn(globalThis, 'navigation')` for existing global check in polyfill [PR #36](https://github.com/virtualstate/navigation/pull/36) 62 | 63 | ## [1.0.1-alpha.206] 64 | 65 | ### Changed 66 | 67 | - Updated default serializer for polyfill to JSON [PR #35](https://github.com/virtualstate/navigation/pull/35) 68 | 69 | -------------------------------------------------------------------------------- /src/tests/navigation-type-auto-replace.ts: -------------------------------------------------------------------------------- 1 | import {Navigation} from "../navigation"; 2 | import {ok} from "./util"; 3 | import {NavigateEvent} from "../spec/navigation"; 4 | 5 | { 6 | 7 | const navigation = new Navigation(); 8 | 9 | let eventReceived = false, 10 | eventAsserted = false, 11 | error = undefined 12 | 13 | function noopIntercept(event: NavigateEvent) { 14 | event.intercept({ 15 | async handler() {} 16 | }) 17 | } 18 | 19 | navigation.addEventListener("navigate", noopIntercept) 20 | 21 | await navigation.navigate("/").finished; 22 | 23 | navigation.removeEventListener("navigate", noopIntercept) 24 | 25 | function navigatePush(event: NavigateEvent) { 26 | const { transition: { from: { key: fromKey } }, currentEntry } = navigation; 27 | const { destination: { key: destinationKey }, navigationType } = event; 28 | eventReceived = true; 29 | 30 | try { 31 | ok(navigationType === "push", "Expected navigationType to be push"); 32 | ok(fromKey, "Expected navigation.transition.from.key"); 33 | ok(destinationKey, "Expected destination.key"); 34 | ok(fromKey !== destinationKey, "Expected event.destination.key to not match navigation.transition.from.key"); 35 | eventAsserted = true; 36 | } catch (caught) { 37 | error = caught; 38 | } 39 | 40 | noopIntercept(event); 41 | } 42 | 43 | navigation.addEventListener("navigate", navigatePush) 44 | 45 | await navigation.navigate("/1").finished; 46 | 47 | navigation.removeEventListener("navigate", navigatePush) 48 | 49 | ok(eventReceived, "Event not received") 50 | ok(eventAsserted, error ?? "Event not asserted"); 51 | 52 | eventReceived = undefined; 53 | eventAsserted = undefined; 54 | error = undefined; 55 | 56 | function navigateReplace(event: NavigateEvent) { 57 | eventReceived = true; 58 | const { transition: { from: { key: fromKey } } } = navigation; 59 | const { destination: { key: destinationKey }, navigationType } = event; 60 | try { 61 | ok(navigationType === "replace", "Expected navigationType to be replace"); 62 | ok(fromKey, "Expected navigation.transition.from.key"); 63 | ok(destinationKey, "Expected destination.key"); 64 | ok(fromKey === destinationKey, "Expected event.destination.key to match navigation.transition.from.key"); 65 | eventAsserted = true; 66 | } catch (caught) { 67 | error = caught; 68 | } 69 | noopIntercept(event); 70 | } 71 | 72 | navigation.addEventListener("navigate", navigateReplace); 73 | 74 | await navigation.navigate(navigation.currentEntry.url, { history: "auto" }).finished; 75 | 76 | navigation.removeEventListener("navigate", navigateReplace) 77 | 78 | ok(eventReceived, "Event not received") 79 | ok(eventAsserted, error ?? "Event not asserted") 80 | 81 | } -------------------------------------------------------------------------------- /example/esm.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Polyfill Example 6 | 7 | 8 | 9 | 87 | 88 |

Current Url

89 | 90 | Home Link 91 | Some Page Link 92 | Some Other Page Link 93 | ESM Link 94 | Blank Tab Link 95 | Current Page 96 | 97 |
98 | 99 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Polyfill Example 6 | 7 | 8 | 9 | 10 | 74 | 75 |

Current Url

76 | 77 | Home Link 78 | Some Page Link 79 | Some Other Page Link 80 | ESM Link 81 | Web Component Link 82 | External Link 83 | External Link with Target 84 | Current Page 85 | 86 | 87 |
88 | 89 | 90 | -------------------------------------------------------------------------------- /src/tests/navigation.scope.faker.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start */ 2 | import {NavigateEvent, Navigation} from "../spec/navigation"; 3 | import {like, ok} from "../is"; 4 | import {EventCallback, EventTarget} from "../event-target"; 5 | import {Navigation as NavigationPolyfill, NavigationSetCurrentKey, NavigationSetEntries} from "../navigation"; 6 | import {assertNavigationWithWindow} from "./navigation.scope"; 7 | import {v4} from "../util/uuid-or-random"; 8 | import {applyPolyfill} from "../apply-polyfill"; 9 | import {NavigationHistory} from "../history"; 10 | 11 | 12 | export default 1; 13 | 14 | const window: EventTarget & { document?: unknown } = new EventTarget(); 15 | const document: EventTarget & { createElement?: unknown } = new EventTarget(); 16 | 17 | interface ElementLike { 18 | matches(query: string): boolean; 19 | submit?(): void; 20 | click?(): void; 21 | getAttribute(name: string): string 22 | } 23 | 24 | function createElement(type: string) { 25 | const target: EventTarget & Record & Partial = new EventTarget(); 26 | const children = new Set(); 27 | target.ownerDocument = document; 28 | target.getAttribute = () => ""; 29 | target.click = () => { 30 | console.log("Click", type); 31 | if (type === "a") { 32 | console.log("dispatchEvent click") 33 | window.dispatchEvent({ 34 | type: "click", 35 | target, 36 | button: 0 37 | }); 38 | } else if (type === "button") { 39 | if (target.type === "submit" && like(target.parentElement)) { 40 | if (target.parentElement.matches("form")) { 41 | return target.parentElement.submit(); 42 | } 43 | } 44 | } 45 | } 46 | target.submit = () => { 47 | window.dispatchEvent({ 48 | type: "submit", 49 | target 50 | }); 51 | } 52 | target.matches = (query: string) => { 53 | if (query.startsWith("a[href]")) { 54 | return type === "a" && !!target.href 55 | } 56 | return query.startsWith(type); 57 | } 58 | target.appendChild = (child: { parentElement: unknown }) => { 59 | children.add(child) 60 | child.parentElement = target; 61 | } 62 | target.removeChild = (child: { parentElement: unknown }) => { 63 | children.delete(child) 64 | child.parentElement = undefined; 65 | } 66 | return target 67 | } 68 | 69 | document.createElement = createElement; 70 | document.body = createElement("body") 71 | 72 | window.document = document 73 | 74 | const navigation = new NavigationPolyfill({ 75 | entries: [ 76 | { 77 | key: v4() 78 | } 79 | ] 80 | }); 81 | 82 | applyPolyfill({ 83 | window, 84 | history: new NavigationHistory({ 85 | navigation 86 | }) 87 | }); 88 | 89 | console.log("applyPolyfill", navigation) 90 | 91 | ok(window); 92 | ok(document); 93 | 94 | declare var FormData: unknown; 95 | 96 | if (typeof FormData !== "undefined") { 97 | console.log("FormData exists, will test navigation as a polyfill"); 98 | await assertNavigationWithWindow( 99 | window, 100 | navigation 101 | ) 102 | } else { 103 | console.log("FormData does not exists, will not test navigation as a polyfill"); 104 | } -------------------------------------------------------------------------------- /scripts/correct-import-extensions.js: -------------------------------------------------------------------------------- 1 | import FileHound from "filehound"; 2 | import { promises as fs } from "fs"; 3 | import path from "path"; 4 | import { promisify } from "util"; 5 | // 6 | // const packages = await FileHound.create() 7 | // .paths(`packages`) 8 | // .directory() 9 | // .depth(1) 10 | // .find(); 11 | 12 | const buildPaths = ['esnext'] 13 | 14 | for (const buildPath of buildPaths) { 15 | const filePaths = await FileHound.create() 16 | .paths(buildPath) 17 | .discard("node_modules") 18 | .ext("js") 19 | .find() 20 | 21 | await Promise.all( 22 | filePaths.map( 23 | async filePath => { 24 | 25 | const initialContents = await fs.readFile( 26 | filePath, 27 | "utf-8" 28 | ); 29 | 30 | const statements = initialContents.match(/(?:(?:import|export)(?: .+ from)? ["'].+["'];|(?:import\(["'].+["']\)))/g); 31 | 32 | if (!statements) { 33 | return; 34 | } 35 | 36 | const importMap = process.env.IMPORT_MAP ? JSON.parse(await fs.readFile(process.env.IMPORT_MAP, "utf-8")) : undefined; 37 | const contents = await statements.reduce( 38 | async (contentsPromise, statement) => { 39 | const contents = await contentsPromise; 40 | const url = statement.match(/["'](.+)["']/)[1]; 41 | if (importMap?.imports?.[url]) { 42 | const replacement = importMap.imports[url]; 43 | if (!replacement.includes("./src")) { 44 | return contents.replace( 45 | statement, 46 | statement.replace(url, replacement) 47 | ); 48 | } 49 | const shift = filePath 50 | .split("/") 51 | // Skip top folder + file 52 | .slice(2) 53 | // Replace with shift up directory 54 | .map(() => "..") 55 | .join("/"); 56 | return contents.replace( 57 | statement, 58 | statement.replace(url, replacement.replace("./src", shift).replace(/\.tsx?$/, ".js")) 59 | ); 60 | } else { 61 | return contents.replace( 62 | statement, 63 | await getReplacement(url) 64 | ); 65 | } 66 | 67 | async function getReplacement(url) { 68 | const [stat, indexStat] = await Promise.all([ 69 | fs.stat(path.resolve(path.dirname(filePath), url + ".js")).catch(() => {}), 70 | fs.stat(path.resolve(path.dirname(filePath), url + "/index.js")).catch(() => {}) 71 | ]); 72 | 73 | if (stat && stat.isFile()) { 74 | return statement.replace(url, url + ".js"); 75 | } else if (indexStat && indexStat.isFile()) { 76 | return statement.replace(url, url + "/index.js"); 77 | } 78 | return statement; 79 | } 80 | }, 81 | Promise.resolve(initialContents) 82 | ); 83 | 84 | await fs.writeFile(filePath, contents, "utf-8"); 85 | 86 | } 87 | ) 88 | ); 89 | } 90 | 91 | -------------------------------------------------------------------------------- /src/tests/examples/sync-legacy.ts: -------------------------------------------------------------------------------- 1 | import { Navigation } from "../../spec/navigation"; 2 | import { NavigationSync } from "../../history"; 3 | import { ok } from "../util"; 4 | import {like} from "../../is"; 5 | 6 | export async function syncLocationExample(navigation: Navigation) { 7 | const sync = new NavigationSync({ navigation }), 8 | location = sync; 9 | 10 | const expectedHash = `#hash${Math.random()}`; 11 | 12 | location.hash = expectedHash; 13 | ok(location.hash === expectedHash); 14 | 15 | await finished(navigation); 16 | 17 | const expectedPathname = `/pathname/1/${Math.random()}`; 18 | location.pathname = expectedPathname; 19 | ok(location.pathname === expectedPathname); 20 | 21 | await finished(navigation); 22 | 23 | const searchParams = new URLSearchParams(location.search); 24 | searchParams.append("test", "test"); 25 | location.search = searchParams.toString(); 26 | ok(new URLSearchParams(location.search).get("test") === "test"); 27 | 28 | await finished(navigation); 29 | } 30 | 31 | export async function syncHistoryExample(navigation: Navigation>) { 32 | const sync = new NavigationSync({ navigation }), 33 | history = sync; 34 | 35 | const expected = `expected${Math.random()}`; 36 | const expectedUrl = new URL(`https://example.com/${expected}/1`); 37 | history.pushState( 38 | { 39 | [expected]: expected, 40 | }, 41 | "", 42 | expectedUrl 43 | ); 44 | ok(!history.state); 45 | ok(navigation.currentEntry.getState()[expected] === expected); 46 | 47 | await finished(navigation); 48 | 49 | ok(navigation.currentEntry.url === expectedUrl.toString()); 50 | 51 | await navigation.navigate("/1").finished; 52 | const value: unknown = like>(history.state) && history.state?.[expected] 53 | ok(value !== expected); 54 | await navigation.navigate("/2").finished; 55 | await navigation.navigate("/3").finished; 56 | 57 | history.back(); 58 | 59 | await finished(navigation); 60 | ok(!history.state); 61 | ok(navigation.currentEntry.getState()?.[expected] !== expected); 62 | 63 | history.back(); 64 | 65 | await finished(navigation); 66 | ok(!history.state); 67 | ok(navigation.currentEntry.getState()?.[expected] !== expected); 68 | 69 | history.back(); 70 | 71 | await finished(navigation); 72 | 73 | ok(!history.state); 74 | ok(navigation.currentEntry.getState()[expected] === expected); 75 | ok(navigation.currentEntry.url === expectedUrl.toString()); 76 | 77 | history.forward(); 78 | 79 | await finished(navigation); 80 | ok(!history.state); 81 | ok(navigation.currentEntry.getState()?.[expected] !== expected); 82 | 83 | history.go(-1); 84 | 85 | await finished(navigation); 86 | ok(!history.state); 87 | ok(navigation.currentEntry.getState()[expected] === expected); 88 | 89 | history.go(1); 90 | 91 | await finished(navigation); 92 | ok(!history.state); 93 | ok(navigation.currentEntry.getState()?.[expected] !== expected); 94 | 95 | history.go(-1); 96 | 97 | await finished(navigation); 98 | ok(!history.state); 99 | ok(navigation.currentEntry.getState()[expected] === expected); 100 | 101 | history.go(0); 102 | 103 | await finished(navigation); 104 | ok(!history.state); 105 | ok(navigation.currentEntry.getState()[expected] === expected); 106 | } 107 | 108 | async function finished(navigation: Navigation) { 109 | ok(navigation.transition); 110 | ok(navigation.transition.finished); 111 | await navigation.transition.finished; 112 | } 113 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [conduct+axiom@fabiancook.dev](mailto:conduct+axiom@fabiancook.dev). All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /src/tests/entrieschange.ts: -------------------------------------------------------------------------------- 1 | import {Navigation, NavigationEntriesChangeEvent} from "../navigation"; 2 | import {ok} from "../is"; 3 | 4 | async function getChanges(navigation: Navigation) { 5 | return new Promise( 6 | resolve => navigation.addEventListener("entrieschange", resolve, { once: true }) 7 | ); 8 | } 9 | 10 | 11 | { 12 | const navigation = new Navigation(); 13 | const promise = getChanges(navigation); 14 | 15 | const entry = await navigation.navigate("/").finished; 16 | 17 | const { 18 | addedEntries, 19 | removedEntries, 20 | updatedEntries 21 | } = await promise; 22 | 23 | console.log({ 24 | addedEntries, 25 | removedEntries, 26 | updatedEntries 27 | }) 28 | 29 | ok(addedEntries.length === 1); 30 | ok(removedEntries.length === 0); 31 | ok(updatedEntries.length === 0); 32 | 33 | ok(addedEntries[0].key === entry.key); 34 | } 35 | 36 | { 37 | 38 | const navigation = new Navigation(); 39 | const initial = await navigation.navigate("/").finished; 40 | const next = await navigation.navigate("/next").finished; 41 | 42 | let called = false; 43 | navigation.addEventListener("entrieschange", () => called = true, { once: true }); 44 | 45 | await navigation.back().finished; 46 | 47 | ok(!called); // No changes to entries 48 | ok(navigation.currentEntry.key === initial.key); 49 | 50 | { 51 | const entries = navigation.entries(); 52 | 53 | ok(entries.length >= 2); 54 | ok(entries[navigation.currentEntry.index].key === initial.key); 55 | 56 | // The entry is still the same, just shifted index 57 | ok(entries[navigation.currentEntry.index + 1]); 58 | ok(entries[navigation.currentEntry.index + 1].key === next.key); 59 | 60 | called = false; 61 | navigation.addEventListener("entrieschange", () => called = true, { once: true }); 62 | 63 | } 64 | 65 | { 66 | await navigation.forward().finished; 67 | 68 | ok(!called); // No changes to entries 69 | ok(navigation.currentEntry.key === next.key); 70 | 71 | const entries = navigation.entries(); 72 | 73 | ok(entries.length >= 2); 74 | ok(entries[navigation.currentEntry.index].key === next.key); 75 | 76 | // The entry is still the same, just shifted index 77 | ok(entries[navigation.currentEntry.index - 1]); 78 | ok(entries[navigation.currentEntry.index - 1].key === initial.key); 79 | } 80 | 81 | } 82 | 83 | { 84 | const navigation = new Navigation(); 85 | const initial = await navigation.navigate("/").finished; 86 | const next = await navigation.navigate("/next").finished; 87 | 88 | let called = false; 89 | navigation.addEventListener("entrieschange", () => called = true, { once: true }); 90 | 91 | await navigation.traverseTo(initial.key).finished; 92 | 93 | ok(!called); // No changes yet, just a traverse 94 | 95 | const promise = getChanges(navigation); 96 | 97 | const pushed = await navigation.navigate("/pushed").finished; 98 | 99 | const { 100 | addedEntries, 101 | removedEntries, 102 | updatedEntries 103 | } = await promise; 104 | 105 | console.log({ 106 | addedEntries, 107 | removedEntries, 108 | updatedEntries 109 | }) 110 | 111 | ok(addedEntries.length === 1); 112 | ok(removedEntries.length === 1); 113 | ok(updatedEntries.length === 0); 114 | 115 | ok(addedEntries[0].key === pushed.key); 116 | ok(removedEntries[0].key === next.key); 117 | 118 | } -------------------------------------------------------------------------------- /src/tests/commit.ts: -------------------------------------------------------------------------------- 1 | import {Navigation} from "../navigation"; 2 | import {ok} from "../is"; 3 | import {defer} from "../defer"; 4 | import {Event} from "../event-target"; 5 | 6 | /** 7 | * @experimental 8 | */ 9 | { 10 | const navigation = new Navigation(); 11 | 12 | await navigation.navigate("/").finished; 13 | 14 | navigation.addEventListener("navigate", event => { 15 | console.log(event); 16 | console.log(navigation.currentEntry); 17 | event.intercept({ commit: "after-transition" }); 18 | event.commit(); 19 | console.log(navigation.currentEntry); 20 | }); 21 | 22 | navigation.navigate("/1"); 23 | 24 | // await new Promise(queueMicrotask); 25 | // await new Promise(queueMicrotask); 26 | // 27 | const { pathname } = new URL(navigation.currentEntry.url); 28 | 29 | console.log({ pathname }); 30 | 31 | ok(pathname === "/1"); 32 | 33 | 34 | } 35 | /** 36 | * @experimental 37 | */ 38 | { 39 | const navigation = new Navigation(); 40 | 41 | await navigation.navigate("/").finished; 42 | 43 | const errorMessage = `Custom error ${Math.random()}`; 44 | 45 | const { resolve, promise } = defer(); 46 | 47 | navigation.addEventListener("navigateerror", resolve, { once: true }); 48 | 49 | navigation.addEventListener("navigate", event => { 50 | event.intercept({ commit: "after-transition" }); 51 | event.reportError(new Error(errorMessage)) 52 | }); 53 | 54 | const { committed, finished } = navigation.navigate("/1") 55 | 56 | const [ 57 | committedError, 58 | finishedError 59 | ] = await Promise.all([ 60 | committed.catch(error => error), 61 | finished.catch(error => error) 62 | ]); 63 | 64 | const { error } = await promise; 65 | 66 | console.log({ 67 | error 68 | }); 69 | 70 | ok(error instanceof Error); 71 | ok(error.message === errorMessage); 72 | ok(committedError instanceof Error); 73 | ok(committedError.message === errorMessage); 74 | ok(finishedError instanceof Error); 75 | ok(finishedError.message === errorMessage); 76 | ok(committedError === error); 77 | ok(finishedError === error); 78 | 79 | } 80 | 81 | 82 | /** 83 | * @experimental 84 | */ 85 | { 86 | const navigation = new Navigation(); 87 | 88 | await navigation.navigate("/").finished; 89 | 90 | const errorMessage = `Custom error ${Math.random()}`; 91 | 92 | const { resolve, promise } = defer(); 93 | 94 | navigation.addEventListener("navigateerror", resolve, { once: true }); 95 | 96 | navigation.addEventListener("navigate", event => { 97 | event.intercept({ 98 | handler: async () => { 99 | await new Promise(queueMicrotask); 100 | event.reportError(new Error(errorMessage)) 101 | } 102 | }); 103 | }); 104 | 105 | const { committed, finished } = navigation.navigate("/1") 106 | 107 | const [ 108 | committedError, 109 | finishedError 110 | ] = await Promise.all([ 111 | committed.catch(error => error), 112 | finished.catch(error => error) 113 | ]); 114 | 115 | const { error } = await promise; 116 | 117 | console.log({ 118 | error 119 | }); 120 | 121 | ok(error instanceof Error); 122 | ok(error.message === errorMessage); 123 | // we should be committing fine 124 | ok(!(committedError instanceof Error)); 125 | ok(finishedError instanceof Error); 126 | ok(finishedError.message === errorMessage); 127 | ok(finishedError === error); 128 | 129 | } 130 | 131 | -------------------------------------------------------------------------------- /.github/workflows/release-actions.yml: -------------------------------------------------------------------------------- 1 | name: release-actions 2 | on: 3 | push: 4 | branches: 5 | - main 6 | release: 7 | types: 8 | - created 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-22.04 12 | permissions: 13 | contents: read 14 | id-token: write 15 | env: 16 | NO_COVERAGE_BADGE_UPDATE: 1 17 | FLAGS: FETCH_SERVICE_DISABLE,POST_CONFIGURE_TEST 18 | steps: 19 | - uses: actions/checkout@v5 20 | - uses: actions/setup-node@v5 21 | with: 22 | node-version: '24.x' 23 | registry-url: 'https://registry.npmjs.org' 24 | cache: "yarn" 25 | - uses: denoland/setup-deno@v2 26 | with: 27 | deno-version: 'v2.x' 28 | - uses: antongolub/action-setup-bun@v1 29 | - run: | 30 | yarn install 31 | npx playwright install-deps 32 | - run: yarn build 33 | # yarn coverage === c8 + yarn test 34 | - run: yarn coverage 35 | - run: yarn test:deno 36 | - run: yarn test:bun 37 | # rollup is for tests only 38 | - run: rm -rf ./esnext/tests/rollup.js 39 | - name: Package Registry Publish - npm 40 | run: | 41 | git config user.name "${{ github.actor }}" 42 | git config user.email "${{ github.actor}}@users.noreply.github.com" 43 | npm set "registry=https://registry.npmjs.org/" 44 | npm set "@virtualstate:registry=https://registry.npmjs.org/" 45 | npm set "//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}" 46 | npm publish --access=public 47 | continue-on-error: true 48 | env: 49 | YARN_TOKEN: ${{ secrets.YARN_TOKEN }} 50 | NPM_TOKEN: ${{ secrets.YARN_TOKEN }} 51 | NODE_AUTH_TOKEN: ${{ secrets.YARN_TOKEN }} 52 | - uses: actions/setup-node@v2 53 | with: 54 | node-version: '21.x' 55 | registry-url: 'https://npm.pkg.github.com' 56 | - name: Package Registry Publish - GitHub 57 | run: | 58 | git config user.name "${{ github.actor }}" 59 | git config user.email "${{ github.actor}}@users.noreply.github.com" 60 | npm set "registry=https://npm.pkg.github.com/" 61 | npm set "@virtualstate:registry=https://npm.pkg.github.com/virtualstate" 62 | npm set "//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}" 63 | npm publish --access=public 64 | env: 65 | YARN_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | NPM_TOKEN: ${{ secrets.GITHUB_TOKEN }} 67 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | continue-on-error: true 69 | - uses: actions/setup-node@v2 70 | with: 71 | node-version: '21.x' 72 | registry-url: 'https://registry.npmjs.org' 73 | - name: Package Registry Test - npm 74 | run: | 75 | yarn add --dev @virtualstate/navigation@$(node scripts/log-version.js) 76 | yarn add --dev @virtualstate/navigation-local-build@./node_modules/@virtualstate/navigation 77 | export TEST_CONFIG='{"@virtualstate/navigation/test/imported/path": "@virtualstate/navigation-local-build"}' 78 | yarn test 79 | continue-on-error: true 80 | - uses: actions/setup-node@v2 81 | with: 82 | node-version: '21.x' 83 | registry-url: 'https://npm.pkg.github.com' 84 | - name: Package Registry Test - GitHub 85 | run: | 86 | yarn add --dev @virtualstate/navigation@$(node scripts/log-version.js) 87 | yarn add --dev @virtualstate/navigation-local-build@./node_modules/@virtualstate/navigation 88 | export TEST_CONFIG='{"@virtualstate/navigation/test/imported/path": "@virtualstate/navigation-local-build"}' 89 | yarn test 90 | continue-on-error: true -------------------------------------------------------------------------------- /example/webcomponents.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Polyfill Example 6 | 7 | 8 | 9 | 10 | 96 | 97 |

Current Url

98 | 99 | Home Link 100 | ESM Link 101 | 102 | 103 | 104 | 105 | Current Page 106 | 107 |
108 | 109 | 110 | -------------------------------------------------------------------------------- /src/tests/index.ts: -------------------------------------------------------------------------------- 1 | /* c8 ignore start */ 2 | // import {run, dispatchEvent, addEventListener} from "@opennetwork/environment"; 3 | import process from "./node-process"; 4 | import { getConfig } from "./config"; 5 | import { setTraceWarnings } from "../util/warnings"; 6 | 7 | const isWindow = typeof window !== "undefined"; 8 | 9 | setTraceWarnings(true) 10 | 11 | console.log("====== START NEW SET OF TESTS ======"); 12 | 13 | if (typeof process !== "undefined" && process.on) { 14 | process.on("uncaughtException", (...args: unknown[]) => { 15 | console.log("process uncaught exception", ...args); 16 | process.exit(1); 17 | }); 18 | process.on("unhandledRejection", (...args: unknown[]) => { 19 | console.log("process unhandled rejection", ...args); 20 | process.exit(1); 21 | }); 22 | process.on("error", (...args: unknown[]) => { 23 | console.log("process error", ...args); 24 | process.exit(1); 25 | }); 26 | } 27 | 28 | async function runTests() { 29 | const flags = getConfig().FLAGS; 30 | const WPT = flags?.includes("WEB_PLATFORM_TESTS"), 31 | playright = flags?.includes("PLAYWRIGHT") 32 | 33 | if (!playright) { 34 | await import("./navigation.class"); 35 | } 36 | if (typeof window === "undefined" && typeof process !== "undefined") { 37 | if (!playright) { 38 | await import("./navigation.imported"); 39 | await import("./navigation.scope.faker"); 40 | } 41 | if (WPT) { 42 | await import("./navigation.playwright.wpt"); 43 | } 44 | if (playright) { 45 | await import("./navigation.playwright"); 46 | await import("./navigation.class"); 47 | } 48 | } else if (window.navigation) { 49 | await import("./navigation.scope"); 50 | } 51 | if (!(WPT || playright)) { 52 | console.log("Starting routes tests"); 53 | await import("./routes"); 54 | console.log("Starting transition tests"); 55 | await import("./transition"); 56 | console.log("Starting wpt base tests"); 57 | await import("./wpt"); 58 | console.log("Starting commit tests"); 59 | await import("./commit"); 60 | console.log("Starting state tests"); 61 | await import("./state"); 62 | console.log("Starting entrieschange tests"); 63 | await import("./entrieschange"); 64 | console.log("Starting custom state tests"); 65 | await import("./custom-state"); 66 | console.log("Starting original event tests"); 67 | await import("./original-event"); 68 | console.log("Starting same document tests"); 69 | await import("./same-document"); 70 | console.log("Starting await tests"); 71 | await import("./await"); 72 | if (!isWindow) { 73 | console.log("Starting dynamic tests", import.meta.url); 74 | const {dynamicNavigation} = await import("./dynamic"); 75 | await dynamicNavigation(); 76 | } 77 | console.log("Starting destination key tests"); 78 | await import("./destination-key-from-key"); 79 | console.log("Starting navigation type tests"); 80 | await import("./navigation-type-auto-replace"); 81 | } 82 | 83 | console.log("Completed tests successfully"); 84 | } 85 | 86 | if (typeof window === "undefined") { 87 | console.log("Running tests within shell"); 88 | } else { 89 | if (sessionStorage.testsRanInThisWindow) { 90 | throw new Error( 91 | "Tests already ran in this window, network navigation caused" 92 | ); 93 | } 94 | sessionStorage.setItem("testsRanInThisWindow", "1"); 95 | console.log("Running tests within window"); 96 | } 97 | let exitCode = 0, 98 | caught = undefined; 99 | try { 100 | await runTests(); 101 | } catch (error) { 102 | caught = error; 103 | exitCode = 1; 104 | console.error("Caught test error!", caught); 105 | if (typeof window === "undefined" && typeof process !== "undefined") { 106 | console.error(caught); 107 | } else { 108 | throw await Promise.reject(caught); 109 | } 110 | } 111 | 112 | // Settle tests, allow for the above handlers to fire if they need to 113 | await new Promise((resolve) => setTimeout(resolve, 200)); 114 | 115 | if (typeof process !== "undefined" && exitCode) { 116 | process.exit(exitCode); 117 | } 118 | 119 | export default exitCode; 120 | -------------------------------------------------------------------------------- /src/event-target/async-event-target.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "./event"; 2 | import { EventCallback } from "./callback"; 3 | import { isParallelEvent } from "./parallel-event"; 4 | import { isSignalEvent, isSignalHandled } from "./signal-event"; 5 | import { AbortError } from "../navigation-errors"; 6 | import { 7 | EventTargetAddListenerOptions, 8 | EventTargetListenersMatch, 9 | EventTargetListenersThis, 10 | } from "./event-target-options"; 11 | import { EventTargetListeners } from "./event-target-listeners"; 12 | 13 | export type { EventCallback, EventTargetAddListenerOptions }; 14 | 15 | export interface AsyncEventTarget extends EventTargetListeners { 16 | new (thisValue?: unknown): AsyncEventTarget; 17 | dispatchEvent(event: Event): void | Promise; 18 | } 19 | 20 | export class AsyncEventTarget 21 | extends EventTargetListeners 22 | implements AsyncEventTarget 23 | { 24 | readonly [EventTargetListenersThis]?: unknown; 25 | 26 | constructor(thisValue: unknown = undefined) { 27 | super(); 28 | this[EventTargetListenersThis] = thisValue; 29 | } 30 | 31 | async dispatchEvent(event: Event) { 32 | const listeners = this[EventTargetListenersMatch]?.(event.type) ?? []; 33 | 34 | // Don't even dispatch an aborted event 35 | if (isSignalEvent(event) && event.signal.aborted) { 36 | throw new AbortError(); 37 | } 38 | 39 | const parallel = isParallelEvent(event); 40 | const promises = []; 41 | for (let index = 0; index < listeners.length; index += 1) { 42 | const descriptor = listeners[index]; 43 | 44 | const promise = (async () => { 45 | // Remove the listener before invoking the callback 46 | // This ensures that inside of the callback causes no more additional event triggers to this 47 | // listener 48 | if (descriptor.once) { 49 | // by passing the descriptor as the options, we get an internal redirect 50 | // that forces an instance level object equals, meaning 51 | // we will only remove _this_ descriptor! 52 | this.removeEventListener( 53 | descriptor.type, 54 | descriptor.callback, 55 | descriptor 56 | ); 57 | } 58 | 59 | await descriptor.callback.call( 60 | this[EventTargetListenersThis] ?? this, 61 | event 62 | ); 63 | })(); 64 | 65 | if (!parallel) { 66 | try { 67 | await promise; 68 | } catch (error) { 69 | if (!isSignalHandled(event, error)) { 70 | await Promise.reject(error); 71 | } 72 | } 73 | if (isSignalEvent(event) && event.signal.aborted) { 74 | // bye 75 | return; 76 | } 77 | } else { 78 | promises.push(promise); 79 | } 80 | } 81 | if (promises.length) { 82 | // Allows for all promises to settle finish so we can stay within the event, we then 83 | // will utilise Promise.all which will reject with the first rejected promise 84 | const results = await Promise.allSettled(promises); 85 | 86 | const rejected = results.filter( 87 | (result): result is PromiseRejectedResult => { 88 | return result.status === "rejected"; 89 | } 90 | ); 91 | 92 | if (rejected.length) { 93 | let unhandled = rejected; 94 | 95 | // If the event was aborted, then allow abort errors to occur, and handle these as handled errors 96 | // The dispatcher does not care about this because they requested it 97 | // 98 | // There may be other unhandled errors that are more pressing to the task they are doing. 99 | // 100 | // The dispatcher can throw an abort error if they need to throw it up the chain 101 | if (isSignalEvent(event) && event.signal.aborted) { 102 | unhandled = unhandled.filter( 103 | (result) => !isSignalHandled(event, result.reason) 104 | ); 105 | } 106 | if (unhandled.length === 1) { 107 | await Promise.reject(unhandled[0].reason); 108 | throw unhandled[0].reason; // We shouldn't get here 109 | } else if (unhandled.length > 1) { 110 | throw new AggregateError(unhandled.map(({ reason }) => reason)); 111 | } 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/state/state.ts: -------------------------------------------------------------------------------- 1 | import {NavigateEvent, Navigation, NavigationHistoryEntry} from "../spec/navigation"; 2 | import {getNavigation} from "../get-navigation"; 3 | import {Push} from "@virtualstate/promise"; 4 | import {Event} from "../event-target"; 5 | import {isPromise, ok} from "../is"; 6 | 7 | export function setState(state: S, navigation: Navigation = getNavigation()): unknown { 8 | const currentEntryChangePromise = new Promise( 9 | resolve => { 10 | navigation.addEventListener( 11 | "currententrychange", 12 | resolve, 13 | { once: true } 14 | ) 15 | } 16 | ); 17 | const returned = navigation.updateCurrentEntry({ 18 | state 19 | }); 20 | const promises = [currentEntryChangePromise]; 21 | if (isPromise(returned)) { 22 | promises.push(returned); 23 | } 24 | return Promise.all(promises); 25 | } 26 | 27 | export function getState(navigation: Navigation = getNavigation()) { 28 | return navigation.currentEntry.getState(); 29 | } 30 | 31 | const states = new WeakMap>(); 32 | 33 | export function state(navigation: Navigation = getNavigation()) { 34 | 35 | const existing = states.get(navigation); 36 | 37 | // Using a stable object for a navigation instance 38 | // allows for this state object to be used in memo'd functions 39 | if (isExisting(existing)) { 40 | return existing; 41 | } 42 | 43 | // Returning an object like this allows callers to bind 44 | // a navigation to the stateGenerator function, each time 45 | // a new asyncIterator is created, the transition or currentEntry 46 | // at that point will be used, meaning that this object 47 | // too can be freely used as a static source across 48 | // an application. 49 | const result: AsyncIterable = { 50 | [Symbol.asyncIterator]() { 51 | return stateGenerator(navigation); 52 | } 53 | } 54 | states.set(navigation, result); 55 | return result; 56 | 57 | function isExisting(existing: AsyncIterable): existing is AsyncIterable { 58 | return !!existing; 59 | } 60 | } 61 | 62 | export async function * stateGenerator(navigation: Navigation = getNavigation()): AsyncIterableIterator { 63 | 64 | let lastState: S | undefined = undefined, 65 | wasState = false; 66 | 67 | let currentEntry: NavigationHistoryEntry | undefined = navigation.currentEntry; 68 | 69 | if (navigation.transition) { 70 | currentEntry = await navigation.transition?.finished; 71 | } 72 | 73 | const push = new Push(); 74 | 75 | ok(currentEntry, "Expected a currentEntry"); 76 | 77 | pushState(); 78 | 79 | navigation.addEventListener("navigate", onNavigate); 80 | navigation.addEventListener("navigatesuccess", onNavigateSuccess); 81 | navigation.addEventListener("navigateerror", onNavigateError, { once: true }); 82 | navigation.addEventListener("currententrychange", pushState); 83 | 84 | currentEntry.addEventListener("dispose", close, { once: true }); 85 | 86 | yield * push; 87 | 88 | function pushState() { 89 | if (navigation.currentEntry.id !== currentEntry.id) { 90 | return close(); 91 | } 92 | const state = currentEntry.getState() 93 | if (wasState || typeof state !== "undefined") { 94 | if (lastState === state) { 95 | return; 96 | } 97 | push.push(state); 98 | lastState = state; 99 | wasState = true; 100 | } 101 | } 102 | 103 | function onNavigate(event: NavigateEvent) { 104 | // Indicate that we have intercepted navigation 105 | // and are using it as a state tracker 106 | event.intercept?.({ 107 | handler: () => Promise.resolve() 108 | }); 109 | } 110 | 111 | function onNavigateSuccess() { 112 | if (navigation.currentEntry.id !== currentEntry.id) { 113 | close(); 114 | } 115 | } 116 | 117 | function onNavigateError(event: Event) { 118 | const { error } = event; 119 | push.throw(error); 120 | } 121 | 122 | function close() { 123 | navigation.removeEventListener("navigate", onNavigate); 124 | navigation.removeEventListener("navigatesuccess", onNavigateSuccess); 125 | navigation.removeEventListener("navigateerror", onNavigateError); 126 | navigation.removeEventListener("currententrychange", pushState); 127 | currentEntry.removeEventListener("dispose", close); 128 | push.close(); 129 | } 130 | 131 | } -------------------------------------------------------------------------------- /src/tests/navigation.server.wpt.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "http"; 2 | import { resolve, dirname, join } from "path"; 3 | import * as Cheerio from "cheerio"; 4 | import { promises as fs } from "fs"; 5 | 6 | const namespacePath = "/node_modules/wpt"; 7 | const buildPath = "/esnext"; 8 | const resourcesInput = "/resources"; 9 | const resourcesTarget = "/node_modules/wpt/resources"; 10 | 11 | export function startServer(port: number) { 12 | const server = createServer((request, response) => { 13 | const url = new URL(request.url, `http://localhost:${port}`); 14 | let promise = Promise.resolve(""); 15 | 16 | response.setHeader("Access-Control-Allow-Origin", "*"); 17 | 18 | console.log({ pathname: url.pathname }); 19 | 20 | if (url.pathname.endsWith(".html.js")) { 21 | promise = createJavaScriptBundle(url); 22 | response.setHeader("Content-Type", "application/javascript"); 23 | } 24 | promise 25 | .then((contents) => { 26 | response.writeHead(200); 27 | response.write(contents); 28 | }) 29 | .catch((error) => { 30 | console.log({ error }); 31 | response.writeHead(500); 32 | }) 33 | .then(() => response.end()); 34 | }); 35 | 36 | server.listen(port, () => { 37 | console.log( 38 | `Running Web Platform Tests ECMAScript Modules server on port ${port}` 39 | ); 40 | }); 41 | 42 | return () => {}; 43 | } 44 | 45 | export async function createJavaScriptBundle(url: URL) { 46 | const cwd = resolve(dirname(new URL(import.meta.url).pathname), "../.."); 47 | const withoutExtension = url.pathname.replace(/\.html\.js$/, ""); 48 | console.log({ cwd, withoutExtension }); 49 | if (withoutExtension.includes("..")) throw new Error("Unexpected double dot in path"); 50 | const htmlPath = join(cwd, namespacePath, `${withoutExtension}.html`); 51 | console.log({ cwd, withoutExtension, htmlPath }); 52 | const html = await fs.readFile(htmlPath, "utf-8"); 53 | if (!html) return ""; 54 | const $ = Cheerio.load(html); 55 | 56 | const dependencies = await Promise.all( 57 | $("script[src]") 58 | .map(function () { 59 | return $(this).attr("src"); 60 | }) 61 | .toArray() 62 | .filter((dependency) => 63 | url.searchParams.get("localDependenciesOnly") 64 | ? !dependency.startsWith("/") 65 | : true 66 | ) 67 | .map( 68 | async (dependency): Promise<[string, string]> => [ 69 | dependency, 70 | await fs 71 | .readFile( 72 | join( 73 | cwd, 74 | namespacePath, 75 | new URL(dependency, url.toString()).pathname 76 | ), 77 | "utf-8" 78 | ) 79 | .catch(() => "// Could not load"), 80 | ] 81 | ) 82 | ); 83 | 84 | const dependenciesJoined = ` 85 | 86 | // 87 | const self = globalThis; 88 | 89 | ${dependencies 90 | .map(([name, contents]) => `// ${name.replace(cwd, "")}\n${contents}`) 91 | .join("\n")} 92 | `; 93 | 94 | const scripts = $("script:not([src])") 95 | .map(function () { 96 | return $(this).html() ?? ""; 97 | }) 98 | .toArray(); 99 | 100 | let scriptsJoined = scripts.join("\n"); 101 | 102 | if (url.searchParams.get("preferUndefined")) { 103 | // eek 104 | scriptsJoined = scriptsJoined.replace(/null/g, "undefined"); 105 | } 106 | 107 | let fnName = url.searchParams.get("exportAs"); 108 | 109 | // https://stackoverflow.com/a/2008353/1174869 110 | const identifierTest = /^[$A-Z_][0-9A-Z_$]*$/i; 111 | 112 | if (!fnName || !identifierTest.test(fnName)) { 113 | fnName = "runTests"; 114 | } 115 | 116 | let globalNames = (url.searchParams.get("globals") ?? "") 117 | .split(",") 118 | .filter((name) => identifierTest.test(name)); 119 | 120 | if (!globalNames.length) { 121 | globalNames = ["______globals_object_key"]; 122 | } 123 | 124 | const globalsDestructure = `{${globalNames.join(", ")}}`; 125 | 126 | const scriptHarness = ` 127 | export function ${fnName}(${globalsDestructure}) {${ 128 | url.searchParams.get("debugger") 129 | ? "\nconsole.log('debugger start');debugger;\n" 130 | : "" 131 | } 132 | ${scriptsJoined} 133 | } 134 | `.trim(); 135 | 136 | if (url.searchParams.get("dependenciesOnly")) { 137 | return dependenciesJoined; 138 | } 139 | if (url.searchParams.get("scriptsOnly")) { 140 | return scriptHarness; 141 | } 142 | 143 | return `${dependenciesJoined}\n${scriptHarness}`; 144 | } 145 | 146 | if ( 147 | typeof process !== "undefined" && 148 | process.argv.includes(new URL(import.meta.url).pathname) 149 | ) { 150 | const portString = process.env.PORT || ""; 151 | const port = /^\d+$/.test(portString) ? +portString : 3000; 152 | void startServer(port); 153 | } 154 | -------------------------------------------------------------------------------- /src/routes/transition.ts: -------------------------------------------------------------------------------- 1 | import {Route, RouteType, URLPatternResult} from "./types"; 2 | import {isPromise, like} from "../is"; 3 | import {Event} from "../event-target"; 4 | import {NavigationDestination} from "../spec/navigation"; 5 | import {getRouterRoutes, isRouter, Router} from "./router"; 6 | import {exec} from "./url-pattern"; 7 | 8 | export async function transitionEvent(router: Router, event: E): Promise { 9 | const promises: Promise[] = []; 10 | 11 | const { 12 | signal, 13 | } = event; 14 | 15 | const url: URL = getURL(event); 16 | const { pathname } = url; 17 | 18 | transitionPart( 19 | "route", 20 | (route, match) => route.fn(event, match), 21 | handleResolve, 22 | handleReject 23 | ); 24 | 25 | if (promises.length) { 26 | await Promise.all(promises); 27 | } 28 | 29 | function transitionPart( 30 | type: RouteType, 31 | fn: (route: Route, match?: URLPatternResult) => unknown, 32 | resolve = handleResolve, 33 | reject = handleReject 34 | ) { 35 | let isRoute = false; 36 | resolveRouter(router); 37 | return isRoute; 38 | 39 | function matchRoute(route: Route, parentMatch?: URLPatternResult) { 40 | const { router, pattern, string } = route; 41 | 42 | let match = parentMatch; 43 | 44 | if (string) { 45 | if (string !== pathname) { 46 | return; 47 | } 48 | } else if (pattern) { 49 | match = exec(pattern, url); 50 | if (!match) return; 51 | } 52 | 53 | if (isRouter(router)) { 54 | return resolveRouter(router, match); 55 | } 56 | 57 | isRoute = true; 58 | try { 59 | const maybe = fn(route, match); 60 | if (isPromise(maybe)) { 61 | promises.push( 62 | maybe 63 | .then(resolve) 64 | .catch(reject) 65 | ); 66 | } else { 67 | resolve(maybe); 68 | } 69 | } catch (error) { 70 | reject(error); 71 | } 72 | } 73 | 74 | function resolveRouter(router: Router, match?: URLPatternResult) { 75 | const routes = getRouterRoutes(router); 76 | resolveRoutes(routes[type]); 77 | resolveRoutes(routes.router); 78 | function resolveRoutes(routes: Route[]) { 79 | for (const route of routes) { 80 | if (signal?.aborted) break; 81 | matchRoute(route, match); 82 | } 83 | } 84 | } 85 | 86 | } 87 | 88 | function noop() {} 89 | 90 | function handleResolve(value: unknown) { 91 | transitionPart( 92 | "resolve", 93 | (route, match) => route.fn(value, event, match), 94 | noop, 95 | handleReject 96 | ); 97 | } 98 | 99 | function handleReject(error: unknown) { 100 | const isRoute = transitionPart( 101 | "reject", 102 | (route, match) => route.fn(error, event, match), 103 | noop, 104 | (error) => Promise.reject(error) 105 | ); 106 | if (!isRoute) { 107 | throw error; 108 | } 109 | } 110 | 111 | function getURL(event: E) { 112 | if (isDestination(event)) { 113 | return new URL(event.destination.url); 114 | } else if (isRequest(event)) { 115 | return new URL(event.request.url); 116 | } else if (isURL(event)) { 117 | return new URL(event.url); 118 | } 119 | throw new Error("Could not get url from event"); 120 | 121 | function isDestination(event: E): event is E & { destination: NavigationDestination } { 122 | return ( 123 | like<{ destination: unknown }>(event) && 124 | !!event.destination 125 | ) 126 | } 127 | 128 | function isRequest(event: E): event is E & { request: Request } { 129 | return ( 130 | like<{ request: unknown }>(event) && 131 | !!event.request 132 | ) 133 | } 134 | 135 | function isURL(event: E): event is E & { url: string | URL } { 136 | return ( 137 | like<{ url: unknown }>(event) && 138 | !!( 139 | event.url && ( 140 | typeof event.url === "string" || 141 | event.url instanceof URL 142 | ) 143 | ) 144 | ) 145 | } 146 | } 147 | 148 | } -------------------------------------------------------------------------------- /src/await/create-promise.ts: -------------------------------------------------------------------------------- 1 | import {defer} from "../defer"; 2 | import {NavigateEvent, Navigation, NavigationEventMap} from "../spec/navigation"; 3 | import {getNavigation} from "../get-navigation"; 4 | import {isPromise} from "../is"; 5 | 6 | export function createRepeatingPromise(fn: () => Promise): Promise { 7 | let promise: Promise | undefined; 8 | function getPromise() { 9 | if (promise) return promise; 10 | const current = promise = fn(); 11 | promise.finally(() => { 12 | if (promise === current) { 13 | promise = undefined; 14 | } 15 | }); 16 | return promise; 17 | } 18 | return { 19 | get [Symbol.toStringTag]() { 20 | return "[Promise Repeating]"; 21 | }, 22 | then(onResolve, onReject) { 23 | return getPromise().then(onResolve, onReject) 24 | }, 25 | catch(onReject) { 26 | return getPromise().catch(onReject) 27 | }, 28 | finally(onFinally) { 29 | return getPromise().finally(onFinally) 30 | } 31 | }; 32 | } 33 | 34 | export function createNavigationEvent(type: T, navigation: Navigation = getNavigation()): Promise[T]> { 35 | return createRepeatingPromise(getNavigationPromise); 36 | 37 | function getNavigationPromise() { 38 | return createNavigationPromise(type, navigation); 39 | } 40 | } 41 | 42 | export type NavigationEventsMap = { 43 | [P in keyof NavigationEventMap]: Promise[P]> 44 | } 45 | 46 | export function createNavigationEvents(navigation: Navigation = getNavigation()): NavigationEventsMap { 47 | return { 48 | navigate: createNavigationEvent("navigate", navigation), 49 | navigateerror: createNavigationEvent("navigateerror", navigation), 50 | navigatesuccess: createNavigationEvent("navigatesuccess", navigation), 51 | entrieschange: createNavigationEvent("entrieschange", navigation), 52 | currententrychange: createNavigationEvent("currententrychange", navigation) 53 | } 54 | } 55 | 56 | export async function createNavigationPromise( 57 | type: T, 58 | navigation: Navigation = getNavigation(), 59 | onEventFn?: (event: NavigationEventMap[T]) => void | unknown 60 | ): Promise[T]> { 61 | const { promise, resolve, reject } = defer[T]>(); 62 | 63 | navigation.addEventListener( 64 | type, 65 | onEvent, 66 | { 67 | once: true 68 | } 69 | ); 70 | 71 | if (type !== "navigate") { 72 | navigation.addEventListener( 73 | "navigate", 74 | onNavigate, 75 | { 76 | once: true 77 | } 78 | ) 79 | } 80 | 81 | if (type !== "navigateerror") { 82 | navigation.addEventListener( 83 | "navigateerror", 84 | onError, 85 | { 86 | once: true 87 | } 88 | ); 89 | } 90 | 91 | return promise; 92 | 93 | function removeListeners() { 94 | navigation.removeEventListener( 95 | type, 96 | onEvent 97 | ); 98 | if (type !== "navigate") { 99 | navigation.removeEventListener( 100 | "navigate", 101 | onNavigate 102 | ) 103 | } 104 | if (type !== "navigateerror") { 105 | navigation.removeEventListener( 106 | "navigateerror", 107 | onError 108 | ); 109 | } 110 | } 111 | 112 | function onEvent(event: NavigationEventMap[T]) { 113 | removeListeners(); 114 | if (onEventFn) { 115 | try { 116 | const result = onEventFn(event); 117 | if (isPromise(result)) { 118 | return result.then( 119 | () => resolve(event), 120 | reject 121 | ); 122 | } 123 | } catch (error) { 124 | return reject(error); 125 | } 126 | } else if (isNavigateEvent(event)) { 127 | onNavigate(event); 128 | } 129 | resolve(event); 130 | } 131 | 132 | function isNavigateEvent(event: NavigationEventMap[keyof NavigationEventMap]): event is NavigateEvent { 133 | return event.type === "navigate"; 134 | } 135 | 136 | function onNavigate(event: NavigateEvent) { 137 | event.intercept(); 138 | } 139 | 140 | function onError(event: NavigationEventMap["navigateerror"]) { 141 | removeListeners(); 142 | reject(event.error); 143 | } 144 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@virtualstate/navigation", 3 | "version": "1.0.1-alpha.212", 4 | "main": "./esnext/index.js", 5 | "module": "./esnext/index.js", 6 | "types": "./esnext/index.d.ts", 7 | "type": "module", 8 | "keywords": [ 9 | "app history", 10 | "app-history", 11 | "navigation", 12 | "history", 13 | "ponyfill" 14 | ], 15 | "typesVersions": { 16 | "*": { 17 | "*": [ 18 | "./esnext/index.d.ts" 19 | ], 20 | "routes": [ 21 | "./esnext/routes/index.d.ts" 22 | ], 23 | "state": [ 24 | "./esnext/state/index.d.ts" 25 | ], 26 | "polyfill": [ 27 | "./esnext/polyfill.d.ts" 28 | ], 29 | "polyfill/rollup": [ 30 | "./esnext/polyfill.d.ts" 31 | ], 32 | "tests": [ 33 | "./esnext/tests/index.d.ts" 34 | ], 35 | "rollup": [ 36 | "./esnext/index.d.ts" 37 | ], 38 | "await": [ 39 | "./esnext/await/index.d.ts" 40 | ], 41 | "dynamic": [ 42 | "./esnext/dynamic/index.d.ts" 43 | ] 44 | } 45 | }, 46 | "exports": { 47 | ".": { 48 | "require": "./esnext/rollup.cjs", 49 | "import": "./esnext/index.js", 50 | "module": "./esnext/index.js" 51 | }, 52 | "./event-target": "./esnext/event-target/async-event-target.js", 53 | "./event-target/async": "./esnext/event-target/async-event-target.js", 54 | "./event-target/sync": "./esnext/event-target/sync-event-target.js", 55 | "./esnext/polyfill": "./esnext/polyfill.js", 56 | "./esnext/get-polyfill": "./esnext/get-polyfill.js", 57 | "./esnext/apply-polyfill": "./esnext/apply-polyfill.js", 58 | "./esnext/util/serialization": "./esnext/util/serialization.js", 59 | "./polyfill": { 60 | "require": "./esnext/polyfill-rollup.js", 61 | "import": "./esnext/polyfill.js", 62 | "module": "./esnext/polyfill.js" 63 | }, 64 | "./polyfill/rollup": "./esnext/polyfill-rollup.js", 65 | "./get-polyfill": "./esnext/get-polyfill.js", 66 | "./apply-polyfill": "./esnext/apply-polyfill.js", 67 | "./routes": "./esnext/routes/index.js", 68 | "./state": "./esnext/state/index.js", 69 | "./await": "./esnext/await/index.js", 70 | "./dynamic": "./esnext/dynamic/index.js", 71 | "./rollup": { 72 | "require": "./esnext/rollup.cjs", 73 | "import": "./esnext/rollup.js" 74 | } 75 | }, 76 | "repository": { 77 | "type": "git", 78 | "url": "git+https://github.com/virtualstate/navigation.git" 79 | }, 80 | "bugs": { 81 | "url": "https://github.com/virtualstate/navigation/issues" 82 | }, 83 | "homepage": "https://github.com/virtualstate/navigation#readme", 84 | "author": "Fabian Cook ", 85 | "license": "MIT", 86 | "dependencies": { 87 | "@virtualstate/composite-key": "^1.0.0" 88 | }, 89 | "devDependencies": { 90 | "@babel/cli": "^7.15.4", 91 | "@babel/core": "^7.15.4", 92 | "@babel/preset-env": "^7.15.4", 93 | "@opennetwork/http-representation": "^3.0.0", 94 | "@rollup/plugin-node-resolve": "^13.1.1", 95 | "@rollup/plugin-typescript": "^8.3.0", 96 | "@types/chance": "^1.1.3", 97 | "@types/mkdirp": "^1.0.2", 98 | "@types/node": "^18.14.1", 99 | "@types/rimraf": "^3.0.2", 100 | "@types/whatwg-url": "^8.2.1", 101 | "@virtualstate/focus": "^1.4.9", 102 | "@virtualstate/kdl": "^1.0.1-alpha.32", 103 | "@virtualstate/promise": "^1.3.4", 104 | "@virtualstate/union": "^2.48.1", 105 | "c8": "^7.11.3", 106 | "chance": "^1.1.8", 107 | "cheerio": "^1.0.0-rc.10", 108 | "core-js": "^3.17.2", 109 | "dom-lite": "^20.2.0", 110 | "filehound": "^1.17.4", 111 | "mkdirp": "^1.0.4", 112 | "playwright": "^1.25.2", 113 | "rimraf": "^3.0.2", 114 | "rollup": "^2.61.1", 115 | "rollup-plugin-babel": "^4.4.0", 116 | "rollup-plugin-ignore": "^1.0.10", 117 | "ts-node": "^10.2.1", 118 | "typescript": "^5.4.5", 119 | "urlpattern-polyfill": "^8.0.2", 120 | "v8-to-istanbul": "^8.1.0" 121 | }, 122 | "scripts": { 123 | "build": "rm -rf esnext && tsc", 124 | "postbuild": "mkdir -p coverage && node scripts/post-build.js", 125 | "generate": "yarn build && node esnext/generate.js", 126 | "test": "yarn build && yarn test:all", 127 | "test:all": "yarn test:node && yarn test:deno && yarn test:bun", 128 | "test:node": "node --enable-source-maps esnext/tests/index.js", 129 | "test:deno": "deno run --allow-import --allow-env --allow-read --allow-net --import-map=import-map-deno.json esnext/tests/index.js", 130 | "test:bun": "bun esnext/tests/index.js", 131 | "test:deno:r": "yarn build && deno run -r --allow-import --allow-env --allow-read --allow-net --import-map=import-map-deno.json esnext/tests/index.js", 132 | "test:inspect": "yarn build && node --enable-source-maps --inspect-brk esnext/tests/index.js", 133 | "coverage": "yarn build && c8 node esnext/tests/index.js && yarn postbuild" 134 | }, 135 | "publishConfig": { 136 | "access": "public", 137 | "tag": "latest" 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/navigation-entry.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Navigation as NavigationPrototype, 3 | NavigationHistoryEntry as NavigationHistoryEntryPrototype, 4 | NavigationHistoryEntryEventMap, 5 | NavigationHistoryEntryInit as NavigationHistoryEntryInitPrototype, 6 | NavigationNavigationType, 7 | } from "./spec/navigation"; 8 | import { NavigationEventTarget } from "./navigation-event-target"; 9 | import { EventTargetListeners } from "./event-target"; 10 | import { v4 } from "./util/uuid-or-random"; 11 | 12 | // To prevent cyclic imports, where a circular is used, instead use the prototype interface 13 | // and then copy over the "private" symbol 14 | const NavigationGetState = Symbol.for("@virtualstate/navigation/getState"); 15 | 16 | export const NavigationHistoryEntryNavigationType = Symbol.for( 17 | "@virtualstate/navigation/entry/navigationType" 18 | ); 19 | export const NavigationHistoryEntryKnownAs = Symbol.for( 20 | "@virtualstate/navigation/entry/knownAs" 21 | ); 22 | export const NavigationHistoryEntrySetState = Symbol.for( 23 | "@virtualstate/navigation/entry/setState" 24 | ); 25 | 26 | export interface NavigationHistoryEntryGetStateFn { 27 | (entry: NavigationHistoryEntry): S | undefined 28 | } 29 | 30 | export interface NavigationHistoryEntryFn { 31 | (entry: NavigationHistoryEntry): void 32 | } 33 | 34 | export interface NavigationHistoryEntrySerialized { 35 | key: string; 36 | navigationType?: string; 37 | url?: string; 38 | state?: S; 39 | sameDocument?: boolean; 40 | } 41 | 42 | export interface NavigationHistoryEntryInit 43 | extends NavigationHistoryEntryInitPrototype { 44 | navigationType: NavigationNavigationType; 45 | getState?: NavigationHistoryEntryGetStateFn 46 | [NavigationHistoryEntryKnownAs]?: Set; 47 | } 48 | 49 | function isPrimitiveValue(state: unknown): state is number | boolean | symbol | bigint | string { 50 | return ( 51 | typeof state === "number" || 52 | typeof state === "boolean" || 53 | typeof state === "symbol" || 54 | typeof state === "bigint" || 55 | typeof state === "string" 56 | ) 57 | } 58 | 59 | function isValue(state: unknown) { 60 | return !!(state || isPrimitiveValue(state)); 61 | } 62 | 63 | export class NavigationHistoryEntry 64 | extends NavigationEventTarget 65 | implements NavigationHistoryEntryPrototype 66 | { 67 | readonly #index: number | (() => number); 68 | #state: S | undefined; 69 | 70 | get index() { 71 | return typeof this.#index === "number" ? this.#index : this.#index(); 72 | } 73 | 74 | public readonly key: string; 75 | public readonly id: string; 76 | public readonly url?: string; 77 | public readonly sameDocument: boolean; 78 | 79 | get [NavigationHistoryEntryNavigationType]() { 80 | return this.#options.navigationType; 81 | } 82 | 83 | get [NavigationHistoryEntryKnownAs]() { 84 | const set = new Set(this.#options[NavigationHistoryEntryKnownAs]); 85 | set.add(this.id); 86 | return set; 87 | } 88 | 89 | readonly #options: NavigationHistoryEntryInit; 90 | 91 | get [EventTargetListeners]() { 92 | return [ 93 | ...(super[EventTargetListeners] ?? []), 94 | ...(this.#options[EventTargetListeners] ?? []), 95 | ]; 96 | } 97 | 98 | constructor(init: NavigationHistoryEntryInit) { 99 | super(); 100 | this.#options = init; 101 | this.key = init.key || v4(); 102 | this.id = v4(); 103 | this.url = init.url ?? undefined; 104 | this.#index = init.index; 105 | this.sameDocument = init.sameDocument ?? true; 106 | this.#state = init.state ?? undefined; 107 | } 108 | 109 | [NavigationGetState]() { 110 | return this.#options?.getState?.(this); 111 | } 112 | 113 | getState(): ST; 114 | getState(): S; 115 | getState(): unknown { 116 | let state = this.#state; 117 | 118 | if (!isValue(state)) { 119 | const external = this[NavigationGetState](); 120 | if (isValue(external)) { 121 | state = this.#state = external; 122 | } 123 | } 124 | 125 | /** 126 | * https://github.com/WICG/app-history/blob/7c0332b30746b14863f717404402bc49e497a2b2/spec.bs#L1406 127 | * Note that in general, unless the state value is a primitive, entry.getState() !== entry.getState(), since a fresh copy is returned each time. 128 | */ 129 | if ( 130 | typeof state === "undefined" || 131 | isPrimitiveValue(state) 132 | ) { 133 | return state; 134 | } 135 | if (typeof state === "function") { 136 | console.warn( 137 | "State passed to Navigation.navigate was a function, this may be unintentional" 138 | ); 139 | console.warn( 140 | "Unless a state value is primitive, with a standard implementation of Navigation" 141 | ); 142 | console.warn( 143 | "your state value will be serialized and deserialized before this point, meaning" 144 | ); 145 | console.warn("a function would not be usable."); 146 | } 147 | return { 148 | ...state, 149 | }; 150 | } 151 | 152 | [NavigationHistoryEntrySetState](state: S) { 153 | this.#state = state; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/dynamic/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Navigation, 3 | NavigateEvent, 4 | NavigationEventMap, 5 | } from "../navigation"; 6 | import {like, ok} from "../is"; 7 | 8 | type AnyReturnTypeOrAsync = Promise | void | unknown; 9 | 10 | interface Intercept { 11 | intercept(event: NavigationEventMap["navigate"], navigation: Navigation): AnyReturnTypeOrAsync 12 | } 13 | 14 | export interface DynamicNavigationOptions { 15 | hosts?: Record; 16 | baseURL?: string; 17 | extension?: string; 18 | } 19 | 20 | function getHost(options?: DynamicNavigationOptions) { 21 | if (options.baseURL) { 22 | return new URL(options.baseURL).host; 23 | } 24 | if (options.hosts) { 25 | const hosts = Object.keys(options.hosts); 26 | if (hosts.includes("localhost")) { 27 | return "localhost"; 28 | } 29 | if (hosts.length) { 30 | return hosts[0]; 31 | } 32 | } 33 | return undefined; 34 | } 35 | 36 | function isMaybeExtension(string: string) { 37 | return /\.[a-z]+$/.test(string); 38 | } 39 | 40 | function extensionName(string: string) { 41 | if (!isMaybeExtension(string)) { 42 | return ""; 43 | } 44 | const split = string.split("."); 45 | return `.${split.at(-1)}`; 46 | } 47 | 48 | function directoryName(string: string) { 49 | const split = string.split("/"); 50 | split.pop(); 51 | return `${split.join("/")}/`; 52 | } 53 | 54 | function getBaseURL(options?: DynamicNavigationOptions) { 55 | const url = get(); 56 | const instance = new URL(url); 57 | let extension = ".js"; 58 | if (isMaybeExtension(url)) { 59 | extension = extensionName(instance.pathname); 60 | instance.pathname = directoryName(instance.pathname); 61 | } 62 | if (typeof options.extension === "string") { 63 | extension = options.extension; 64 | } 65 | return { 66 | baseURL: instance.toString(), 67 | extension 68 | }; 69 | 70 | function get() { 71 | if (options.baseURL) { 72 | return options.baseURL; 73 | } 74 | const host = getHost(options); 75 | if (host) return `file://${host}`; 76 | try { 77 | if (typeof window !== "undefined") { 78 | if (typeof window.location !== "undefined") { 79 | return window.location.origin; 80 | } 81 | } 82 | } catch {} 83 | throw new Error("Could not resolve base URL"); 84 | } 85 | } 86 | 87 | export class DynamicNavigation extends Navigation { 88 | 89 | modules: Record; 90 | options?: DynamicNavigationOptions 91 | baseURL: string; 92 | extension: string; 93 | 94 | constructor(options?: DynamicNavigationOptions) { 95 | const { 96 | baseURL, 97 | extension 98 | } = getBaseURL(options); 99 | super({ 100 | baseURL 101 | }); 102 | this.modules = {}; 103 | this.baseURL = baseURL; 104 | this.extension = extension; 105 | this.options = options; 106 | this.addEventListener("navigate", this.onNavigate); 107 | this.addEventListener("navigateerror", console.error); 108 | } 109 | 110 | onNavigate = (event: NavigateEvent) => { 111 | // TODO implement cross origin runtime navigation 112 | ok(event.destination.sameDocument, "Must be sameDocument navigation"); 113 | event.intercept({ 114 | commit: "after-transition", 115 | handler: () => this.intercept(event) 116 | }); 117 | } 118 | 119 | async intercept(event: NavigateEvent) { 120 | const dynamic = await this.getModule(event.destination.url); 121 | 122 | event.commit(); 123 | 124 | if (isHandler(dynamic)) { 125 | await dynamic.intercept(event, this); 126 | } 127 | 128 | function isHandler(value: unknown): value is Intercept { 129 | return ( 130 | like(value) && 131 | typeof value.intercept === "function" 132 | ); 133 | } 134 | } 135 | 136 | async getModule(url: string) { 137 | const instance = new URL(url, this.baseURL); 138 | const string = instance.toString(); 139 | const existing = this.modules[string]; 140 | if (existing) { 141 | return existing; 142 | } 143 | 144 | const hostPath = this.options.hosts?.[instance.host]; 145 | 146 | if (hostPath) { 147 | instance.pathname = `${hostPath.replace(/\/$/, "")}/${instance.pathname}` 148 | instance.host = ""; 149 | } 150 | 151 | if (!isMaybeExtension(instance.pathname)) { 152 | if (instance.pathname.endsWith("/")) { 153 | instance.pathname = `${instance.pathname}index${this.extension}`; 154 | } else { 155 | instance.pathname = `${instance.pathname}${this.extension}` 156 | } 157 | } 158 | 159 | const dynamic = await import(instance.toString()); 160 | this.modules[string] = dynamic; 161 | return dynamic; 162 | } 163 | 164 | } -------------------------------------------------------------------------------- /src/history.ts: -------------------------------------------------------------------------------- 1 | import { Navigation, NavigationResult } from "./spec/navigation"; 2 | import { 3 | NavigationLocation, 4 | NavigationLocationOptions, 5 | AppLocationAwaitFinished, 6 | AppLocationTransitionURL, 7 | } from "./location"; 8 | import { InvalidStateError } from "./navigation-errors"; 9 | 10 | const State = Symbol.for("@virtualstate/navigation/history/state"); 11 | 12 | export type ScrollRestoration = "auto" | "manual"; 13 | 14 | // export interface History {} 15 | 16 | export interface NavigationHistoryOptions extends NavigationLocationOptions { 17 | navigation: Navigation; 18 | [State]?: unknown 19 | } 20 | 21 | export interface NavigationHistory {} 22 | 23 | 24 | 25 | /** 26 | * @experimental 27 | */ 28 | export class NavigationHistory 29 | extends NavigationLocation 30 | implements History 31 | { 32 | readonly #options: NavigationHistoryOptions; 33 | readonly #navigation: Navigation; 34 | 35 | constructor(options: NavigationHistoryOptions) { 36 | super(options); 37 | this.#options = options; 38 | this.#navigation = options.navigation; 39 | } 40 | 41 | get length() { 42 | return this.#navigation.entries().length; 43 | } 44 | 45 | scrollRestoration: ScrollRestoration = "manual"; 46 | 47 | get state(): unknown { 48 | const currentState = this.#navigation.currentEntry?.getState(); 49 | if (typeof currentState === "string" || typeof currentState === "number" || typeof currentState === "boolean") { 50 | return currentState; 51 | } 52 | return this.#options[State] ?? undefined; 53 | } 54 | 55 | back(): unknown; 56 | back(): void; 57 | back(): unknown { 58 | const entries = this.#navigation.entries(); 59 | const index = this.#navigation.currentEntry?.index ?? -1; 60 | const back = entries[index - 1]; 61 | const url = back?.url; 62 | if (!url) throw new InvalidStateError("Cannot go back"); 63 | return this[AppLocationTransitionURL](url, () => 64 | this.#navigation.back() 65 | ); 66 | } 67 | 68 | forward(): unknown; 69 | forward(): void; 70 | forward(): unknown { 71 | const entries = this.#navigation.entries(); 72 | const index = this.#navigation.currentEntry?.index ?? -1; 73 | const forward = entries[index + 1]; 74 | const url = forward?.url; 75 | if (!url) throw new InvalidStateError("Cannot go forward"); 76 | return this[AppLocationTransitionURL](url, () => 77 | this.#navigation.forward() 78 | ); 79 | } 80 | 81 | go(delta?: number): unknown; 82 | go(delta?: number): void; 83 | go(delta?: number): unknown { 84 | if (typeof delta !== "number" || delta === 0 || isNaN(delta)) { 85 | return this[AppLocationAwaitFinished](this.#navigation.reload()); 86 | } 87 | const entries = this.#navigation.entries(); 88 | const { 89 | currentEntry 90 | } = this.#navigation; 91 | if (!currentEntry) { 92 | throw new Error(`Could not go ${delta}`); 93 | } 94 | const nextIndex = currentEntry.index + delta; 95 | const nextEntry = entries[nextIndex]; 96 | if (!nextEntry) { 97 | throw new Error(`Could not go ${delta}`); 98 | } 99 | const nextEntryKey = nextEntry.key; 100 | return this[AppLocationAwaitFinished](this.#navigation.traverseTo(nextEntryKey)); 101 | } 102 | 103 | replaceState( 104 | data: any, 105 | unused: string, 106 | url?: string | URL | null 107 | ): unknown; 108 | replaceState(data: any, unused: string, url?: string | URL | null): void; 109 | replaceState( 110 | data: any, 111 | unused: string, 112 | url?: string | URL | null 113 | ): unknown { 114 | if (url) { 115 | return this[AppLocationTransitionURL](url, (url) => 116 | this.#navigation.navigate(url.toString(), { 117 | state: data, 118 | history: "replace", 119 | }) 120 | ); 121 | } else { 122 | return this.#navigation.updateCurrentEntry({ 123 | state: data 124 | }); 125 | } 126 | } 127 | 128 | pushState( 129 | data: S, 130 | unused: string, 131 | url?: string | URL | null 132 | ): unknown; 133 | pushState( 134 | data: object, 135 | unused: string, 136 | url?: string | URL | null 137 | ): unknown; 138 | pushState(data: unknown, unused: string, url?: string | URL): unknown; 139 | pushState(data: S, unused: string, url?: string | URL | null): void; 140 | pushState(data: object, unused: string, url?: string | URL | null): void; 141 | pushState(data: unknown, unused: string, url?: string | URL): void; 142 | pushState( 143 | data: S, 144 | unused: string, 145 | url?: string | URL | null 146 | ): unknown { 147 | if (url) { 148 | return this[AppLocationTransitionURL](url, (url) => 149 | this.#navigation.navigate(url.toString(), { 150 | state: data, 151 | }) 152 | ); 153 | } else { 154 | return this.#navigation.updateCurrentEntry({ 155 | state: data, 156 | }); 157 | } 158 | } 159 | } 160 | 161 | /** 162 | * @experimental 163 | * @internal 164 | */ 165 | export class NavigationSync 166 | extends NavigationHistory 167 | implements NavigationHistory, NavigationLocation {} 168 | -------------------------------------------------------------------------------- /src/event-target/event-target-listeners.ts: -------------------------------------------------------------------------------- 1 | import { Event } from "./event"; 2 | import { EventListener } from "./context"; 3 | import { EventCallback, SyncEventCallback } from "./callback"; 4 | import { EventDescriptor, EventDescriptorSymbol } from "./descriptor"; 5 | import { matchEventCallback } from "./callback"; 6 | import { isSignalEvent } from "./signal-event"; 7 | import { isAbortError } from "../navigation-errors"; 8 | import { 9 | EventTargetAddListenerOptions, 10 | EventTargetListeners as EventTargetListenersSymbol, 11 | EventTargetListenersIgnore, 12 | EventTargetListenersMatch, 13 | } from "./event-target-options"; 14 | 15 | export interface ExternalSyncEventTargetListeners { 16 | addEventListener( 17 | type: string, 18 | callback: SyncEventCallback, 19 | options?: EventTargetAddListenerOptions 20 | ): void; 21 | removeEventListener( 22 | type: string, 23 | callback: SyncEventCallback, 24 | options?: unknown 25 | ): void; 26 | } 27 | 28 | export interface EventTargetListeners 29 | extends ExternalSyncEventTargetListeners { 30 | addEventListener( 31 | type: string | symbol, 32 | callback: EventCallback, 33 | options?: EventTargetAddListenerOptions 34 | ): void; 35 | addEventListener( 36 | type: string | symbol, 37 | callback: Function, 38 | options?: EventTargetAddListenerOptions 39 | ): void; 40 | removeEventListener( 41 | type: string | symbol, 42 | callback: Function, 43 | options?: unknown 44 | ): void; 45 | hasEventListener(type: string | symbol, callback?: Function): boolean; 46 | } 47 | 48 | function isFunctionEventCallback(fn: Function): fn is EventCallback { 49 | return typeof fn === "function"; 50 | } 51 | 52 | const EventTargetDescriptors = Symbol.for("@virtualstate/navigation/event-target/descriptors"); 53 | 54 | export class EventTargetListeners implements EventTargetListeners { 55 | [key: string]: unknown; 56 | 57 | [EventTargetDescriptors]?: EventDescriptor[] = []; 58 | [EventTargetListenersIgnore]?: WeakSet = 59 | new WeakSet(); 60 | 61 | get [EventTargetListenersSymbol](): EventDescriptor[] | undefined { 62 | return [...(this[EventTargetDescriptors] ?? [])]; 63 | } 64 | 65 | [EventTargetListenersMatch]?(type: string | symbol) { 66 | const external = this[EventTargetListenersSymbol]; 67 | const matched = [ 68 | ...new Set([...(external ?? []), ...(this[EventTargetDescriptors] ?? [])]), 69 | ] 70 | .filter( 71 | (descriptor) => descriptor.type === type || descriptor.type === "*" 72 | ) 73 | .filter( 74 | (descriptor) => !this[EventTargetListenersIgnore]?.has(descriptor) 75 | ); 76 | 77 | const listener: unknown = 78 | typeof type === "string" ? this[`on${type}`] : undefined; 79 | 80 | if (typeof listener === "function" && isFunctionEventCallback(listener)) { 81 | matched.push({ 82 | type, 83 | callback: listener, 84 | [EventDescriptorSymbol]: true, 85 | }); 86 | } 87 | 88 | return matched; 89 | } 90 | 91 | addEventListener( 92 | type: string | symbol, 93 | callback: EventCallback, 94 | options?: EventTargetAddListenerOptions 95 | ): void; 96 | addEventListener( 97 | type: string | symbol, 98 | callback: Function, 99 | options?: EventTargetAddListenerOptions 100 | ): void; 101 | addEventListener( 102 | type: string, 103 | callback: EventCallback, 104 | options?: EventTargetAddListenerOptions 105 | ) { 106 | const listener: EventListener = { 107 | ...options, 108 | isListening: () => 109 | !!this[EventTargetDescriptors]?.find(matchEventCallback(type, callback)), 110 | descriptor: { 111 | [EventDescriptorSymbol]: true, 112 | ...options, 113 | type, 114 | callback, 115 | }, 116 | timestamp: Date.now(), 117 | }; 118 | if (listener.isListening()) { 119 | return; 120 | } 121 | this[EventTargetDescriptors]?.push(listener.descriptor); 122 | } 123 | 124 | removeEventListener( 125 | type: string | symbol, 126 | callback: Function, 127 | options?: unknown 128 | ): void; 129 | removeEventListener( 130 | type: string | symbol, 131 | callback: Function, 132 | options?: unknown 133 | ) { 134 | if (!isFunctionEventCallback(callback)) { 135 | return; 136 | } 137 | const externalListeners = 138 | this[EventTargetListenersSymbol] ?? this[EventTargetDescriptors]?? []; 139 | const externalIndex = externalListeners.findIndex( 140 | matchEventCallback(type, callback, options) 141 | ); 142 | if (externalIndex === -1) { 143 | return; 144 | } 145 | const index = 146 | this[EventTargetDescriptors]?.findIndex(matchEventCallback(type, callback, options)) ?? 147 | -1; 148 | if (index !== -1) { 149 | this[EventTargetDescriptors]?.splice(index, 1); 150 | } 151 | const descriptor = externalListeners[externalIndex]; 152 | if (descriptor) { 153 | this[EventTargetListenersIgnore]?.add(descriptor); 154 | } 155 | } 156 | 157 | hasEventListener(type: string | symbol, callback?: Function): boolean; 158 | hasEventListener(type: string, callback?: Function): boolean { 159 | if (callback && !isFunctionEventCallback(callback)) { 160 | return false; 161 | } 162 | const foundIndex = 163 | this[EventTargetDescriptors]?.findIndex(matchEventCallback(type, callback)) ?? -1; 164 | return foundIndex > -1; 165 | } 166 | } 167 | 168 | export function isSignalHandled(event: Event, error: unknown) { 169 | if ( 170 | isSignalEvent(event) && 171 | event.signal.aborted && 172 | error instanceof Error && 173 | isAbortError(error) 174 | ) { 175 | return true; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/navigation-navigation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Navigation, 3 | NavigationHistoryEntry, 4 | NavigationEventMap, 5 | NavigationNavigationOptions, 6 | NavigationReloadOptions, 7 | NavigationResult, 8 | NavigationUpdateCurrentOptions, 9 | } from "./spec/navigation"; 10 | import { 11 | EventCallback, 12 | EventTargetAddListenerOptions, 13 | SyncEventCallback, 14 | Event, 15 | EventTargetListeners as EventTargetListenersSymbol, 16 | EventDescriptor, 17 | } from "./event-target"; 18 | import { NavigationNavigateOptions } from "./create-navigation-transition"; 19 | 20 | export interface NavigationNavigation { 21 | new (thisValue: Navigation): NavigationNavigation; 22 | } 23 | 24 | const Navigation = Symbol.for("@virtualstate/navigation/instance"); 25 | 26 | export class NavigationNavigation implements Navigation { 27 | [key: string]: unknown; 28 | 29 | readonly [Navigation]: Navigation; 30 | 31 | get [EventTargetListenersSymbol](): EventDescriptor[] | undefined { 32 | return this[Navigation][EventTargetListenersSymbol]; 33 | } 34 | 35 | constructor(navigation: Navigation) { 36 | this[Navigation] = navigation; 37 | } 38 | 39 | get canGoBack() { 40 | return this[Navigation].canGoBack; 41 | } 42 | 43 | get canGoForward() { 44 | return this[Navigation].canGoForward; 45 | } 46 | 47 | get currentEntry() { 48 | return this[Navigation].currentEntry; 49 | } 50 | 51 | set oncurrententrychange(value: Navigation["oncurrententrychange"]) { 52 | this[Navigation].oncurrententrychange = value; 53 | } 54 | set onnavigate(value: Navigation["onnavigate"]) { 55 | this[Navigation].onnavigate = value; 56 | } 57 | set onnavigateerror(value: Navigation["onnavigateerror"]) { 58 | this[Navigation].onnavigateerror = value; 59 | } 60 | set onnavigatesuccess(value: Navigation["onnavigatesuccess"]) { 61 | this[Navigation].onnavigatesuccess = value; 62 | } 63 | get transition() { 64 | return this[Navigation].transition; 65 | } 66 | 67 | addEventListener>( 68 | type: K, 69 | listener: (ev: NavigationEventMap[K]) => unknown, 70 | options?: boolean | EventTargetAddListenerOptions 71 | ): void; 72 | addEventListener( 73 | type: string, 74 | listener: EventCallback, 75 | options?: boolean | EventTargetAddListenerOptions 76 | ): void; 77 | addEventListener( 78 | type: string | symbol, 79 | callback: EventCallback, 80 | options?: EventTargetAddListenerOptions 81 | ): void; 82 | addEventListener( 83 | type: string, 84 | callback: EventCallback, 85 | options?: EventTargetAddListenerOptions 86 | ): void; 87 | addEventListener( 88 | type: string | symbol, 89 | callback: Function, 90 | options?: EventTargetAddListenerOptions 91 | ): void; 92 | addEventListener( 93 | type: string | symbol, 94 | listener: EventCallback, 95 | options?: boolean | EventTargetAddListenerOptions 96 | ): void { 97 | if (typeof type === "string") { 98 | this[Navigation].addEventListener( 99 | type, 100 | listener, 101 | typeof options === "boolean" ? { once: true } : options 102 | ); 103 | } 104 | } 105 | 106 | back(options?: NavigationNavigationOptions): NavigationResult { 107 | return this[Navigation].back(options); 108 | } 109 | 110 | dispatchEvent(event: Event): Promise; 111 | dispatchEvent(event: Event): void; 112 | dispatchEvent(event: Event): void | Promise { 113 | return this[Navigation].dispatchEvent(event); 114 | } 115 | 116 | entries(): NavigationHistoryEntry[] { 117 | return this[Navigation].entries(); 118 | } 119 | 120 | forward(options?: NavigationNavigationOptions): NavigationResult { 121 | return this[Navigation].forward(options); 122 | } 123 | 124 | traverseTo(key: string, options?: NavigationNavigationOptions): NavigationResult { 125 | return this[Navigation].traverseTo(key, options); 126 | } 127 | 128 | hasEventListener(type: string | symbol, callback?: Function): boolean; 129 | hasEventListener(type: string, callback?: Function): boolean; 130 | hasEventListener(type: string | symbol, callback?: Function): boolean { 131 | return this[Navigation].hasEventListener(type, callback); 132 | } 133 | 134 | navigate(url: string, options?: NavigationNavigateOptions): NavigationResult { 135 | return this[Navigation].navigate(url, options); 136 | } 137 | 138 | reload(options?: NavigationReloadOptions): NavigationResult { 139 | return this[Navigation].reload(options); 140 | } 141 | 142 | removeEventListener>( 143 | type: K, 144 | listener: (ev: NavigationEventMap[K]) => unknown, 145 | options?: boolean | EventListenerOptions 146 | ): void; 147 | removeEventListener( 148 | type: string, 149 | listener: EventCallback, 150 | options?: boolean | EventListenerOptions 151 | ): void; 152 | removeEventListener( 153 | type: string | symbol, 154 | callback: Function, 155 | options?: unknown 156 | ): void; 157 | removeEventListener( 158 | type: string, 159 | callback: SyncEventCallback, 160 | options?: unknown 161 | ): void; 162 | removeEventListener( 163 | type: string | symbol, 164 | listener: EventCallback, 165 | options?: unknown 166 | ): void { 167 | if (typeof type === "string") { 168 | return this[Navigation].removeEventListener(type, listener, options); 169 | } 170 | } 171 | 172 | updateCurrentEntry(options: NavigationUpdateCurrentOptions): unknown; 173 | updateCurrentEntry(options: NavigationUpdateCurrentOptions): void; 174 | updateCurrentEntry( 175 | options: NavigationUpdateCurrentOptions 176 | ): unknown { 177 | return this[Navigation].updateCurrentEntry(options); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/tests/examples/demo-1.ts: -------------------------------------------------------------------------------- 1 | import * as Cheerio from "cheerio"; 2 | import { Navigation } from "../../spec/navigation"; 3 | import { fetch } from "./fetch"; 4 | import { addEventListener } from "../../event-target/global"; 5 | import { Response } from "@opennetwork/http-representation"; 6 | import { ok } from "../util"; 7 | import { v4 } from "../../util/uuid-or-random"; 8 | import { parseDOM } from "../../util/parse-dom"; 9 | 10 | const SUBPAGE_MARKER = v4(); 11 | 12 | export async function demo1(navigation: Navigation) { 13 | addSubpageEventListener(); 14 | 15 | let delayChecked = false; 16 | 17 | interface Element { 18 | localName: string; 19 | replaceWith(element: Element): void; 20 | innerHTML: string; 21 | textContent: string; 22 | style: Record; 23 | checked?: boolean; 24 | } 25 | 26 | const elements: Record = { 27 | "#add-delay": { 28 | ...createElement("button"), 29 | get checked() { 30 | return delayChecked; 31 | }, 32 | }, 33 | main: { 34 | ...createElement("main"), 35 | replaceWith(element: Element) { 36 | elements["main"] = { 37 | ...element, 38 | replaceWith: this.replaceWith, 39 | }; 40 | }, 41 | }, 42 | }; 43 | 44 | const document = { 45 | querySelector(query: string) { 46 | return elements[query]; 47 | }, 48 | body: { 49 | children: [createElement("p")], 50 | }, 51 | createElement, 52 | documentTransition: { 53 | start() {}, 54 | }, 55 | title: "", 56 | }; 57 | 58 | function createElement(localName: string): Element { 59 | return { 60 | localName, 61 | textContent: "", 62 | innerHTML: "", 63 | style: { 64 | contain: "", 65 | }, 66 | replaceWith(element: Element) {}, 67 | }; 68 | } 69 | 70 | // const useSET = document.querySelector("#use-set"); 71 | // if (!document.documentTransition) { 72 | // useSET.checked = false; 73 | // } 74 | const addDelay = document.querySelector("#add-delay"); 75 | 76 | const sharedElements = [...document.body.children].filter( 77 | (el) => el.localName !== "main" 78 | ); 79 | for (const el of sharedElements) { 80 | el.style.contain = "paint"; 81 | } 82 | 83 | navigation.addEventListener("navigateerror", console.error); 84 | 85 | navigation.addEventListener("navigate", (e) => { 86 | console.log(e); 87 | 88 | if (!e.canIntercept || e.hashChange) { 89 | return; 90 | } 91 | 92 | e.intercept({ 93 | async handler() { 94 | e.signal.addEventListener("abort", () => { 95 | // console.log(e.signal); 96 | const newMain = document.createElement("main"); 97 | newMain.textContent = "You pressed the browser stop button!"; 98 | document.querySelector("main").replaceWith(newMain); 99 | console.log("Hello?"); 100 | }); 101 | 102 | if (addDelay.checked) { 103 | await delay(2_000, { signal: e.signal }); 104 | } 105 | 106 | // if (useSET.checked) { 107 | // await document.documentTransition.prepare({ 108 | // rootTransition: getTransition(e), 109 | // sharedElements 110 | // }); 111 | // document.documentTransition.start({ sharedElements }); 112 | // } 113 | 114 | const body = await ( 115 | await fetch(e.destination.url, { signal: e.signal }) 116 | ).text(); 117 | const { title, main } = await getResult(body); 118 | 119 | document.title = title; 120 | document.querySelector("main").replaceWith(main); 121 | 122 | // if (useSET.checked) { 123 | // await document.documentTransition.start(); 124 | // } 125 | } 126 | }); 127 | }); 128 | 129 | async function getResult(htmlString: string) { 130 | const { innerHTML, title } = await parseDOM(htmlString, "main"); 131 | const main = createElement("main"); 132 | main.innerHTML = innerHTML; 133 | return { title, main }; 134 | } 135 | 136 | // function getTransition(e) { 137 | // if (e.navigationType === "reload" || e.navigationType === "replace") { 138 | // return "explode"; 139 | // } 140 | // if (e.navigationType === "traverse" && e.destination.index < navigation.current.index) { 141 | // return "reveal-right"; 142 | // } 143 | // return "reveal-left"; 144 | // } 145 | 146 | function delay(ms: number, event?: { signal?: AbortSignal }) { 147 | return new Promise((resolve, reject) => { 148 | setTimeout(resolve, ms); 149 | event?.signal?.addEventListener("abort", reject); 150 | }); 151 | } 152 | 153 | await navigation.navigate("subpage.html").finished; 154 | 155 | const main = document.querySelector("main"); 156 | 157 | ok(main); 158 | ok(main.innerHTML); 159 | 160 | // console.log(main.innerHTML); 161 | ok(main.innerHTML.includes(SUBPAGE_MARKER)); 162 | } 163 | 164 | function addSubpageEventListener() { 165 | addEventListener("fetch", (event) => { 166 | const { pathname } = new URL(event.request.url); 167 | if (pathname !== "/subpage.html") return; 168 | return event.respondWith( 169 | new Response( 170 | ` 171 | 172 | 173 | 174 | 175 | App history demo: subpage 176 | 177 | 178 |

App history demo

179 | 180 |
181 |

I am subpage.html!

182 | 183 |

You can use either your browser back button, or the following link, to go back to index.html. Either will perform a single-page navigation, in browsers that support app history!

184 | 185 |

Back to index.html.

186 | 187 |

188 |

Page id: ${SUBPAGE_MARKER}

189 |
190 | 191 |

If you see this, you did a normal multi-page navigation, not an app history-mediated single-page navigation.

192 | 193 | 194 | 195 | `, 196 | { 197 | status: 200, 198 | headers: { 199 | "Content-Type": "text/html", 200 | }, 201 | } 202 | ) 203 | ); 204 | }); 205 | } 206 | -------------------------------------------------------------------------------- /src/tests/state/index.tsx: -------------------------------------------------------------------------------- 1 | import { state, setState } from "../../state"; 2 | import {Router} from "../../routes"; 3 | import {ok} from "../../is"; 4 | import {descendants, h} from "@virtualstate/focus"; 5 | import {Navigation} from "../../navigation"; 6 | import {transition} from "../../transition"; 7 | import {getNavigation} from "../../get-navigation"; 8 | import {isWindowNavigation} from "../util"; 9 | 10 | if (!isWindowNavigation(getNavigation())) { 11 | const navigation = getNavigation() 12 | 13 | await navigation.navigate("/").finished; 14 | 15 | const router = new Router(navigation); 16 | 17 | const { route, then } = router; 18 | 19 | const changes: unknown[] = [] 20 | 21 | async function *Test() { 22 | console.log("Test start"); 23 | for await (const change of state()) { 24 | console.log({ change }) 25 | changes.push(change); 26 | } 27 | console.log("Test end"); 28 | } 29 | 30 | route("/test-state", () => ()); 31 | 32 | // This is detaching the promise from the route transition. 33 | then(node => void descendants(node).catch(error => error)) 34 | 35 | console.log("Navigate /test-state"); 36 | await navigation.navigate("/test-state").finished; 37 | 38 | const initialEntry = navigation.currentEntry; 39 | 40 | setState("Test 1"); 41 | setState("Test 2"); 42 | 43 | await new Promise(queueMicrotask); 44 | 45 | setState("Test 3"); 46 | 47 | await new Promise(resolve => setTimeout(resolve, 100)); 48 | 49 | console.log("Navigate /another"); 50 | await navigation.navigate("/another").finished; 51 | 52 | const nextEntry = navigation.currentEntry; 53 | 54 | // Should be setting the next entry 55 | setState("Test 4"); 56 | 57 | ok(nextEntry.id !== initialEntry.id, "Expected entry id not to match"); 58 | ok(nextEntry.getState() !== initialEntry.getState(), "Expected state to be different"); 59 | 60 | router.detach(); 61 | 62 | ok(changes.length === 3); 63 | ok(changes[0] === "Test 1"); 64 | ok(changes[1] === "Test 2"); 65 | ok(changes[2] === "Test 3"); 66 | 67 | 68 | } 69 | 70 | { 71 | 72 | const navigation = new Navigation() 73 | 74 | await navigation.navigate("/").finished; 75 | 76 | const router = new Router(navigation); 77 | 78 | const { route, then } = router; 79 | 80 | const changes: unknown[] = [] 81 | 82 | const entryChange = state(navigation); 83 | 84 | async function *Test() { 85 | console.log("Test start"); 86 | for await (const change of entryChange) { 87 | console.log({ change }) 88 | changes.push(change); 89 | } 90 | console.log("Test end"); 91 | } 92 | 93 | route("/*", () => { 94 | return "Default"; 95 | }) 96 | 97 | route("/test-route", () => { 98 | return "test" 99 | }) 100 | 101 | route("/route", () => { 102 | console.log("/route"); 103 | return 104 | }); 105 | 106 | // This is detaching the promise from the route transition. 107 | then(node => void descendants(node).catch(error => error)) 108 | 109 | await navigation.navigate("/route").finished; 110 | 111 | const initialEntry = navigation.currentEntry; 112 | 113 | navigation.updateCurrentEntry({ 114 | state: "Test 1" 115 | }); 116 | navigation.updateCurrentEntry({ 117 | state: "Test 2" 118 | }); 119 | 120 | await new Promise(queueMicrotask); 121 | 122 | navigation.updateCurrentEntry({ 123 | state: "Test 3" 124 | }); 125 | 126 | await new Promise(resolve => setTimeout(resolve, 100)); 127 | 128 | await navigation.navigate("/another").finished; 129 | 130 | const nextEntry = navigation.currentEntry; 131 | 132 | // Should be setting the next entry 133 | navigation.updateCurrentEntry({ 134 | state: "Test 4" 135 | }); 136 | 137 | ok(nextEntry.id !== initialEntry.id); 138 | ok(nextEntry.getState() !== initialEntry.getState()); 139 | 140 | { 141 | ok(changes.length === 3); 142 | ok(changes[0] === "Test 1"); 143 | ok(changes[1] === "Test 2"); 144 | ok(changes[2] === "Test 3"); 145 | } 146 | 147 | await navigation.navigate("/test-route").finished; 148 | 149 | { 150 | ok(changes.length === 3); 151 | } 152 | 153 | console.log("Navigating to /route"); 154 | 155 | await navigation.navigate("/route").finished; 156 | 157 | navigation.updateCurrentEntry({ 158 | state: "Test 5" 159 | }); 160 | navigation.updateCurrentEntry({ 161 | state: "Test 6" 162 | }); 163 | 164 | navigation.updateCurrentEntry({ 165 | state: "Test 7" 166 | }); 167 | 168 | await new Promise(resolve => setTimeout(resolve, 100)); 169 | 170 | await navigation.navigate("/").finished; 171 | 172 | console.log(changes); 173 | { 174 | ok(changes.length > 3); 175 | ok(changes.at(-1) === "Test 7"); 176 | } 177 | 178 | } 179 | 180 | 181 | { 182 | const navigation = new Navigation(); 183 | 184 | await navigation.navigate("/", { }).finished; 185 | 186 | const changes = state(navigation); 187 | 188 | const controller = new AbortController(); 189 | 190 | async function app(): Promise { 191 | for await (const change of changes) { 192 | console.log({ change }); 193 | } 194 | } 195 | 196 | navigation.addEventListener("navigate", event => event.intercept({ 197 | handler: () => void app() 198 | })); 199 | 200 | // The below is "simulating" navigation, without watching 201 | // what effects happen from it, in real apps this would be happening 202 | // all over the place 203 | // Some state might be missed if it is immediately updated during 204 | // or right after a transition 205 | 206 | navigation.navigate("/a", { state: "Loading!" }); 207 | 208 | await transition(navigation); 209 | await new Promise(resolve => setTimeout(resolve, 10)); 210 | 211 | navigation.updateCurrentEntry({ state: "Value!" }); 212 | 213 | await transition(navigation) 214 | await new Promise(resolve => setTimeout(resolve, 10)); 215 | 216 | navigation.navigate("/a/route", { state: "Some other value" }); 217 | 218 | await transition(navigation); 219 | await new Promise(resolve => setTimeout(resolve, 10)); 220 | 221 | navigation.updateCurrentEntry({ state: "Updated value!" }); 222 | 223 | await transition(navigation); 224 | await new Promise(resolve => setTimeout(resolve, 10)); 225 | 226 | controller.abort(); 227 | 228 | 229 | } 230 | -------------------------------------------------------------------------------- /src/tests/navigation.tsx: -------------------------------------------------------------------------------- 1 | /* c8 ignore start */ 2 | import { Navigation } from "../spec/navigation"; 3 | import * as Examples from "./examples"; 4 | import { getConfig } from "./config"; 5 | import { isWindowNavigation } from "./util"; 6 | import { NavigationNavigation } from "../navigation-navigation"; 7 | import {like} from "../is"; 8 | 9 | export interface NavigationAssertFn { 10 | (given: unknown): asserts given is () => Navigation; 11 | } 12 | 13 | declare global { 14 | interface History { 15 | pushState(data: unknown, unused: string, url?: string | URL): void; 16 | } 17 | interface Navigation {} 18 | interface Window { 19 | readonly history: History; 20 | readonly navigation: Navigation; 21 | } 22 | } 23 | 24 | export async function assertNavigation( 25 | createNavigation: () => unknown 26 | ): Promise { 27 | let caught: unknown; 28 | 29 | const tests = [ 30 | ...Object.values(Examples).filter( 31 | (value): value is typeof throwError => typeof value === "function" 32 | ), 33 | throwError, 34 | ] as const; 35 | 36 | const expectedError = new Error(); 37 | 38 | try { 39 | for (const test of tests) { 40 | const defaultNavigation = createNavigation(); 41 | // console.log("Starting initial run tests", createNavigation); 42 | await runTests(test, defaultNavigation); 43 | const wrappedNavigation = createNavigation(); 44 | // if (like(defaultNavigation) && like(wrappedNavigation)) { 45 | // 46 | // console.log( 47 | // "Starting wrapper tests", 48 | // createNavigation, 49 | // defaultNavigation.currentEntry?.url, 50 | // wrappedNavigation.currentEntry?.url, 51 | // wrappedNavigation === defaultNavigation, 52 | // defaultNavigation.currentEntry?.url === wrappedNavigation.currentEntry?.url 53 | // ); 54 | // } 55 | await runWrapperTests(test, wrappedNavigation); 56 | } 57 | } catch (error) { 58 | caught = error; 59 | } 60 | 61 | return (given) => { 62 | if (given !== createNavigation) 63 | throw new Error("Expected same instance to be provided to assertion"); 64 | if (caught) throw caught; 65 | }; 66 | 67 | async function runWrapperTests( 68 | test: (navigation: Navigation) => unknown, 69 | localNavigation: unknown 70 | ) { 71 | assertNavigationLike(localNavigation); 72 | if (!localNavigation) throw new Error("Expected navigation"); 73 | const target = new NavigationNavigation(localNavigation); 74 | const proxied = new Proxy(localNavigation, { 75 | get(unknown: Navigation, p): any { 76 | if (isTargetKey(p)) { 77 | const value = target[p]; 78 | if (typeof value === "function") { 79 | return value.bind(target); 80 | } 81 | return value; 82 | } 83 | return undefined; 84 | 85 | function isTargetKey(key: unknown): key is keyof typeof target { 86 | return ( 87 | (typeof key === "string" || typeof key === "symbol") && 88 | key in target 89 | ); 90 | } 91 | }, 92 | }); 93 | return runTests(test, proxied); 94 | } 95 | 96 | async function runTests( 97 | test: (navigation: Navigation) => unknown, 98 | localNavigation: unknown 99 | ) { 100 | assertNavigationLike(localNavigation); 101 | 102 | localNavigation.addEventListener("navigate", (event) => { 103 | if (isWindowNavigation(localNavigation)) { 104 | // Add a default navigation to disable network features 105 | event.intercept({ 106 | handler: () => Promise.resolve() 107 | }); 108 | } 109 | }); 110 | 111 | // // Add as very first currententrychange listener, to allow location change to happen 112 | // localNavigation.addEventListener("currententrychange", (event) => { 113 | // const { currentEntry } = localNavigation; 114 | // if (!currentEntry) return; 115 | // const state = currentEntry.getState<{ title?: string }>() ?? {}; 116 | // const { pathname } = new URL( 117 | // currentEntry.url ?? "/", 118 | // "https://example.com" 119 | // ); 120 | // try { 121 | // if ( 122 | // typeof window !== "undefined" && 123 | // typeof window.history !== "undefined" && 124 | // !isWindowNavigation(localNavigation) 125 | // ) { 126 | // window.history.pushState(state, state.title ?? "", pathname); 127 | // } 128 | // } catch (e) { 129 | // console.warn("Failed to push state", e); 130 | // } 131 | // console.log(`Updated window pathname to ${pathname}`); 132 | // }); 133 | 134 | try { 135 | console.log("START ", test.name); 136 | await test(localNavigation); 137 | const finished = localNavigation.transition?.finished; 138 | if (finished) { 139 | await finished.catch((error: unknown): void => void error); 140 | } 141 | 142 | // Let the events to finish logging 143 | if (typeof process !== "undefined" && process.nextTick) { 144 | await new Promise(process.nextTick); 145 | } else { 146 | await new Promise(queueMicrotask); 147 | } 148 | // await new Promise(resolve => setTimeout(resolve, 20)); 149 | 150 | console.log("PASS ", test.name); 151 | } catch (error) { 152 | if (error !== expectedError) { 153 | caught = caught || error; 154 | console.error("ERROR", test.name, error); 155 | if (!getConfig().FLAGS?.includes("CONTINUE_ON_ERROR")) { 156 | return; 157 | } 158 | } else { 159 | console.log("PASS ", test.name); 160 | } 161 | } 162 | } 163 | 164 | async function throwError(navigation: Navigation): Promise { 165 | throw expectedError; 166 | } 167 | } 168 | 169 | async function getPerformance(): Promise< 170 | Pick | undefined 171 | > { 172 | if (typeof performance !== "undefined") { 173 | return performance; 174 | } 175 | const { performance: nodePerformance } = await import("perf_hooks"); 176 | return nodePerformance; 177 | } 178 | 179 | function assertNavigationLike( 180 | navigation: unknown 181 | ): asserts navigation is Navigation { 182 | function isLike(navigation: unknown): navigation is Partial { 183 | return !!navigation; 184 | } 185 | const is = 186 | isLike(navigation) && 187 | typeof navigation.navigate === "function" && 188 | typeof navigation.back === "function" && 189 | typeof navigation.forward === "function"; 190 | if (!is) throw new Error("Expected Navigation instance"); 191 | } 192 | --------------------------------------------------------------------------------