tuil
ReferencePackages@mwillbanks/tuil-routerAPI

TerminalRouter

class exported by @mwillbanks/tuil-router.

View rawEdit

class

Public class exported by @mwillbanks/tuil-router.

export class TerminalRouter<
  TRoutes extends Readonly<Record<string, object>> = Readonly<
    Record<string, object>
  >,
> {
  readonly #routes: Map<string, readonly InternalRouteMatch[]>;
  readonly #store = createNusmStore<RouterState>({
    history: Object.freeze([]),
    index: -1,
  });
  readonly #observers = new Set<(event: RouterEvent) => void>();
  #navigation?: AbortController;

  constructor(
    routes: TRoutes,
    readonly options: TerminalRouterOptions = {},
  ) {
    this.#routes = flattenRoutes(
      routes as unknown as Readonly<Record<string, InternalRouteDefinition>>,
    );
  }

  get state(): RouterState {
    return this.#store.state;
  }

  get routes(): readonly string[] {
    return Object.freeze([...this.#routes.keys()]);
  }

  subscribe(observer: () => void): () => void {
    const subscription = this.#store.subscribe(observer);
    return () => subscription.unsubscribe();
  }

  observe(observer: (event: RouterEvent) => void): () => void {
    this.#observers.add(observer);
    return () => this.#observers.delete(observer);
  }

  navigate<TPath extends RoutePaths<TRoutes>>(
    target: NavigationTarget<TPath, RouteParamsAtPath<TRoutes, TPath>>,
  ): Promise<NavigationEntry> {
    return this.#navigate(target as NavigationTarget);
  }

  async #navigate(target: NavigationTarget): Promise<NavigationEntry> {
    const routeChain = this.#routes.get(target.to);
    if (!routeChain) throw new Error(`Unknown route "${target.to}"`);
    this.#navigation?.abort(new DOMException("Superseded", "AbortError"));
    const navigation = new AbortController();
    this.#navigation = navigation;
    const signal = target.signal
      ? AbortSignal.any([navigation.signal, target.signal])
      : navigation.signal;
    const from = this.state.location;
    this.#setState({ ...this.state, pending: target, error: undefined });
    this.#emit({ type: "route:before-navigate", from, to: target });
    const contexts: RouteContext[] = [];
    let activeMatchIndex = 0;
    try {
      for (const [index, match] of routeChain.entries()) {
        activeMatchIndex = index;
        const rawParams = match.definition.parseParams
          ? match.definition.parseParams(target.params)
          : target.params;
        contexts.push({
          params: normalizeParams(rawParams),
          signal,
          router: this as unknown as TerminalRouter,
        });
      }
      for (const [index, match] of routeChain.entries()) {
        activeMatchIndex = index;
        const guard = await match.definition.beforeEnter?.(
          contexts[index] as RouteContext,
        );
        signal.throwIfAborted();
        if (typeof guard === "string") {
          return this.#navigate({
            to: guard,
            surface: target.surface,
            signal: target.signal,
          });
        }
        if (guard === false) {
          throw new Error(`Navigation to "${target.to}" denied`);
        }
      }
      await this.#assertCanLeave(from, signal);
      const params =
        contexts.at(-1)?.params ?? Object.freeze<Record<string, unknown>>({});
      const routeMatches: RouteMatch[] = routeChain.map((match, index) =>
        Object.freeze({
          route: match.route,
          params: (contexts[index] as RouteContext).params,
          component: match.definition.component,
        }),
      );
      const entryBase: NavigationEntry = Object.freeze({
        id: crypto.randomUUID(),
        route: target.to,
        params,
        surface: target.surface ?? "screen",
        matches: Object.freeze([...routeMatches]),
        createdAt: Date.now(),
      });
      this.#emit({ type: "route:navigate", from, to: entryBase });
      for (const [index, match] of routeChain.entries()) {
        if (!match.definition.loader) continue;
        activeMatchIndex = index;
        this.#emit({ type: "route:load", from, to: entryBase });
        const data = await match.definition.loader(
          contexts[index] as RouteContext,
        );
        signal.throwIfAborted();
        routeMatches[index] = Object.freeze({
          ...(routeMatches[index] as RouteMatch),
          data,
        });
      }
      const entry = Object.freeze({
        ...entryBase,
        data: routeMatches.at(-1)?.data,
        matches: Object.freeze(routeMatches),
      });
      signal.throwIfAborted();
      const historyWithFocus = this.#captureCurrentFocus();
      const retained = target.replace
        ? historyWithFocus.slice(0, Math.max(0, this.state.index))
        : historyWithFocus.slice(0, this.state.index + 1);
      const history = Object.freeze(
        target.replace && this.state.index >= 0
          ? [...retained, entry]
          : [...retained, entry],
      );
      if (from) this.#emit({ type: "route:leave", from, to: entry });
      this.#setState({
        location: entry,
        history,
        index: history.length - 1,
      });
      this.#emit({ type: "route:ready", from, to: entry });
      return entry;
    } catch (error) {
      if (!navigation.signal.aborted) {
        let boundaryIndex = activeMatchIndex;
        while (
          boundaryIndex >= 0 &&
          !routeChain[boundaryIndex]?.definition.onError
        ) {
          boundaryIndex -= 1;
        }
        const boundary = routeChain[boundaryIndex];
        let reportedError = error;
        try {
          boundary?.definition.onError?.(
            error,
            contexts[boundaryIndex] ??
              ({
                params: Object.freeze({}),
                signal,
                router: this as unknown as TerminalRouter,
              } as RouteContext),
          );
        } catch (boundaryError) {
          reportedError = new AggregateError(
            [error, boundaryError],
            `Route error boundary for "${target.to}" failed`,
          );
        }
        this.#setState({
          ...this.state,
          pending: undefined,
          error: reportedError,
        });
        this.#emit({
          type: "route:error",
          from,
          to: target,
          error: reportedError,
        });
        throw reportedError;
      }
      throw error;
    }
  }

  open<TPath extends RoutePaths<TRoutes>>(
    target: {
      readonly route: TPath;
      readonly surface: Exclude<NavigationSurface, "screen">;
      readonly signal?: AbortSignal;
    } & (unknown extends RouteParamsAtPath<TRoutes, TPath>
      ? { readonly params?: unknown }
      : keyof RouteParamsAtPath<TRoutes, TPath> extends never
        ? { readonly params?: RouteParamsAtPath<TRoutes, TPath> }
        : { readonly params: RouteParamsAtPath<TRoutes, TPath> }),
  ): Promise<NavigationEntry> {
    const navigationTarget: NavigationTarget = {
      to: target.route,
      params: target.params,
      surface: target.surface,
      signal: target.signal,
    };
    return this.#navigate(navigationTarget);
  }

  async back(signal?: AbortSignal): Promise<NavigationEntry | undefined> {
    return this.#restore(this.state.index - 1, signal);
  }

  async forward(signal?: AbortSignal): Promise<NavigationEntry | undefined> {
    return this.#restore(this.state.index + 1, signal);
  }

  cancel(reason: unknown = new DOMException("Cancelled", "AbortError")): void {
    this.#navigation?.abort(reason);
    this.#setState({ ...this.state, pending: undefined });
  }

  dispose(): void {
    this.cancel(new DOMException("Disposed", "AbortError"));
    this.#observers.clear();
  }

  async #restore(
    index: number,
    externalSignal?: AbortSignal,
  ): Promise<NavigationEntry | undefined> {
    const entry = this.state.history[index];
    if (!entry) {
      this.cancel(new DOMException("Superseded", "AbortError"));
      return undefined;
    }
    this.#navigation?.abort(new DOMException("Superseded", "AbortError"));
    const navigation = new AbortController();
    this.#navigation = navigation;
    const signal = externalSignal
      ? AbortSignal.any([navigation.signal, externalSignal])
      : navigation.signal;
    const from = this.state.location;
    this.#emit({ type: "route:before-navigate", from, to: entry });
    try {
      await this.#assertCanLeave(from, signal);
      const routeChain = this.#routes.get(entry.route) ?? [];
      for (const match of routeChain) {
        const context: RouteContext = {
          params:
            entry.matches.find((entryMatch) => entryMatch.route === match.route)
              ?.params ?? entry.params,
          signal,
          router: this as unknown as TerminalRouter,
        };
        const guard = await match.definition.beforeEnter?.(context);
        signal.throwIfAborted();
        if (guard === false || typeof guard === "string") {
          throw new Error(`History navigation to "${entry.route}" denied`);
        }
      }
      const history = this.#captureCurrentFocus();
      const restoredEntry = history[index] ?? entry;
      const previousState = this.state;
      this.#setState({
        ...this.state,
        history: Object.freeze(history),
        location: restoredEntry,
        index,
        pending: undefined,
        error: undefined,
      });
      try {
        if (restoredEntry.focus && this.options.restoreFocus) {
          await this.options.restoreFocus(restoredEntry.focus, signal);
          signal.throwIfAborted();
          this.#emit({
            type: "route:restore-focus",
            from,
            to: restoredEntry,
          });
        }
        signal.throwIfAborted();
      } catch (error) {
        this.#setState({
          ...previousState,
          history: Object.freeze(history),
          pending: undefined,
        });
        throw error;
      }
      if (from) {
        this.#emit({ type: "route:leave", from, to: restoredEntry });
      }
      this.#emit({ type: "route:ready", from, to: restoredEntry });
      return restoredEntry;
    } catch (error) {
      if (!signal.aborted) {
        this.#setState({ ...this.state, pending: undefined, error });
        this.#emit({ type: "route:error", from, to: entry, error });
      }
      throw error;
    }
  }

  async #assertCanLeave(
    entry: NavigationEntry | undefined,
    signal: AbortSignal,
  ): Promise<void> {
    if (!entry) return;
    const routeChain = this.#routes.get(entry.route) ?? [];
    for (const match of [...routeChain].reverse()) {
      const allowed = await match.definition.beforeLeave?.({
        params:
          entry.matches.find((entryMatch) => entryMatch.route === match.route)
            ?.params ?? entry.params,
        signal,
        router: this as unknown as TerminalRouter,
      });
      signal.throwIfAborted();
      if (allowed === false) {
        throw new Error(`Navigation away from "${entry.route}" denied`);
      }
    }
  }

  #captureCurrentFocus(): readonly NavigationEntry[] {
    const captured = this.state.location
      ? this.options.captureFocus?.()
      : undefined;
    if (!captured || this.state.index < 0) return this.state.history;
    return this.state.history.map((entry, index) =>
      index === this.state.index
        ? Object.freeze({ ...entry, focus: captured })
        : entry,
    );
  }

  #setState(state: RouterState): void {
    this.#store.setState(() => Object.freeze(state));
  }

  #emit(event: Omit<RouterEvent, "at">): void {
    const complete = Object.freeze({ ...event, at: Date.now() });
    for (const observer of this.#observers) {
      try {
        observer(complete);
      } catch (error) {
        try {
          this.options.onObserverError?.(error);
        } catch {
          // Observer error reporting must not corrupt router state.
        }
      }
    }
  }
}

Members

MemberTypeRequiredDescriptionRelated types
#routesMap<string, readonly InternalRouteMatch[]>YesThe #routes member uses the Map<string, readonly InternalRouteMatch[]> contract.
#storeimport("nusm").NusmStore<RouterState>YesThe #store member uses the import("nusm").NusmStore<RouterState> contract.RouterState
#observersSet<(event: RouterEvent) => void>YesThe #observers member uses the Set<(event: RouterEvent) => void> contract.RouterEvent
#navigationAbortController | undefinedNoThe #navigation member uses the AbortController | undefined contract.
__constructoranyYesThe __constructor member uses the any contract.
stateRouterStateYesThe state member uses the RouterState contract.RouterState
routesreadonly string[]YesThe routes member uses the readonly string[] contract.
subscribe(observer: () => void) => () => voidYesThe subscribe member uses the (observer: () => void) => () => void contract.
observe(observer: (event: RouterEvent) => void) => () => voidYesThe observe member uses the (observer: (event: RouterEvent) => void) => () => void contract.RouterEvent
navigate<TPath extends RoutePaths<TRoutes>>(target: NavigationTarget<TPath, RouteParamsAtPath<TRoutes, TPath>>) => Promise<NavigationEntry>YesThe navigate member uses the <TPath extends RoutePaths<TRoutes>>(target: NavigationTarget<TPath, RouteParamsAtPath<TRoutes, TPath>>) => Promise<NavigationEntry> contract.NavigationEntry, NavigationTarget
#navigate(target: NavigationTarget) => Promise<NavigationEntry>YesThe #navigate member uses the (target: NavigationTarget) => Promise<NavigationEntry> contract.NavigationEntry, NavigationTarget
open<TPath extends RoutePaths<TRoutes>>(target: &#123; readonly route: TPath; readonly surface: Exclude<NavigationSurface, "screen">; readonly signal?: AbortSignal; &#125; & (unknown extends RouteParamsAtPath<TRoutes, TPath> ? &#123; readonly params?: unknown; &#125; : keyof RouteParamsAtPath<TRoutes, TPath> extends never ? &#123; readonly params?: RouteParamsAtPath<TRoutes, TPath>; &#125; : &#123; readonly params: RouteParamsAtPath<TRoutes, TPath>; &#125;)) => Promise<NavigationEntry>YesThe open member uses the <TPath extends RoutePaths<TRoutes>>(target: &#123; readonly route: TPath; readonly surface: Exclude<NavigationSurface, "screen">; readonly signal?: AbortSignal; &#125; & (unknown extends RouteParamsAtPath<TRoutes, TPath> ? &#123; readonly params?: unknown; &#125; : keyof RouteParamsAtPath<TRoutes, TPath> extends never ? &#123; readonly params?: RouteParamsAtPath<TRoutes, TPath>; &#125; : &#123; readonly params: RouteParamsAtPath<TRoutes, TPath>; &#125;)) => Promise<NavigationEntry> contract.NavigationEntry, NavigationSurface
back(signal?: AbortSignal) => Promise<NavigationEntry | undefined>YesThe back member uses the (signal?: AbortSignal) => Promise<NavigationEntry | undefined> contract.NavigationEntry
forward(signal?: AbortSignal) => Promise<NavigationEntry | undefined>YesThe forward member uses the (signal?: AbortSignal) => Promise<NavigationEntry | undefined> contract.NavigationEntry
cancel(reason?: unknown) => voidYesThe cancel member uses the (reason?: unknown) => void contract.
dispose() => voidYesThe dispose member uses the () => void contract.
#restore(index: number, externalSignal?: AbortSignal) => Promise<NavigationEntry | undefined>YesThe #restore member uses the (index: number, externalSignal?: AbortSignal) => Promise<NavigationEntry | undefined> contract.NavigationEntry
#assertCanLeave(entry: NavigationEntry | undefined, signal: AbortSignal) => Promise<void>YesThe #assertCanLeave member uses the (entry: NavigationEntry | undefined, signal: AbortSignal) => Promise<void> contract.NavigationEntry
#captureCurrentFocus() => readonly NavigationEntry[]YesThe #captureCurrentFocus member uses the () => readonly NavigationEntry[] contract.NavigationEntry
#setState(state: RouterState) => voidYesThe #setState member uses the (state: RouterState) => void contract.RouterState
#emit(event: Omit<RouterEvent, "at">) => voidYesThe #emit member uses the (event: Omit<RouterEvent, "at">) => void contract.RouterEvent

Parameters

This declaration has no public members.

Returns

This declaration does not return a value.

Throws

No thrown errors are documented for this declaration.

Source

View the secondary source reference

Package

@mwillbanks/tuil-router

On this page