refactor(web): use / as the home route instead of /home
Home now lives at `/` directly. Unauthenticated visitors are sent to `/explore` by the landing gate; old `/home` URLs redirect to `/` for bookmark compatibility.
This commit is contained in:
parent
8c16ffa1f1
commit
aa5cb455e9
7 changed files with 57 additions and 68 deletions
|
|
@ -42,7 +42,7 @@ export const Tag: React.FC<TagProps> = ({ "data-tag": dataTag, children, classNa
|
|||
|
||||
// If the tag is clicked in a memo detail page, we should navigate to the memo list page.
|
||||
if (location.pathname.startsWith("/m")) {
|
||||
const pathname = parentPage || Routes.ENTRY;
|
||||
const pathname = parentPage || Routes.HOME;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set("filter", stringifyFilters([{ factor: "tagSearch", value: tag }]));
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ const Navigation = (props: Props) => {
|
|||
}
|
||||
key={navLink.id}
|
||||
to={navLink.path}
|
||||
end={navLink.path === Routes.HOME}
|
||||
id={navLink.id}
|
||||
aria-label={navLink.id === "header-inbox" ? inboxAriaLabel : undefined}
|
||||
viewTransition
|
||||
|
|
|
|||
|
|
@ -4,25 +4,29 @@ import { AUTH_REDIRECT_PARAM, buildAuthRoute, getSafeRedirectPath } from "@/util
|
|||
import { ROUTES } from "./routes";
|
||||
|
||||
/**
|
||||
* Entry-route component mounted at `/`. Performs authentication-aware redirection
|
||||
* to the correct landing page before any business UI renders, preserving the
|
||||
* original query string and hash so bookmarks like `/?filter=foo` keep working.
|
||||
* Index-route gate mounted at `/`. Authenticated visitors fall through to the
|
||||
* nested Home page; unauthenticated visitors are redirected to `/explore`,
|
||||
* preserving the original query string and hash so bookmarks like `/?filter=foo`
|
||||
* keep working.
|
||||
*/
|
||||
export const LandingRoute = () => {
|
||||
const currentUser = useCurrentUser();
|
||||
const location = useLocation();
|
||||
const target = currentUser ? ROUTES.HOME : ROUTES.EXPLORE;
|
||||
|
||||
return (
|
||||
<Navigate
|
||||
to={{
|
||||
pathname: target,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
}}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<Navigate
|
||||
to={{
|
||||
pathname: ROUTES.EXPLORE,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
}}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -44,7 +48,7 @@ export const RequireAuthRoute = () => {
|
|||
|
||||
/**
|
||||
* Guard for guest-only routes (sign-in and sign-up). Already-authenticated users
|
||||
* are redirected to the requested `redirect` target (when safe) or to `/home`.
|
||||
* are redirected to the requested `redirect` target (when safe) or to `/`.
|
||||
*
|
||||
* The OAuth callback route (`/auth/callback`) intentionally opts out of this guard:
|
||||
* an authenticated session in another tab must not prevent the callback from
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { lazy } from "react";
|
||||
import { createBrowserRouter, type RouteObject } from "react-router-dom";
|
||||
import { createBrowserRouter, Navigate, type RouteObject } from "react-router-dom";
|
||||
import App from "@/App";
|
||||
import { ChunkLoadErrorFallback } from "@/components/ErrorBoundary";
|
||||
import MainLayout from "@/layouts/MainLayout";
|
||||
|
|
@ -72,20 +72,23 @@ export const routeConfig: RouteObject[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{ index: true, element: <LandingRoute /> },
|
||||
// Backward compatibility: the old `/home` URL now lives at `/`.
|
||||
{ path: "home", element: <Navigate to={Routes.HOME} replace /> },
|
||||
{
|
||||
path: Routes.ENTRY,
|
||||
element: <RootLayout />,
|
||||
children: [
|
||||
{
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{
|
||||
element: <LandingRoute />,
|
||||
children: [{ index: true, element: <Home /> }],
|
||||
},
|
||||
{ path: Routes.EXPLORE, element: <Explore /> },
|
||||
{ path: "u/:username", element: <UserProfile /> },
|
||||
{
|
||||
element: <RequireAuthRoute />,
|
||||
children: [
|
||||
{ path: Routes.HOME, element: <Home /> },
|
||||
{ path: Routes.ARCHIVED, element: <Archived /> },
|
||||
{ path: Routes.SHORTCUTS, element: <Shortcuts /> },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
export const ROUTES = {
|
||||
// Entry-only route. Hosts the landing redirect, never a business page.
|
||||
ENTRY: "/",
|
||||
// The authenticated user's primary workspace page.
|
||||
HOME: "/home",
|
||||
HOME: "/",
|
||||
ATTACHMENTS: "/attachments",
|
||||
INBOX: "/inbox",
|
||||
ARCHIVED: "/archived",
|
||||
|
|
|
|||
|
|
@ -25,19 +25,20 @@ const renderAt = (initialEntry: string, children: ReactNode) =>
|
|||
render(<MemoryRouter initialEntries={[initialEntry]}>{children}</MemoryRouter>);
|
||||
|
||||
describe("LandingRoute", () => {
|
||||
it("sends an authenticated visitor from the entry to /home", () => {
|
||||
it("renders the nested home page for an authenticated visitor at /", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/",
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingRoute />} />
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
<Route path="/" element={<LandingRoute />}>
|
||||
<Route index element={<div data-testid="home">home</div>} />
|
||||
</Route>
|
||||
<Route path="/explore" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/home");
|
||||
expect(screen.getByTestId("home")).toHaveTextContent("home");
|
||||
});
|
||||
|
||||
it("sends an unauthenticated visitor from the entry to /explore", () => {
|
||||
|
|
@ -46,8 +47,9 @@ describe("LandingRoute", () => {
|
|||
renderAt(
|
||||
"/",
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingRoute />} />
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
<Route path="/" element={<LandingRoute />}>
|
||||
<Route index element={<div data-testid="home">home</div>} />
|
||||
</Route>
|
||||
<Route path="/explore" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
|
@ -55,20 +57,6 @@ describe("LandingRoute", () => {
|
|||
expect(screen.getByTestId("location").textContent).toBe("/explore");
|
||||
});
|
||||
|
||||
it("preserves the query string and hash when redirecting an authenticated visitor", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/?filter=tag:work&sort=desc#top",
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingRoute />} />
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/home?filter=tag:work&sort=desc#top");
|
||||
});
|
||||
|
||||
it("preserves the query string and hash when redirecting an unauthenticated visitor", () => {
|
||||
// Covers the regression in issue #5846: bookmarks pointing at `/?filter=...`
|
||||
// must not drop their params on the trip through the landing redirect.
|
||||
|
|
@ -77,7 +65,9 @@ describe("LandingRoute", () => {
|
|||
renderAt(
|
||||
"/?filter=tag:work#latest",
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingRoute />} />
|
||||
<Route path="/" element={<LandingRoute />}>
|
||||
<Route index element={<div data-testid="home">home</div>} />
|
||||
</Route>
|
||||
<Route path="/explore" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
|
@ -91,10 +81,10 @@ describe("RequireAuthRoute", () => {
|
|||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
"/home",
|
||||
"/setting",
|
||||
<Routes>
|
||||
<Route element={<RequireAuthRoute />}>
|
||||
<Route path="/home" element={<div data-testid="protected">secret</div>} />
|
||||
<Route path="/setting" element={<div data-testid="protected">secret</div>} />
|
||||
</Route>
|
||||
</Routes>,
|
||||
);
|
||||
|
|
@ -106,16 +96,16 @@ describe("RequireAuthRoute", () => {
|
|||
mockedUseCurrentUser.mockReturnValue(undefined);
|
||||
|
||||
renderAt(
|
||||
"/home?tab=pins#latest",
|
||||
"/setting?tab=pins#latest",
|
||||
<Routes>
|
||||
<Route element={<RequireAuthRoute />}>
|
||||
<Route path="/home" element={<div data-testid="protected">secret</div>} />
|
||||
<Route path="/setting" element={<div data-testid="protected">secret</div>} />
|
||||
</Route>
|
||||
<Route path="/auth" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/auth?redirect=%2Fhome%3Ftab%3Dpins%23latest");
|
||||
expect(screen.getByTestId("location").textContent).toBe("/auth?redirect=%2Fsetting%3Ftab%3Dpins%23latest");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -135,7 +125,7 @@ describe("RequireGuestRoute", () => {
|
|||
expect(screen.getByTestId("sign-in")).toHaveTextContent("sign in");
|
||||
});
|
||||
|
||||
it("redirects already-authenticated users to /home by default", () => {
|
||||
it("redirects already-authenticated users to / by default", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
|
|
@ -144,11 +134,11 @@ describe("RequireGuestRoute", () => {
|
|||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div>sign in</div>} />
|
||||
</Route>
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
<Route path="/" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/home");
|
||||
expect(screen.getByTestId("location").textContent).toBe("/");
|
||||
});
|
||||
|
||||
it("honours a safe redirect target from the query string", () => {
|
||||
|
|
@ -161,14 +151,14 @@ describe("RequireGuestRoute", () => {
|
|||
<Route path="/auth" element={<div>sign in</div>} />
|
||||
</Route>
|
||||
<Route path="/setting" element={<LocationProbe />} />
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
<Route path="/" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/setting");
|
||||
});
|
||||
|
||||
it("ignores an auth-family redirect target and falls back to /home", () => {
|
||||
it("ignores an auth-family redirect target and falls back to /", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
|
|
@ -177,14 +167,14 @@ describe("RequireGuestRoute", () => {
|
|||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div>sign in</div>} />
|
||||
</Route>
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
<Route path="/" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/home");
|
||||
expect(screen.getByTestId("location").textContent).toBe("/");
|
||||
});
|
||||
|
||||
it("ignores an external redirect target and falls back to /home", () => {
|
||||
it("ignores an external redirect target and falls back to /", () => {
|
||||
mockedUseCurrentUser.mockReturnValue(fakeUser);
|
||||
|
||||
renderAt(
|
||||
|
|
@ -193,10 +183,10 @@ describe("RequireGuestRoute", () => {
|
|||
<Route element={<RequireGuestRoute />}>
|
||||
<Route path="/auth" element={<div>sign in</div>} />
|
||||
</Route>
|
||||
<Route path="/home" element={<LocationProbe />} />
|
||||
<Route path="/" element={<LocationProbe />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("location").textContent).toBe("/home");
|
||||
expect(screen.getByTestId("location").textContent).toBe("/");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { isValidElement } from "react";
|
|||
import type { RouteObject } from "react-router-dom";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routeConfig, ROUTES } from "@/router";
|
||||
import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
|
||||
import { RequireAuthRoute, RequireGuestRoute } from "@/router/guards";
|
||||
|
||||
// Walk the nested route config and find the first route with the given path,
|
||||
// starting from the provided roots. Returns undefined if nothing matches.
|
||||
|
|
@ -39,12 +39,6 @@ function hasAncestorOfType(routes: RouteObject[], path: string, guardType: unkno
|
|||
}
|
||||
|
||||
describe("router configuration", () => {
|
||||
it("mounts the LandingRoute at the entry index", () => {
|
||||
const root = routeConfig[0];
|
||||
const indexRoute = root.children?.find((r) => r.index);
|
||||
expect(elementType(indexRoute)).toBe(LandingRoute);
|
||||
});
|
||||
|
||||
it("keeps /auth/callback outside the guest-only guard", () => {
|
||||
// Regression guard for issue #5846 follow-up: an authenticated tab elsewhere
|
||||
// must not short-circuit the OAuth callback via RequireGuestRoute.
|
||||
|
|
@ -58,7 +52,7 @@ describe("router configuration", () => {
|
|||
});
|
||||
|
||||
it("wraps authenticated-only pages in RequireAuthRoute", () => {
|
||||
for (const path of [ROUTES.HOME, ROUTES.ARCHIVED, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.SETTING]) {
|
||||
for (const path of [ROUTES.ARCHIVED, ROUTES.ATTACHMENTS, ROUTES.INBOX, ROUTES.SETTING]) {
|
||||
expect(hasAncestorOfType(routeConfig, path, RequireAuthRoute)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue