Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions packages/wouter/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Router> 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;
Expand All @@ -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 };
Expand Down
6 changes: 6 additions & 0 deletions packages/wouter/src/use-browser-location.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Router /> and reads from location.search
ssrSearch != null ? () => ssrSearch : currentSearch
);

Expand All @@ -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 <Router /> and reads from location.pathname
ssrPath != null ? () => ssrPath : currentPathname
);

Expand Down
11 changes: 10 additions & 1 deletion packages/wouter/test/router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) => <Router>{props.children}</Router>,
});

expect(result.current.ssrPath).toBe(undefined);
expect(result.current.ssrSearch).toBe(undefined);
});

it("shares one router instance between components", () => {
const routers: any[] = [];

Expand Down
15 changes: 15 additions & 0 deletions packages/wouter/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(fn: () => T): T => {
const original = globalThis.location;
// @ts-expect-error - intentionally removing location
delete globalThis.location;
try {
return fn();
} finally {
globalThis.location = original;
}
};
41 changes: 29 additions & 12 deletions packages/wouter/test/ssr.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(
<Router ssrPath="/">
<PrintSearch />
</Router>
);

expect(rendered).toBe("");
});

test("allows to override search string", () => {
const App = () => {
const search = useSearch();
Expand All @@ -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(
<Router ssrPath="/">
<PrintSearch />
</Router>
)
);

expect(rendered).toBe("");
});

test("works with empty ssrSearch", () => {
const PrintSearch = () => <>{useSearch()}</>;

const rendered = withoutLocation(() =>
renderToStaticMarkup(
<Router ssrPath="/" ssrSearch="">
<PrintSearch />
</Router>
)
);

expect(rendered).toBe("");
});
});
});