Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions e2e/fixture-app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -17,6 +18,7 @@ const DEMOS: Record<string, ComponentType> = {
advancedRouting: AdvancedRouting,
queryStrings: QueryStrings,
redirects: Redirects,
navigationGuards: NavigationGuards,
};

const App = () => {
Expand Down
48 changes: 48 additions & 0 deletions e2e/fixture-app/src/pages/NavigationGuards.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<nav>
<Link route={navigationGuardsAtom} data-test="editor-link">
Editor
</Link>{" "}
<Link route={navigationGuardsAwayAtom} data-test="away-link">
Away
</Link>
</nav>
<div data-test="header">{away.match ? "Away" : "Editor"}</div>
<label>
<input
data-test="dirty-toggle"
type="checkbox"
checked={unsavedEdits}
onChange={(event) => setUnsavedEdits(event.target.checked)}
/>
Unsaved edits
</label>
<button data-test="navigate-away" onClick={() => navigateAway({})}>
Navigate away
</button>
</div>
);
};

export default NavigationGuards;
22 changes: 21 additions & 1 deletion e2e/fixture-app/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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,
);
117 changes: 117 additions & 0 deletions e2e/tests/05-navigation-guards.spec.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
});
Loading
Loading