From 2f8c17cb8258fa0c54f16c63631db0456f7b4b6b Mon Sep 17 00:00:00 2001 From: uhyo Date: Sat, 7 Feb 2026 20:29:29 +0900 Subject: [PATCH] fix!: remove useLocationSSR hook to prevent hydration mismatches useLocationSSR was a footgun that easily caused hydration mismatches because the router context state and the actual DOM could diverge during hydration. The docs now recommend using a client-side effect approach (useLayoutEffect + Navigation API) instead. BREAKING CHANGE: useLocationSSR hook has been removed from the public API. Co-Authored-By: Claude Opus 4.6 --- packages/docs/src/pages/ApiHooksPage.tsx | 38 ----------- .../docs/src/pages/ApiReferenceIndexPage.tsx | 4 -- packages/docs/src/pages/LearnSsrPage.tsx | 31 ++++++--- packages/router/src/__tests__/hooks.test.tsx | 68 ------------------- packages/router/src/hooks/useLocationSSR.ts | 25 ------- packages/router/src/index.ts | 1 - 6 files changed, 20 insertions(+), 147 deletions(-) delete mode 100644 packages/router/src/hooks/useLocationSSR.ts diff --git a/packages/docs/src/pages/ApiHooksPage.tsx b/packages/docs/src/pages/ApiHooksPage.tsx index c3b9eb4..8125616 100644 --- a/packages/docs/src/pages/ApiHooksPage.tsx +++ b/packages/docs/src/pages/ApiHooksPage.tsx @@ -48,44 +48,6 @@ function MyComponent() { }`} -
-

- useLocationSSR() -

-

- Returns the current location object, or null when the URL - is not available (e.g. during SSR). This is the SSR-safe alternative - to useLocation(). -

- {`import { useLocationSSR } from "@funstack/router"; - -function AppShell() { - const location = useLocationSSR(); - - // location is null during SSR, Location object after hydration - const isActive = (path: string) => { - if (location === null) return false; - return location.pathname === path; - }; - - return ( - - ); -}`} -

Return Value

-
    -
  • - Location | null — The current location object - with pathname, search, and{" "} - hash properties, or null during SSR when - no URL is available. -
  • -
-
-

useSearchParams() diff --git a/packages/docs/src/pages/ApiReferenceIndexPage.tsx b/packages/docs/src/pages/ApiReferenceIndexPage.tsx index 030d4cf..16d9296 100644 --- a/packages/docs/src/pages/ApiReferenceIndexPage.tsx +++ b/packages/docs/src/pages/ApiReferenceIndexPage.tsx @@ -37,10 +37,6 @@ export function ApiReferenceIndexPage() {
  • useLocation() — Current location object
  • -
  • - useLocationSSR() — SSR-safe current location (returns{" "} - null during SSR) -
  • useSearchParams() — Search query management
  • diff --git a/packages/docs/src/pages/LearnSsrPage.tsx b/packages/docs/src/pages/LearnSsrPage.tsx index a2ee3d8..c9125bf 100644 --- a/packages/docs/src/pages/LearnSsrPage.tsx +++ b/packages/docs/src/pages/LearnSsrPage.tsx @@ -91,9 +91,10 @@ useSearchParams(); // Error: "useSearchParams: URL is not available during SSR."`}

    To avoid these errors, either use URL-dependent hooks only in - components rendered by path-based routes, or use the SSR-safe{" "} - useLocationSSR() hook which returns null{" "} - instead of throwing when the URL is unavailable: + components rendered by path-based routes, or read the current path + inside a client-side effect (e.g., useLayoutEffect +{" "} + navigation.currentEntry) so the value is only accessed + after hydration:

    {`// ✗ Bad: AppShell renders during SSR, useLocation will throw function AppShell() { @@ -101,13 +102,20 @@ function AppShell() { return
    {/* ... */}
    ; } -// ✓ Good: Use useLocationSSR in components that render during SSR +// ✓ Good: Read the path in a client-side effect +function useCurrentPath() { + const [path, setPath] = useState(undefined); + useLayoutEffect(() => { + setPath(navigation.currentEntry?.url + ? new URL(navigation.currentEntry.url).pathname + : undefined); + }, []); + return path; +} + function AppShell() { - const location = useLocationSSR(); // Returns null during SSR - const isActive = (path: string) => { - if (location === null) return false; - return location.pathname === path; - }; + const path = useCurrentPath(); // undefined during SSR, string after hydration + const isActive = (p: string) => path === p; return ; } @@ -159,8 +167,9 @@ function HomePage() {
  • Avoid useLocation and useSearchParams in - components that render during SSR; use useLocationSSR{" "} - instead when you need location information in the app shell + components that render during SSR; use a client-side effect (e.g.,{" "} + useLayoutEffect) to read location information in the + app shell
  • This two-stage model keeps SSR output lightweight while enabling diff --git a/packages/router/src/__tests__/hooks.test.tsx b/packages/router/src/__tests__/hooks.test.tsx index 2431bc4..1388a5f 100644 --- a/packages/router/src/__tests__/hooks.test.tsx +++ b/packages/router/src/__tests__/hooks.test.tsx @@ -4,11 +4,9 @@ import { Suspense, type ReactNode } from "react"; import { Router } from "../Router.js"; import { useNavigate } from "../hooks/useNavigate.js"; import { useLocation } from "../hooks/useLocation.js"; -import { useLocationSSR } from "../hooks/useLocationSSR.js"; import { useSearchParams } from "../hooks/useSearchParams.js"; import { useIsPending } from "../hooks/useIsPending.js"; import { setupNavigationMock, cleanupNavigationMock } from "./setup.js"; -import { RouterContext } from "../context/RouterContext.js"; import type { RouteDefinition } from "../route.js"; describe("hooks", () => { @@ -133,72 +131,6 @@ describe("hooks", () => { }); }); - describe("useLocationSSR", () => { - it("returns current location when URL is available", () => { - mockNavigation = setupNavigationMock( - "http://localhost/page?foo=bar#section", - ); - - function TestComponent() { - const location = useLocationSSR(); - return ( -
    - {location?.pathname} - {location?.search} - {location?.hash} -
    - ); - } - - const routes: RouteDefinition[] = [ - { path: "/page", component: TestComponent }, - ]; - - render(); - - expect(screen.getByTestId("pathname").textContent).toBe("/page"); - expect(screen.getByTestId("search").textContent).toBe("?foo=bar"); - expect(screen.getByTestId("hash").textContent).toBe("#section"); - }); - - it("returns null during SSR (url is null)", () => { - let capturedLocation: ReturnType | undefined; - - function TestComponent() { - capturedLocation = useLocationSSR(); - return
    test
    ; - } - - const ssrContextValue = { - locationEntry: null, - url: null, - isPending: false, - navigate: () => {}, - navigateAsync: async () => {}, - updateCurrentEntryState: () => {}, - }; - - render( - - - , - ); - - expect(capturedLocation).toBeNull(); - }); - - it("throws when used outside Router", () => { - function TestComponent() { - useLocationSSR(); - return null; - } - - expect(() => render()).toThrow( - "useLocationSSR must be used within a Router", - ); - }); - }); - describe("useSearchParams", () => { it("returns current search params", () => { mockNavigation = setupNavigationMock( diff --git a/packages/router/src/hooks/useLocationSSR.ts b/packages/router/src/hooks/useLocationSSR.ts deleted file mode 100644 index 5096934..0000000 --- a/packages/router/src/hooks/useLocationSSR.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { useContext, useMemo } from "react"; -import { RouterContext } from "../context/RouterContext.js"; -import type { Location } from "../types.js"; - -/** - * Returns the current location object, or `null` when the URL is not available (e.g. during SSR). - */ -export function useLocationSSR(): Location | null { - const context = useContext(RouterContext); - - if (!context) { - throw new Error("useLocationSSR must be used within a Router"); - } - - const { url } = context; - - return useMemo(() => { - if (url === null) return null; - return { - pathname: url.pathname, - search: url.search, - hash: url.hash, - }; - }, [url]); -} diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 5484ab3..8af424a 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -9,7 +9,6 @@ export { Outlet } from "./Outlet.js"; // Hooks export { useNavigate } from "./hooks/useNavigate.js"; export { useLocation } from "./hooks/useLocation.js"; -export { useLocationSSR } from "./hooks/useLocationSSR.js"; export { useSearchParams } from "./hooks/useSearchParams.js"; export { useBlocker, type UseBlockerOptions } from "./hooks/useBlocker.js"; export { useRouteParams } from "./hooks/useRouteParams.js";