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.
pull/5918/merge
boojack 3 weeks ago
parent 8c16ffa1f1
commit aa5cb455e9

@ -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…
Cancel
Save