diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 3c759dc..f45d930 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -146,9 +146,12 @@ export const Router = ({ children, ...props }) => { // holds to the context value: the router object let value = parent; - // when `ssrPath` contains a `?` character, we can extract the search from it - const [path, search] = props.ssrPath?.split("?") ?? []; - if (search) (props.ssrSearch = search), (props.ssrPath = path); + // when `ssrPath` contains a `?` character, we can extract the search from it. + // also, ensure ssrSearch is always defined when ssrPath is provided, so that + // useSearch behavior matches usePathname (proper SSR hydration when client + // renders without props after server rendered with ssrPath/ssrSearch) + const [path, search = props.ssrSearch ?? ""] = props.ssrPath?.split("?") ?? []; + if (path) props.ssrSearch = search, props.ssrPath = path; // hooks can define their own `href` formatter (e.g. for hash location) props.hrefs = props.hrefs ?? props.hook?.hrefs; @@ -174,8 +177,8 @@ export const Router = ({ children, ...props }) => { const option = k === "base" ? /* base is special case, it is appended to the parent's base */ - parent[k] + (props[k] || "") - : props[k] || parent[k]; + parent[k] + (props[k] ?? "") + : props[k] ?? parent[k]; if (prev === next && option !== next[k]) { ref.current = next = { ...next }; diff --git a/packages/wouter/src/use-browser-location.js b/packages/wouter/src/use-browser-location.js index 6d91a49..4b8b690 100644 --- a/packages/wouter/src/use-browser-location.js +++ b/packages/wouter/src/use-browser-location.js @@ -33,6 +33,9 @@ const currentSearch = () => location.search; export const useSearch = ({ ssrSearch } = {}) => useLocationProperty( currentSearch, + // != null checks for both null and undefined, but allows empty string "" + // This allows proper hydration: server renders with ssrSearch="?foo", + // client hydrates with just and reads from location.search ssrSearch != null ? () => ssrSearch : currentSearch ); @@ -41,6 +44,9 @@ const currentPathname = () => location.pathname; export const usePathname = ({ ssrPath } = {}) => useLocationProperty( currentPathname, + // != null checks for both null and undefined, but allows empty string "" + // This allows proper hydration: server renders with ssrPath="/foo", + // client hydrates with just and reads from location.pathname ssrPath != null ? () => ssrPath : currentPathname ); diff --git a/packages/wouter/test/router.test.tsx b/packages/wouter/test/router.test.tsx index 2868256..b1abe15 100644 --- a/packages/wouter/test/router.test.tsx +++ b/packages/wouter/test/router.test.tsx @@ -76,7 +76,7 @@ it("can extract `ssrSearch` from `ssrPath` after the '?' symbol", () => { }); expect(result.current.ssrPath).toBe("/no-search"); - expect(result.current.ssrSearch).toBe(undefined); + expect(result.current.ssrSearch).toBe(""); ssrPath = "/with-search?a=b&c=d"; rerender(); @@ -89,6 +89,15 @@ it("can extract `ssrSearch` from `ssrPath` after the '?' symbol", () => { expect(result.current.ssrSearch).toBe("a=b&c=d"); }); +it("keeps the ssrSearch undefined if not in SSR mode", () => { + const { result } = renderHook(() => useRouter(), { + wrapper: (props) => {props.children}, + }); + + expect(result.current.ssrPath).toBe(undefined); + expect(result.current.ssrSearch).toBe(undefined); +}); + it("shares one router instance between components", () => { const routers: any[] = []; diff --git a/packages/wouter/test/setup.ts b/packages/wouter/test/setup.ts index fa38dbb..8f17b3b 100644 --- a/packages/wouter/test/setup.ts +++ b/packages/wouter/test/setup.ts @@ -11,3 +11,18 @@ GlobalRegistrator.register({ // Extend Bun's expect with jest-dom matchers (expect as any).extend(matchers); + +/** + * Runs a function with `location` temporarily removed from globalThis. + * Simulates pure Node.js SSR environment for testing. + */ +export const withoutLocation = (fn: () => T): T => { + const original = globalThis.location; + // @ts-expect-error - intentionally removing location + delete globalThis.location; + try { + return fn(); + } finally { + globalThis.location = original; + } +}; diff --git a/packages/wouter/test/ssr.test.tsx b/packages/wouter/test/ssr.test.tsx index f47eb89..496f4c8 100644 --- a/packages/wouter/test/ssr.test.tsx +++ b/packages/wouter/test/ssr.test.tsx @@ -10,6 +10,7 @@ import { useLocation, SsrContext, } from "../src/index.js"; +import { withoutLocation } from "./setup.js"; describe("server-side rendering", () => { test("works via `ssrPath` prop", () => { @@ -88,18 +89,6 @@ describe("server-side rendering", () => { }); describe("rendering with given search string", () => { - test("is empty when not specified", () => { - const PrintSearch = () => <>{useSearch()}; - - const rendered = renderToStaticMarkup( - - - - ); - - expect(rendered).toBe(""); - }); - test("allows to override search string", () => { const App = () => { const search = useSearch(); @@ -120,5 +109,33 @@ describe("server-side rendering", () => { expect(rendered).toBe("/catalog filter by sort=created_at"); }); + + test("doesn't break useSearch hook if not specified", () => { + const PrintSearch = () => <>{useSearch()}; + + const rendered = withoutLocation(() => + renderToStaticMarkup( + + + + ) + ); + + expect(rendered).toBe(""); + }); + + test("works with empty ssrSearch", () => { + const PrintSearch = () => <>{useSearch()}; + + const rendered = withoutLocation(() => + renderToStaticMarkup( + + + + ) + ); + + expect(rendered).toBe(""); + }); }); });