From 853031f3bbad280d5278dde8f9197adc0196e1be Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 Dec 2025 16:17:28 +0100 Subject: [PATCH 1/3] Fix useSearch in SSR regression. --- packages/wouter/src/index.js | 4 +- packages/wouter/src/use-browser-location.js | 2 +- packages/wouter/test/setup.ts | 15 +++++++ packages/wouter/test/ssr.test.tsx | 43 +++++++++++++++------ 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 3c759dcd..238cd7cc 100644 --- a/packages/wouter/src/index.js +++ b/packages/wouter/src/index.js @@ -174,8 +174,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 6d91a497..4c8f2576 100644 --- a/packages/wouter/src/use-browser-location.js +++ b/packages/wouter/src/use-browser-location.js @@ -33,7 +33,7 @@ const currentSearch = () => location.search; export const useSearch = ({ ssrSearch } = {}) => useLocationProperty( currentSearch, - ssrSearch != null ? () => ssrSearch : currentSearch + ssrSearch != null ? () => ssrSearch : () => "" ); const currentPathname = () => location.pathname; diff --git a/packages/wouter/test/setup.ts b/packages/wouter/test/setup.ts index fa38dbb3..8f17b3ba 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 f47eb895..f2af6089 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,35 @@ describe("server-side rendering", () => { expect(rendered).toBe("/catalog filter by sort=created_at"); }); + + // issue #550: useSearch should work in pure Node.js without `location` global + test("works without location global (issue #550)", () => { + const PrintSearch = () => <>{useSearch()}; + + const rendered = withoutLocation(() => + renderToStaticMarkup( + + + + ) + ); + + expect(rendered).toBe(""); + }); + + // issue #550: passing ssrSearch="" explicitly should work + test("empty ssrSearch is respected (issue #550)", () => { + const PrintSearch = () => <>{useSearch()}; + + const rendered = withoutLocation(() => + renderToStaticMarkup( + + + + ) + ); + + expect(rendered).toBe(""); + }); }); }); From e5aa21124896bc7d1c1d7f77df19de8f7e14be08 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 Dec 2025 21:40:15 +0100 Subject: [PATCH 2/3] Improve the fix. --- packages/wouter/src/index.js | 9 ++++++--- packages/wouter/src/use-browser-location.js | 8 +++++++- packages/wouter/test/router.test.tsx | 2 +- packages/wouter/test/ssr.test.tsx | 6 ++---- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js index 238cd7cc..f45d9300 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; diff --git a/packages/wouter/src/use-browser-location.js b/packages/wouter/src/use-browser-location.js index 4c8f2576..4b8b6904 100644 --- a/packages/wouter/src/use-browser-location.js +++ b/packages/wouter/src/use-browser-location.js @@ -33,7 +33,10 @@ const currentSearch = () => location.search; export const useSearch = ({ ssrSearch } = {}) => useLocationProperty( currentSearch, - ssrSearch != null ? () => ssrSearch : () => "" + // != 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 ); const currentPathname = () => location.pathname; @@ -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 28682568..282305ff 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(); diff --git a/packages/wouter/test/ssr.test.tsx b/packages/wouter/test/ssr.test.tsx index f2af6089..496f4c89 100644 --- a/packages/wouter/test/ssr.test.tsx +++ b/packages/wouter/test/ssr.test.tsx @@ -110,8 +110,7 @@ describe("server-side rendering", () => { expect(rendered).toBe("/catalog filter by sort=created_at"); }); - // issue #550: useSearch should work in pure Node.js without `location` global - test("works without location global (issue #550)", () => { + test("doesn't break useSearch hook if not specified", () => { const PrintSearch = () => <>{useSearch()}; const rendered = withoutLocation(() => @@ -125,8 +124,7 @@ describe("server-side rendering", () => { expect(rendered).toBe(""); }); - // issue #550: passing ssrSearch="" explicitly should work - test("empty ssrSearch is respected (issue #550)", () => { + test("works with empty ssrSearch", () => { const PrintSearch = () => <>{useSearch()}; const rendered = withoutLocation(() => From d7845f58a86b5e2f566c735f74faf333a47a01f7 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 Dec 2025 22:19:55 +0100 Subject: [PATCH 3/3] One more test. --- packages/wouter/test/router.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/wouter/test/router.test.tsx b/packages/wouter/test/router.test.tsx index 282305ff..b1abe151 100644 --- a/packages/wouter/test/router.test.tsx +++ b/packages/wouter/test/router.test.tsx @@ -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[] = [];