import { isEqual } from "es-toolkit"; import type { Observable } from "rxjs"; import { type AnyEventObject, assertEvent, assign, enqueueActions, fromCallback, fromObservable, raise, setup, } from "xstate"; import { ApplicationList } from "../../core/applications/application-list"; import { logger } from "../../core/log"; import { os } from "../../runtime/bus"; import type { NavigationLocation, PayloadOf, ReplyOf, } from "../../runtime/topics"; import { normalizeHref, resolveNavigation, targetOf } from "./location"; type NavigationReply = ReplyOf<"navigation.location.update">; type NavigationRequest = { payload: PayloadOf<"navigation.location.update">; reply: (reply: NavigationReply) => void; /** Aborts once the caller stops waiting: don't navigate or reply past it. */ signal: AbortSignal; }; /** * How the machine reaches the router, which owns the history stack: ask it to * navigate, and read back where it is. `history` mirrors the Navigation API's * option of the same name. * @public */ export type NavigationAdapter = { navigate: ( href: string, options: { history: "push" | "replace" }, ) => Promise; location: Observable; }; type Transition = { request: NavigationRequest; href: string; navigationType: "push" | "replace"; }; type NavigationInput = NavigationAdapter & { /** * The organization's applications, or `null` once the list has failed. Read * from the sibling `applications` actor, so nothing has to mirror it in. */ applications: Observable; }; type NavigationContext = { navigate: NavigationAdapter["navigate"]; commits$: NavigationAdapter["location"]; applications$: NavigationInput["applications"]; applications: ApplicationList | null; href: string | null; /** The last value published, so an unchanged location stays silent. */ published: NavigationLocation | null; transition: Transition | null; request: NavigationRequest | null; }; type NavigationEvent = | { type: "applications.changed"; applications: ApplicationList | null } | { type: "navigation.committed"; href: string } | { type: "navigation.request.committed" } | ({ type: "navigation.requested" } & NavigationRequest) | { type: "navigation.failed"; error: unknown } | { type: "navigation.superseded" } | { type: "navigation.aborted" }; const requestOf = (event: NavigationEvent): NavigationRequest => { assertEvent(event, "navigation.requested"); return event; }; // Resolves the caller's promise, so a location published later in the same // action list still reaches them first. const reply = ( request: NavigationRequest | null, value: NavigationReply, ): void => { if (request && !request.signal.aborted) request.reply(value); }; const navigationRequests = fromCallback(({ sendBack }) => { const controller = new AbortController(); os.subscribe( "navigation.location.update", (message) => sendBack({ type: "navigation.requested", payload: message.payload, reply: message.reply, signal: message.signal, }), { signal: controller.signal }, ); return () => controller.abort(); }); const applicationsFeed = fromObservable< ApplicationList | null, NavigationInput["applications"] >(({ input }) => input); const commitFeed = fromObservable( ({ input }) => input, ); // Lives exactly as long as the navigation: stopping it drops the late refusal of // a superseded run, and a caller giving up mid-flight ends the transition. const navigationRun = fromCallback< AnyEventObject, { navigate: NavigationAdapter["navigate"]; transition: Transition | null } >(({ input: { navigate, transition }, sendBack }) => { const abort = () => sendBack({ type: "navigation.aborted" }); if (!transition || transition.request.signal.aborted) { abort(); return; } const { href, navigationType, request } = transition; request.signal.addEventListener("abort", abort); // The wrapper turns a synchronous throw (history.pushState's SecurityError) // into a rejection, so it can't escape and error the actor. void (async () => navigate(href, { history: navigationType }))() .then(() => sendBack({ type: "navigation.request.committed" })) .catch((error: unknown) => sendBack( error instanceof Error && error.name === "AbortError" ? { type: "navigation.superseded" } : { type: "navigation.failed", error }, ), ); return () => request.signal.removeEventListener("abort", abort); }); /** * Owns `navigation.location` and answers `navigation.location.update`. * * A commit arrives as an event because the router owns the location and only the * host can report one; `navigate` comes in as input for the same reason. The * application list is read straight off the sibling `applications` actor. */ export const navigationLogic = setup({ types: { input: {} as NavigationInput, context: {} as NavigationContext, events: {} as NavigationEvent, }, actors: { navigationRequests, applicationsFeed, commitFeed, navigationRun }, // Releases a transition the router never commits — a request nobody awaits // arms no bus timeout, so no caller signal would. Longer than that timeout on // purpose: a slow navigation must not be answered `failed` while it may land. delays: { commitDeadline: 10_000 }, guards: { listPending: ({ context, event }) => { const request = requestOf(event); return ( !request.signal.aborted && resolveNavigation(context.applications, request.payload).status === "pending" ); }, // A refusal, or a request for where the workbench already is, has no commit // to reply on. needsNavigation: ({ context, event }) => { const request = requestOf(event); if (request.signal.aborted) return false; const resolved = resolveNavigation(context.applications, request.payload); return resolved.status === "navigable" && resolved.href !== context.href; }, }, actions: { storeHref: assign(({ event }) => { assertEvent(event, "navigation.committed"); return { href: normalizeHref(event.href) }; }), storeApplications: assign(({ context, event }) => { assertEvent(event, "applications.changed"); // `null` is a failed load, which must not suspend the topic forever: an // empty list still yields a workbench-level location. return { applications: event.applications ?? context.applications ?? new ApplicationList([]), }; }), publish: enqueueActions(({ context, enqueue }) => { const { applications, href, transition } = context; if (!applications || href === null) return; const location: NavigationLocation = { ...targetOf(applications, href), transition: transition && { navigationType: transition.navigationType, to: targetOf(applications, transition.href), }, }; if (isEqual(location, context.published)) return; enqueue.assign({ published: location }); enqueue(() => os.emit("navigation.location", location)); }), // Newest wins, as it would have had the list been there all along. holdRequest: enqueueActions(({ context, enqueue, event }) => { reply(context.request, { ok: false, reason: "interrupted" }); enqueue.assign({ request: requestOf(event) }); }), // Replayed through the same handler, so holding changes only the timing. releaseRequest: enqueueActions(({ context, enqueue }) => { if (!context.request) return; enqueue.raise({ type: "navigation.requested", ...context.request }); enqueue.assign({ request: null }); }), beginTransition: assign(({ context, event }) => { const request = requestOf(event); const resolved = resolveNavigation(context.applications, request.payload); if (resolved.status !== "navigable") return {}; const transition: Transition = { request, href: resolved.href, navigationType: request.payload.history ?? "push", }; return { transition }; }), endTransition: assign({ transition: null }), replyWithoutNavigating: ({ context, event }) => { const request = requestOf(event); const resolved = resolveNavigation(context.applications, request.payload); reply( request, resolved.status === "navigable" ? { ok: true } : { ok: false, reason: "not-navigable" }, ); }, replyInterrupted: ({ context }) => reply(context.transition?.request ?? null, { ok: false, reason: "interrupted", }), replyFailed: ({ context }) => reply(context.transition?.request ?? null, { ok: false, reason: "failed", }), replyCommitted: ({ context }) => reply(context.transition?.request ?? null, { ok: true }), reportFailure: ({ context, event }) => { assertEvent(event, "navigation.failed"); logger.error("Navigation failed", { href: context.transition?.href, error: event.error instanceof Error ? event.error : new Error(String(event.error)), }); }, }, }).createMachine({ id: "navigation", invoke: [ { src: "navigationRequests" }, { src: "commitFeed", input: ({ context }) => context.commits$, onSnapshot: { guard: ({ event }) => event.snapshot.context !== undefined, actions: raise(({ event }) => ({ type: "navigation.committed" as const, href: event.snapshot.context!, })), }, }, { src: "applicationsFeed", input: ({ context }) => context.applications$, // Raised as an event so the feed and a test that stands in for it share one // path for "the list changed". onSnapshot: { guard: ({ event }) => event.snapshot.context !== undefined, actions: raise(({ event }) => ({ type: "applications.changed" as const, applications: event.snapshot.context ?? null, })), }, }, ], context: ({ input }) => ({ navigate: input.navigate, commits$: input.location, applications$: input.applications, applications: null, href: null, published: null, transition: null, request: null, }), initial: "idle", on: { "navigation.committed": { actions: ["storeHref", "publish"] }, "applications.changed": { actions: ["storeApplications", "publish", "releaseRequest"], }, "navigation.requested": [ { guard: "listPending", actions: ["holdRequest"] }, { guard: "needsNavigation", // Re-entry restarts the run, so a newer request supersedes the one in flight. target: ".navigating", reenter: true, actions: ["replyInterrupted", "beginTransition", "publish"], }, { actions: ["replyWithoutNavigating"] }, ], }, states: { idle: {}, navigating: { invoke: { src: "navigationRun", input: ({ context }) => ({ navigate: context.navigate, transition: context.transition, }), }, after: { commitDeadline: { target: "idle", actions: ["replyFailed", "endTransition", "publish"], }, }, on: { "navigation.request.committed": { target: "idle", actions: ["replyCommitted", "endTransition", "publish"], }, "navigation.superseded": { target: "idle", actions: ["replyInterrupted", "endTransition", "publish"], }, "navigation.failed": { target: "idle", actions: ["replyFailed", "reportFailure", "endTransition", "publish"], }, "navigation.aborted": { target: "idle", actions: ["endTransition", "publish"], }, }, }, }, });