From d35f3f67b5fa5127cdbda8c3f3a9cf50d8c39354 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Thu, 20 Aug 2026 02:33:22 +0100 Subject: [PATCH 1/3] =?UTF-8?q?feat(jarl-atoms):=20778=20=E2=80=94=20veto?= =?UTF-8?q?=20any=20navigation=20with=20navigationGuardAtom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every way the URL can move now passes through a guard: in-app route atom writes are vetoed at locationAtom's write, and same-document navigations made outside jarl are vetoed through the Navigation API's navigate event, which also closes the pre-existing gap where a third-party history.pushState was invisible to jarl. Leaving the document is handled by beforeunload. Ticket: 778 --- .../src/__tests__/navigationGuardAtom.test.ts | 237 ++++++++++++++++++ packages/jarl-atoms/src/index.ts | 3 + packages/jarl-atoms/src/locationAtom.ts | 28 ++- .../jarl-atoms/src/navigationGuardAtom.ts | 115 +++++++++ 4 files changed, 378 insertions(+), 5 deletions(-) create mode 100644 packages/jarl-atoms/src/__tests__/navigationGuardAtom.test.ts create mode 100644 packages/jarl-atoms/src/navigationGuardAtom.ts diff --git a/packages/jarl-atoms/src/__tests__/navigationGuardAtom.test.ts b/packages/jarl-atoms/src/__tests__/navigationGuardAtom.test.ts new file mode 100644 index 0000000..4904860 --- /dev/null +++ b/packages/jarl-atoms/src/__tests__/navigationGuardAtom.test.ts @@ -0,0 +1,237 @@ +import { atom, createStore } from "jotai/vanilla"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { locationAtom } from "../locationAtom"; +import { enforceNavigationGuards, navigationGuardAtom } from "../navigationGuardAtom"; +import { staticRouteAtom } from "../staticRouteAtom"; + +const aboutAtom = staticRouteAtom("about"); +const contactAtom = staticRouteAtom("contact"); + +const dirtyAtom = atom(false); +const unsavedGuard = navigationGuardAtom((get) => (get(dirtyAtom) ? "Unsaved edits. Leave anyway?" : null)); + +const confirm = vi.spyOn(window, "confirm"); + +const store = () => { + const created = createStore(); + created.set(aboutAtom, {}); + return created; +}; + +beforeEach(() => { + confirm.mockReset(); + confirm.mockReturnValue(true); +}); + +describe("guarding an in-app navigation", () => { + it("lets a route atom write through while no guard blocks", () => { + const current = store(); + const unsubscribe = enforceNavigationGuards(current, [unsavedGuard]); + + current.set(contactAtom, {}); + + expect(current.get(locationAtom).pathname).toBe("/contact"); + expect(confirm).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it("leaves the location untouched when the user declines the confirmation", () => { + const current = store(); + const unsubscribe = enforceNavigationGuards(current, [unsavedGuard]); + current.set(dirtyAtom, true); + confirm.mockReturnValue(false); + + current.set(contactAtom, {}); + + expect(confirm).toHaveBeenCalledWith("Unsaved edits. Leave anyway?"); + expect(current.get(locationAtom).pathname).toBe("/about"); + unsubscribe(); + }); + + it("navigates when the user accepts the confirmation", () => { + const current = store(); + const unsubscribe = enforceNavigationGuards(current, [unsavedGuard]); + current.set(dirtyAtom, true); + + current.set(contactAtom, {}); + + expect(current.get(locationAtom).pathname).toBe("/contact"); + unsubscribe(); + }); + + it("guards a direct location atom write, not just a route atom one", () => { + const current = store(); + const unsubscribe = enforceNavigationGuards(current, [unsavedGuard]); + current.set(dirtyAtom, true); + confirm.mockReturnValue(false); + + current.set(locationAtom, { pathname: "/contact", searchParams: new URLSearchParams() }); + + expect(current.get(locationAtom).pathname).toBe("/about"); + unsubscribe(); + }); + + it("stops guarding once unsubscribed", () => { + const current = store(); + const unsubscribe = enforceNavigationGuards(current, [unsavedGuard]); + current.set(dirtyAtom, true); + confirm.mockReturnValue(false); + + unsubscribe(); + current.set(contactAtom, {}); + + expect(confirm).not.toHaveBeenCalled(); + expect(current.get(locationAtom).pathname).toBe("/contact"); + }); + + it("guards each store separately", () => { + const guarded = store(); + const unguarded = store(); + const unsubscribe = enforceNavigationGuards(guarded, [unsavedGuard]); + guarded.set(dirtyAtom, true); + unguarded.set(dirtyAtom, true); + confirm.mockReturnValue(false); + + unguarded.set(contactAtom, {}); + + expect(confirm).not.toHaveBeenCalled(); + expect(unguarded.get(locationAtom).pathname).toBe("/contact"); + unsubscribe(); + }); +}); + +describe("composing guards", () => { + it("confirms with the first guard that blocks", () => { + const current = store(); + const first = navigationGuardAtom(() => null); + const second = navigationGuardAtom(() => "second"); + const third = navigationGuardAtom(() => "third"); + const unsubscribe = enforceNavigationGuards(current, [first, second, third]); + + current.set(contactAtom, {}); + + expect(confirm).toHaveBeenCalledExactlyOnceWith("second"); + unsubscribe(); + }); +}); + +// jsdom implements neither the Navigation API nor its wiring into history.pushState, so both are +// stood up by hand: a bare EventTarget as `window.navigation`, and a pushState that fires a +// `navigate` event at it and honours a cancellation, exactly as a browser does. +describe("guarding a navigation made outside jarl", () => { + let navigation: EventTarget; + let pushState: typeof history.pushState; + + const navigateEvent = ({ sameDocument = true, cancelable = true } = {}) => { + const event = new Event("navigate", { cancelable }); + Object.defineProperty(event, "destination", { value: { sameDocument } }); + return event; + }; + + beforeEach(() => { + navigation = new EventTarget(); + Object.defineProperty(window, "navigation", { value: navigation, configurable: true }); + pushState = history.pushState; + history.pushState = (...args: Parameters) => { + const event = navigateEvent(); + navigation.dispatchEvent(event); + if (!event.defaultPrevented) pushState.apply(history, args); + }; + }); + + afterEach(() => { + history.pushState = pushState; + Reflect.deleteProperty(window, "navigation"); + }); + + it("cancels a same-document navigation while a guard blocks", () => { + const current = store(); + const unsubscribe = enforceNavigationGuards(current, [unsavedGuard]); + current.set(dirtyAtom, true); + confirm.mockReturnValue(false); + + const event = navigateEvent(); + navigation.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + unsubscribe(); + }); + + it("lets a same-document navigation through once the user accepts", () => { + const current = store(); + const unsubscribe = enforceNavigationGuards(current, [unsavedGuard]); + current.set(dirtyAtom, true); + + const event = navigateEvent(); + navigation.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + unsubscribe(); + }); + + it("leaves a navigation out of the document to beforeunload", () => { + const current = store(); + const unsubscribe = enforceNavigationGuards(current, [unsavedGuard]); + current.set(dirtyAtom, true); + confirm.mockReturnValue(false); + + const event = navigateEvent({ sameDocument: false }); + navigation.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + expect(confirm).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it("does not touch a navigation the browser will not let it cancel", () => { + const current = store(); + const unsubscribe = enforceNavigationGuards(current, [unsavedGuard]); + current.set(dirtyAtom, true); + confirm.mockReturnValue(false); + + const event = navigateEvent({ cancelable: false }); + navigation.dispatchEvent(event); + + expect(confirm).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it("confirms once for an in-app navigation, not again for the navigate event it fires", () => { + const current = store(); + const unsubscribe = enforceNavigationGuards(current, [unsavedGuard]); + current.set(dirtyAtom, true); + + current.set(contactAtom, {}); + + expect(confirm).toHaveBeenCalledOnce(); + expect(current.get(locationAtom).pathname).toBe("/contact"); + unsubscribe(); + }); +}); + +describe("guarding a document unload", () => { + const dispatchBeforeUnload = () => { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event; + }; + + it("asks the browser to prompt while a guard blocks", () => { + const current = store(); + const unsubscribe = enforceNavigationGuards(current, [unsavedGuard]); + current.set(dirtyAtom, true); + + expect(dispatchBeforeUnload().defaultPrevented).toBe(true); + // The browser shows its own wording, so the guard's message is never confirmed here. + expect(confirm).not.toHaveBeenCalled(); + unsubscribe(); + }); + + it("stays out of the way while no guard blocks", () => { + const current = store(); + const unsubscribe = enforceNavigationGuards(current, [unsavedGuard]); + + expect(dispatchBeforeUnload().defaultPrevented).toBe(false); + unsubscribe(); + }); +}); diff --git a/packages/jarl-atoms/src/index.ts b/packages/jarl-atoms/src/index.ts index a0e4634..c0cfbff 100644 --- a/packages/jarl-atoms/src/index.ts +++ b/packages/jarl-atoms/src/index.ts @@ -17,3 +17,6 @@ export * from "./href"; export * from "./queryAtom"; export * from "./redirectAtom"; export * from "./asyncRouteAtom"; +// Named rather than `export *`: the rest of the module is the machinery `locationAtom` calls into. +export { enforceNavigationGuards, navigationGuardAtom } from "./navigationGuardAtom"; +export type { NavigationGuardAtom } from "./navigationGuardAtom"; diff --git a/packages/jarl-atoms/src/locationAtom.ts b/packages/jarl-atoms/src/locationAtom.ts index 66bd913..27e6fea 100644 --- a/packages/jarl-atoms/src/locationAtom.ts +++ b/packages/jarl-atoms/src/locationAtom.ts @@ -1,5 +1,6 @@ import { SetStateAction, WritableAtom, atom } from "jotai/vanilla"; import { atomWithLocation } from "jotai-location"; +import { allowsNavigation, withApprovedNavigation } from "./navigationGuardAtom"; // Declared rather than imported: jotai-location exports its structurally identical `Location` // only from jotai-location/dist/atomWithLocation, so locationAtom's inferred type can't be @@ -13,13 +14,26 @@ export type JarlLocation = { const isBrowser = typeof window !== "undefined"; +// jotai-location's own default listens to popstate alone, which stays silent for a +// `history.pushState` from outside jarl. The Navigation API reports every same-document +// navigation however it was made. +const subscribe = (callback: () => void) => { + const navigation = window.navigation as Navigation | undefined; + if (!navigation) { + window.addEventListener("popstate", callback); + return () => window.removeEventListener("popstate", callback); + } + navigation.addEventListener("currententrychange", callback); + return () => navigation.removeEventListener("currententrychange", callback); +}; + /** * jotai-location's history-bound location atom. Constructing and *reading* this * is safe under Node (it falls back to an empty location when there's no * `window`); only writing is not, since the write path calls * `history.pushState`/`replaceState` directly. */ -const historyLocationAtom = atomWithLocation(); +const historyLocationAtom = atomWithLocation({ subscribe }); /** * Server-side location override. Stays `null` in the browser, where @@ -31,9 +45,10 @@ const serverLocationAtom = atom(null); * The location every route atom reads from, and the seam where SSR/SSG is made * possible. * - * In a browser this is exactly `atomWithLocation()`: reads and writes go - * straight through to jotai-location, so navigation still drives real - * `history.pushState`/`replaceState` and responds to popstate. + * In a browser reads and writes go straight through to jotai-location, so navigation + * still drives real `history.pushState`/`replaceState`, and every same-document + * navigation - including one made from outside jarl - is picked up. A write is subject + * to any guard registered with `enforceNavigationGuards`, and does nothing if one blocks it. * * Under Node there is no `window` to push history onto, so writes are captured * in plain jotai state instead and reads prefer that captured value. That makes @@ -59,7 +74,10 @@ export const locationAtom: WritableAtom, options?: { replace?: boolean }) => { if (isBrowser) { - set(historyLocationAtom, update, options); + // The one write every in-app navigation funnels through, so guarding it here vetoes a + // route atom write before jotai-location commits it - no history entry, no rollback. + if (!allowsNavigation(get)) return; + withApprovedNavigation(() => set(historyLocationAtom, update, options)); return; } const current = get(serverLocationAtom) ?? get(historyLocationAtom); diff --git a/packages/jarl-atoms/src/navigationGuardAtom.ts b/packages/jarl-atoms/src/navigationGuardAtom.ts new file mode 100644 index 0000000..4330987 --- /dev/null +++ b/packages/jarl-atoms/src/navigationGuardAtom.ts @@ -0,0 +1,115 @@ +import { Atom, Getter, atom } from "jotai/vanilla"; +import type { Store } from "./redirectAtom"; + +/** A guard's verdict: the message to confirm a navigation with, or `null` to let it through. */ +export type NavigationGuardAtom = Atom; + +/** + * A guard that vetoes navigations while its condition holds - typically an unsaved-edits prompt: + * + * ```ts + * const unsavedGuard = navigationGuardAtom((get) => + * get(formDirtyAtom) ? "You have unsaved edits. Leave anyway?" : null, + * ); + * ``` + * + * Returning a string blocks the navigation behind a `window.confirm` carrying that message; + * returning `null` allows it. Reading one is pure - `enforceNavigationGuards` is the effect that + * makes it bite, and documents which navigations can and cannot be vetoed. + */ +export const navigationGuardAtom = (guard: (get: Getter) => string | null): NavigationGuardAtom => atom(guard); + +const enforcedGuardsAtom = atom>([]); + +// Wrapped in an object because a bare function set into a primitive atom is taken as an updater. +const guardListenerAtom = atom<{ remove: () => void } | null>(null); + +const blockingMessage = (get: Getter): string | null => { + for (const guard of get(enforcedGuardsAtom)) { + const message = get(guard); + if (message !== null) return message; + } + return null; +}; + +/** Whether the enforced guards allow a navigation now, confirming with the user if one blocks. */ +export const allowsNavigation = (get: Getter): boolean => { + const message = blockingMessage(get); + return message === null || window.confirm(message); +}; + +// Module-level rather than per-store: window.navigation and history are one global per page, so +// this only needs to track "was the in-flight pushState ours", not which store made it. +let approvingOwnNavigation = false; + +/** + * Runs a navigation whose guards have already been consulted. The `navigate` event its + * `history.pushState` fires would otherwise consult them a second time and confirm twice. + */ +export const withApprovedNavigation = (navigate: () => void): void => { + approvingOwnNavigation = true; + try { + navigate(); + } finally { + approvingOwnNavigation = false; + } +}; + +const listen = (store: Store): { remove: () => void } => { + if (typeof window === "undefined") return { remove: () => {} }; + + const onNavigate = (event: NavigateEvent) => { + // Anything leaving the document is beforeunload's job below: asking here as well would + // prompt twice for one navigation, and the browser's own dialog is unavoidable anyway. + if (approvingOwnNavigation || !event.cancelable || !event.destination.sameDocument) return; + if (!allowsNavigation(store.get)) event.preventDefault(); + }; + const onBeforeUnload = (event: BeforeUnloadEvent) => { + if (blockingMessage(store.get) !== null) event.preventDefault(); + }; + + const navigation = window.navigation as Navigation | undefined; + navigation?.addEventListener("navigate", onNavigate); + window.addEventListener("beforeunload", onBeforeUnload); + return { + remove: () => { + navigation?.removeEventListener("navigate", onNavigate); + window.removeEventListener("beforeunload", onBeforeUnload); + }, + }; +}; + +/** + * Makes navigation guard atoms actually block, for the given store. Call once near the root of an + * app - or per component via `jarl-react`'s `useNavigationGuard` - for every guard you want live. + * Guards compose: the first that returns a message wins. Returns an unsubscribe function. + * + * Nothing commits before a guard has had its say, so a blocked navigation leaves no history + * entry and the URL never flickers. What each navigation source gets: + * + * - In-app navigation - `Link`, `useNavigate`, a route atom or `locationAtom` written directly - + * is vetoed at `locationAtom`'s write, and needs no browser support. + * - A same-document navigation from outside jarl - third-party `history.pushState`, a fragment + * change, same-document back/forward - is vetoed through the + * [Navigation API](https://developer.mozilla.org/docs/Web/API/Navigation_API), and goes + * unguarded in a browser that lacks it. + * - Leaving the document - reload, a cross-document link, closing the tab - gets `beforeunload`, + * so the browser shows its own wording instead of the guard's message. + * + * Two things the platform refuses to let anyone veto, as anti-trapping measures: a cross-document + * back/forward, and a back/forward repeated without interacting with the page in between, since + * cancelling one consumes the activation that permits cancelling. A navigation the browser starts + * for itself - the URL bar, a bookmark, the reload button - reaches `beforeunload` and nothing else. + */ +export const enforceNavigationGuards = (store: Store, guards: ReadonlyArray): (() => void) => { + store.set(enforcedGuardsAtom, (enforced) => [...enforced, ...guards]); + if (!store.get(guardListenerAtom)) { + store.set(guardListenerAtom, listen(store)); + } + return () => { + store.set(enforcedGuardsAtom, (enforced) => enforced.filter((guard) => !guards.includes(guard))); + if (store.get(enforcedGuardsAtom).length > 0) return; + store.get(guardListenerAtom)?.remove(); + store.set(guardListenerAtom, null); + }; +}; From 3ff197834959c6dbb7bee24a9f750286f6de01a3 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Thu, 20 Aug 2026 02:33:27 +0100 Subject: [PATCH 2/3] =?UTF-8?q?feat(jarl-react):=20778=20=E2=80=94=20useNa?= =?UTF-8?q?vigationGuard=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforces a guard atom for as long as the calling component is mounted, so the state a guard reads and the guard itself can live together. Ticket: 778 --- .../src/__tests__/useNavigationGuard.test.tsx | 55 +++++++++++++++++++ packages/jarl-react/src/index.ts | 1 + packages/jarl-react/src/useNavigationGuard.ts | 16 ++++++ 3 files changed, 72 insertions(+) create mode 100644 packages/jarl-react/src/__tests__/useNavigationGuard.test.tsx create mode 100644 packages/jarl-react/src/useNavigationGuard.ts diff --git a/packages/jarl-react/src/__tests__/useNavigationGuard.test.tsx b/packages/jarl-react/src/__tests__/useNavigationGuard.test.tsx new file mode 100644 index 0000000..d100ef7 --- /dev/null +++ b/packages/jarl-react/src/__tests__/useNavigationGuard.test.tsx @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { atom, getDefaultStore } from "jotai"; +import { locationAtom, navigationGuardAtom } from "jarl-atoms"; +import { useNavigate } from "../hooks"; +import { useNavigationGuard } from "../useNavigationGuard"; +import { aboutAtom } from "./fixtures"; + +const unsavedAtom = atom(false); +const unsavedGuard = navigationGuardAtom((get) => (get(unsavedAtom) ? "Unsaved edits" : null)); + +const confirm = vi.spyOn(window, "confirm"); + +const Guard = () => { + useNavigationGuard(unsavedGuard); + return null; +}; + +const Editor = ({ guarded }: { guarded: boolean }) => { + const navigate = useNavigate(aboutAtom); + return ( +
+ {guarded && } + +
+ ); +}; + +beforeEach(() => { + window.history.pushState(null, "", "/"); + confirm.mockReset(); + confirm.mockReturnValue(false); + getDefaultStore().set(unsavedAtom, true); +}); + +describe("useNavigationGuard", () => { + it("blocks a navigation while the guarding component is mounted", () => { + render(); + + fireEvent.click(screen.getByText("Go")); + + expect(confirm).toHaveBeenCalledWith("Unsaved edits"); + expect(getDefaultStore().get(locationAtom).pathname).toBe("/"); + }); + + it("stops blocking once the guarding component unmounts", () => { + const { rerender } = render(); + + rerender(); + fireEvent.click(screen.getByText("Go")); + + expect(confirm).not.toHaveBeenCalled(); + expect(getDefaultStore().get(locationAtom).pathname).toBe("/about"); + }); +}); diff --git a/packages/jarl-react/src/index.ts b/packages/jarl-react/src/index.ts index cdb654b..324709c 100644 --- a/packages/jarl-react/src/index.ts +++ b/packages/jarl-react/src/index.ts @@ -9,3 +9,4 @@ export * from "./hooks"; export * from "./Link"; export * from "./Route"; export * from "./Switch"; +export * from "./useNavigationGuard"; diff --git a/packages/jarl-react/src/useNavigationGuard.ts b/packages/jarl-react/src/useNavigationGuard.ts new file mode 100644 index 0000000..4187763 --- /dev/null +++ b/packages/jarl-react/src/useNavigationGuard.ts @@ -0,0 +1,16 @@ +import { useEffect } from "react"; +import { useStore } from "jotai"; +import { NavigationGuardAtom, enforceNavigationGuards } from "jarl-atoms"; + +/** + * Enforces a navigation guard atom for as long as the calling component is mounted - the React + * binding for `enforceNavigationGuards`, which documents what a guard can and cannot veto. Call + * it once per guard, wherever the state that guard reads is owned. + * + * The guard atom must be stable across renders - defined at module scope, or memoised - since a + * new one on every render re-registers on every render. + */ +export function useNavigationGuard(guard: NavigationGuardAtom): void { + const store = useStore(); + useEffect(() => enforceNavigationGuards(store, [guard]), [store, guard]); +} From d9ba7604c5c99f23370db5e7566fd6e862d31cb6 Mon Sep 17 00:00:00 2001 From: Peter Hurst Date: Thu, 20 Aug 2026 02:33:27 +0100 Subject: [PATCH 3/3] =?UTF-8?q?test(e2e):=20778=20=E2=80=94=20cover=20ever?= =?UTF-8?q?y=20navigation=20source=20a=20guard=20has=20to=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A link click, a useNavigate call, a third-party history.pushState and the browser's back/forward buttons, each with the guard both allowing and blocking. Ticket: 778 --- e2e/fixture-app/src/App.tsx | 2 + .../src/pages/NavigationGuards.tsx | 48 +++++++ e2e/fixture-app/src/routes.ts | 22 +++- e2e/tests/05-navigation-guards.spec.ts | 117 ++++++++++++++++++ 4 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 e2e/fixture-app/src/pages/NavigationGuards.tsx create mode 100644 e2e/tests/05-navigation-guards.spec.ts diff --git a/e2e/fixture-app/src/App.tsx b/e2e/fixture-app/src/App.tsx index 8fd9cba..cca5a65 100644 --- a/e2e/fixture-app/src/App.tsx +++ b/e2e/fixture-app/src/App.tsx @@ -6,6 +6,7 @@ import BasicRouting from "./pages/BasicRouting"; import AdvancedRouting from "./pages/AdvancedRouting"; import QueryStrings from "./pages/QueryStrings"; import Redirects from "./pages/Redirects"; +import NavigationGuards from "./pages/NavigationGuards"; // Top-level segment -> demo. The v2 route atoms don't have a "first match // wins" switch/exclusivity primitive yet, so this dispatch is done in plain @@ -17,6 +18,7 @@ const DEMOS: Record = { advancedRouting: AdvancedRouting, queryStrings: QueryStrings, redirects: Redirects, + navigationGuards: NavigationGuards, }; const App = () => { diff --git a/e2e/fixture-app/src/pages/NavigationGuards.tsx b/e2e/fixture-app/src/pages/NavigationGuards.tsx new file mode 100644 index 0000000..3c7f3d1 --- /dev/null +++ b/e2e/fixture-app/src/pages/NavigationGuards.tsx @@ -0,0 +1,48 @@ +import { useAtom, useAtomValue } from "jotai"; +import { useEffect } from "react"; +import { Link, useNavigate, useNavigationGuard } from "jarl-react"; +import { navigationGuardsAtom, navigationGuardsAwayAtom, unsavedEditsAtom, unsavedEditsGuard } from "../routes"; + +const useTitle = (title: string) => { + useEffect(() => { + document.title = title; + }, [title]); +}; + +// Wraps both pages of the demo so the guard and the dirty flag survive a navigation between +// them, which is what the back/forward scenarios need. +const NavigationGuards = () => { + const away = useAtomValue(navigationGuardsAwayAtom); + const [unsavedEdits, setUnsavedEdits] = useAtom(unsavedEditsAtom); + const navigateAway = useNavigate(navigationGuardsAwayAtom); + useNavigationGuard(unsavedEditsGuard); + useTitle(`Navigation Guards - ${away.match ? "Away" : "Editor"} - JARL`); + + return ( +
+ +
{away.match ? "Away" : "Editor"}
+ + +
+ ); +}; + +export default NavigationGuards; diff --git a/e2e/fixture-app/src/routes.ts b/e2e/fixture-app/src/routes.ts index 50e8800..0f5d9bc 100644 --- a/e2e/fixture-app/src/routes.ts +++ b/e2e/fixture-app/src/routes.ts @@ -12,7 +12,15 @@ */ import { atom } from "jotai/vanilla"; import { loadable } from "jotai/utils"; -import { rootAtom, staticRouteAtom, paramRouteAtom, redirectAtom, asyncRouteAtom, redirect } from "jarl-atoms"; +import { + rootAtom, + staticRouteAtom, + paramRouteAtom, + redirectAtom, + asyncRouteAtom, + redirect, + navigationGuardAtom, +} from "jarl-atoms"; // --- Shell (demo/cypress/integration/00DemosShell.js) --- export { rootAtom }; @@ -95,3 +103,15 @@ export const redirectsContentDataAtom = asyncRouteAtom(redirectsContentSlugAtom, // loadable() lets the pages read these without a Suspense boundary. export const redirectsAdminDataLoadableAtom = loadable(redirectsAdminDataAtom); export const redirectsContentDataLoadableAtom = loadable(redirectsContentDataAtom); + +// --- Navigation Guards --- +export const navigationGuardsAtom = staticRouteAtom("navigationGuards"); +export const navigationGuardsAwayAtom = staticRouteAtom("away", { + parent: navigationGuardsAtom, +}); + +export const unsavedEditsAtom = atom(false); + +export const unsavedEditsGuard = navigationGuardAtom((get) => + get(unsavedEditsAtom) ? "You have unsaved edits. Leave anyway?" : null, +); diff --git a/e2e/tests/05-navigation-guards.spec.ts b/e2e/tests/05-navigation-guards.spec.ts new file mode 100644 index 0000000..b32c2e3 --- /dev/null +++ b/e2e/tests/05-navigation-guards.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +const root = "/navigationGuards"; + +const answerConfirm = (page: Page, accept: boolean) => { + page.on("dialog", (dialog) => (accept ? dialog.accept() : dialog.dismiss())); +}; + +// A blocked traversal never commits, so Playwright's own navigation wait has nothing to resolve +// against: give it a short deadline and let the URL assertion be the real check. +const tryGoBack = (page: Page) => page.goBack({ timeout: 2000 }).catch(() => null); + +const startEditing = async (page: Page) => { + await page.goto(root); + await page.locator("[data-test=dirty-toggle]").check(); +}; + +test.describe("Navigation guards", () => { + test("navigates freely while nothing is dirty", async ({ page, baseURL }) => { + answerConfirm(page, false); + await page.goto(root); + + await page.locator("[data-test=away-link]").click(); + + await expect(page).toHaveURL(`${baseURL}${root}/away`); + await expect(page.locator("[data-test=header]")).toContainText("Away"); + }); + + test("blocks a Link click while edits are unsaved", async ({ page, baseURL }) => { + answerConfirm(page, false); + await startEditing(page); + + await page.locator("[data-test=away-link]").click(); + + await expect(page).toHaveURL(`${baseURL}${root}`); + await expect(page.locator("[data-test=header]")).toContainText("Editor"); + await expect(page.locator("[data-test=dirty-toggle]")).toBeChecked(); + }); + + test("follows a Link click once the prompt is accepted", async ({ page, baseURL }) => { + answerConfirm(page, true); + await startEditing(page); + + await page.locator("[data-test=away-link]").click(); + + await expect(page).toHaveURL(`${baseURL}${root}/away`); + }); + + test("blocks a useNavigate call", async ({ page, baseURL }) => { + answerConfirm(page, false); + await startEditing(page); + + await page.locator("[data-test=navigate-away]").click(); + + await expect(page).toHaveURL(`${baseURL}${root}`); + }); + + test("blocks a history.pushState from outside jarl", async ({ page, baseURL }) => { + answerConfirm(page, false); + await startEditing(page); + + await page.evaluate((to) => history.pushState(null, "", to), `${root}/away`); + + await expect(page).toHaveURL(`${baseURL}${root}`); + await expect(page.locator("[data-test=header]")).toContainText("Editor"); + }); + + test("follows a history.pushState from outside jarl once accepted", async ({ page, baseURL }) => { + answerConfirm(page, true); + await startEditing(page); + + await page.evaluate((to) => history.pushState(null, "", to), `${root}/away`); + + await expect(page).toHaveURL(`${baseURL}${root}/away`); + await expect(page.locator("[data-test=header]")).toContainText("Away"); + }); + + test("blocks the browser's back button", async ({ page, baseURL }) => { + answerConfirm(page, false); + await page.goto(root); + await page.locator("[data-test=away-link]").click(); + await expect(page).toHaveURL(`${baseURL}${root}/away`); + await page.locator("[data-test=dirty-toggle]").check(); + + await tryGoBack(page); + + await expect(page).toHaveURL(`${baseURL}${root}/away`); + await expect(page.locator("[data-test=header]")).toContainText("Away"); + }); + + test("goes back once the prompt is accepted", async ({ page, baseURL }) => { + answerConfirm(page, true); + await page.goto(root); + await page.locator("[data-test=away-link]").click(); + await expect(page).toHaveURL(`${baseURL}${root}/away`); + await page.locator("[data-test=dirty-toggle]").check(); + + await page.goBack(); + + await expect(page).toHaveURL(`${baseURL}${root}`); + await expect(page.locator("[data-test=header]")).toContainText("Editor"); + }); + + test("blocks the browser's forward button", async ({ page, baseURL }) => { + answerConfirm(page, false); + await page.goto(root); + await page.locator("[data-test=away-link]").click(); + await page.goBack(); + await expect(page).toHaveURL(`${baseURL}${root}`); + await page.locator("[data-test=dirty-toggle]").check(); + + await page.goForward({ timeout: 2000 }).catch(() => null); + + await expect(page).toHaveURL(`${baseURL}${root}`); + }); +});