Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SSR support in syncExternalStore #305

Merged
merged 15 commits into from
May 10, 2023
Merged
75 changes: 27 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
> [**Matt Miller**, _An exhaustive React ecosystem for 2020_](https://medium.com/@mmiller42/an-exhaustive-react-guide-for-2020-7859f0bddc56)

Wouter provides a simple API that many developers and library authors appreciate. Some notable
projects that use wouter: **[Ultra](https://ultrajs.dev/)**, **[React-three-fiber](https://github.com/react-spring/react-three-fiber)**,
projects that use wouter: **[Ultra](https://ultrajs.dev/)**,
**[React-three-fiber](https://github.com/react-spring/react-three-fiber)**,
**[Sunmao UI](https://sunmao-ui.com/)**, **[Million](https://millionjs.org/)** and many more.

## Table of Contents
Expand All @@ -69,7 +70,7 @@ projects that use wouter: **[Ultra](https://ultrajs.dev/)**, **[React-three-fibe
- [Multipath routes](#is-it-possible-to-match-an-array-of-paths)
- [TypeScript support](#can-i-use-wouter-in-my-typescript-project)
- [Using with Preact](#preact-support)
- [Server-side Rendering (SSR)](#is-there-any-support-for-server-side-rendering-ssr)
- [Server-side Rendering (SSR)](#server-side-rendering-support-ssr)
- [Routing in less than 400B](#1kb-is-too-much-i-cant-afford-it)

## Getting Started
Expand Down Expand Up @@ -218,7 +219,7 @@ import { useLocationProperty, navigate } from "wouter/use-location";
// (excluding the leading '#' symbol)
const hashLocation = () => window.location.hash.replace(/^#/, "") || "/";

const hashNavigate = (to) => navigate('#' + to);
const hashNavigate = (to) => navigate("#" + to);

const useHashLocation = () => {
const location = useLocationProperty(hashLocation);
Expand Down Expand Up @@ -339,20 +340,18 @@ import { Route, Switch } from "wouter";
<Switch>
<Route path="/orders/all" component={AllOrders} />
<Route path="/orders/:status" component={Orders} />

{/*
in wouter, any Route with empty path is considered always active.
This can be used to achieve "default" route behaviour within Switch.
Note: the order matters! See examples below.
*/}
<Route>
This is rendered when nothing above has matched
</Route>
<Route>This is rendered when nothing above has matched</Route>
</Switch>;
```

Check out [**FAQ and Code Recipes** section](#how-do-i-make-a-default-route) for more advanced use of
`Switch`.
Check out [**FAQ and Code Recipes** section](#how-do-i-make-a-default-route) for more advanced use
of `Switch`.

### `<Redirect to={path} />`

Expand Down Expand Up @@ -519,8 +518,9 @@ const App = () => (
);
```

**Note:** _the base path feature is only supported by the default `pushState` and `staticLocation` hooks. If you're
implementing your own location hook, you'll need to add base path support yourself._
**Note:** _the base path feature is only supported by the default browser History API location hook
(the one exported from `"wouter/use-location"`). If you're implementing your own location hook,
you'll need to add base path support yourself._

### How do I make a default route?

Expand Down Expand Up @@ -689,27 +689,19 @@ You might need to ensure you have the latest version of

**[▶ Demo Sandbox](https://codesandbox.io/s/wouter-preact-0lr3n)**

### Is there any support for server-side rendering (SSR)?
### Server-side Rendering support (SSR)?

Yes! In order to render your app on a server, you'll need to tell the router that the current
location comes from the request rather than the browser history. In **wouter**, you can achieve that
by replacing the default `useLocation` hook with a static one:
In order to render your app on the server, you'll need to wrap your app with top-level Router and
specify `ssrPath` prop (usually, derived from current request).

```js
import { renderToString } from "react-dom/server";
import { Router } from "wouter";

// note: static location has a different import path,
// this helps to keep the wouter source as small as possible
import staticLocationHook from "wouter/static-location";

import App from "./app";

const handleRequest = (req, res) => {
// The staticLocationHook function creates a hook that always
// responds with a path provided
// top-level Router is mandatory in SSR mode
const prerendered = renderToString(
<Router hook={staticLocationHook(req.path)}>
<Router ssrPath={req.path}>
<App />
</Router>
);
Expand All @@ -718,33 +710,20 @@ const handleRequest = (req, res) => {
};
```

Make sure you replace the static hook with the real one when you hydrate your app on a client.

If you want to be able to detect redirects you can provide the `record` option:
On the client, the static markup must be hydrated in order for your app to become interactive. Note
that to avoid having hydration warnings, the JSX rendered on the client must match the one used by
the server, so the `Router` component must be present.

```js
import { renderToString } from "react-dom/server";
import { Router } from "wouter";
import staticLocationHook from "wouter/static-location";

import App from "./app";

const handleRequest = (req, res) => {
const location = staticLocationHook(req.path, { record: true });
const prerendered = renderToString(
<Router hook={location}>
<App />
</Router>
);

// location.history is an array matching the history a
// user's browser would capture after loading the page
import { hydrateRoot } from "react-dom/server";

const finalPage = locationHook.history.slice(-1)[0];
if (finalPage !== req.path) {
// perform redirect
}
};
const root = hydrateRoot(
domNode,
// during hydration `ssrPath` is set to `location.pathname`
<Router>
<App />
</Router>
);
```

### 1KB is too much, I can't afford it!
Expand Down
12 changes: 11 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const defaultRouter = {
hook: locationHook,
matcher: matcherWithCache(),
base: "",
// this option is used to override the current location during SSR
// ssrPath: undefined,
};

const RouterCtx = createContext(defaultRouter);
Expand All @@ -53,11 +55,19 @@ export const useRoute = (pattern) => {
* Part 2, Low Carb Router API: Router, Route, Link, Switch
*/

export const Router = ({ hook, matcher, base = "", parent, children }) => {
export const Router = ({
hook,
matcher,
ssrPath,
base = "",
parent,
children,
}) => {
// updates the current router with the props passed down to the component
const updateRouter = (router, proto = parent || defaultRouter) => {
router.hook = hook || proto.hook;
router.matcher = matcher || proto.matcher;
router.ssrPath = ssrPath || proto.ssrPath;
router.ownBase = base;

// store reference to parent router
Expand Down
13 changes: 6 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "wouter",
"version": "2.10.2-dev.1",
"version": "2.11.0-dev.1",
"description": "A minimalistic routing for React and Preact. Nothing extra, just HOOKS.",
"keywords": [
"react",
Expand Down Expand Up @@ -161,9 +161,8 @@
"devDependencies": {
"@rollup/plugin-replace": "^5.0.2",
"@size-limit/preset-small-lib": "^6.0.4",
"@testing-library/react": "^11.2.5",
"@testing-library/react-hooks": "^5.0.3",
"@types/react": "^17.0.1",
"@testing-library/react": "^14.0.0",
"@types/react": "^18.2.0",
"copyfiles": "^2.4.1",
"dtslint": "^3.4.2",
"eslint": "^7.19.0",
Expand All @@ -173,9 +172,9 @@
"jest-esm-jsx-transform": "^1.0.0",
"preact": "^10.0.0",
"prettier": "^2.4.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-test-renderer": "^17.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-test-renderer": "^18.2.0",
"rimraf": "^3.0.2",
"rollup": "^3.7.4",
"size-limit": "^6.0.4",
Expand Down
18 changes: 9 additions & 9 deletions preact/types/use-location.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ export type Path = string;

// the base useLocation hook type. Any custom hook (including the
// default one) should inherit from it.
export type BaseLocationHook = (
...args: any[]
) => [Path, (path: Path, ...args: any[]) => any];
export type BaseLocationHook = (...args: any[]) => [Path, (path: Path, ...args: any[]) => any];

/*
* Utility types that operate on hook
Expand All @@ -18,22 +16,24 @@ export type BaseLocationHook = (
export type HookReturnValue<H extends BaseLocationHook> = ReturnType<H>;

// Returns the type of the navigation options that hook's push function accepts.
export type HookNavigationOptions<H extends BaseLocationHook> = HookReturnValue<
H
>[1] extends (path: Path, options: infer R, ...rest: any[]) => any
export type HookNavigationOptions<H extends BaseLocationHook> = HookReturnValue<H>[1] extends (
path: Path,
options: infer R,
...rest: any[]
) => any
? R extends { [k: string]: any }
? R
: {}
: {};

type Primitive = string | number | bigint | boolean | null | undefined | symbol;
export const useLocationProperty: <S extends Primitive>(fn: () => S) => S;
export const useLocationProperty: <S extends Primitive>(fn: () => S, ssrFn?: () => S) => S;

export const useSearch: () => string;

export const usePathname: () => Path;
export const usePathname: (options?: { ssrPath?: Path }) => Path;

export const navigate: (to: string | URL, options?: { replace?: boolean }) => void
export const navigate: (to: string | URL, options?: { replace?: boolean }) => void;

/*
* Default `useLocation`
Expand Down
6 changes: 6 additions & 0 deletions static-location.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ const absolutePath = (to, base = "") =>
// responds with initial path provided.
// You can use this for server-side rendering.
export default (path = "/", { record = false } = {}) => {
console.warn(
"`wouter/static-location` is deprecated and will be removed in upcoming versions. " +
"If you want to use wouter in SSR mode, please use `ssrPath` option passed to the top-level " +
"`<Router>` component."
);

const hook = ({ base = "" } = {}) => [
relativePath(base, path),
(to, { replace } = {}) => {
Expand Down
8 changes: 4 additions & 4 deletions test/ssr.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import staticLocationHook from "../static-location.js";
describe("server-side rendering", () => {
it("works via staticHistory", () => {
const App = () => (
<Router hook={staticLocationHook("/users/baz")}>
<Router ssrPath="/users/baz">
<Route path="/users/baz">foo</Route>
<Route path="/users/:any*">bar</Route>
<Route path="/users/:id">{(params) => params.id}</Route>
Expand All @@ -30,7 +30,7 @@ describe("server-side rendering", () => {
};

const App = () => (
<Router hook={staticLocationHook("/pages/intro")}>
<Router ssrPath="/pages/intro">
<HookRoute />
</Router>
);
Expand All @@ -41,7 +41,7 @@ describe("server-side rendering", () => {

it("renders valid and accessible link elements", () => {
const App = () => (
<Router hook={staticLocationHook("/")}>
<Router ssrPath="/">
<Link href="/users/1" title="Profile">
Mark
</Link>
Expand All @@ -54,7 +54,7 @@ describe("server-side rendering", () => {

it("renders redirects however they have effect only on a client-side", () => {
const App = () => (
<Router hook={staticLocationHook("/")}>
<Router ssrPath="/">
<Route path="/">
<Redirect to="/foo" />
</Route>
Expand Down
2 changes: 1 addition & 1 deletion test/static-location.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { renderHook, act } from "@testing-library/react-hooks";
import { renderHook, act } from "@testing-library/react";
import staticLocation from "../static-location.js";

it("is a static hook factory", () => {
Expand Down
41 changes: 28 additions & 13 deletions test/use-location.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { renderHook, act } from "@testing-library/react-hooks";
import React, { useEffect } from "react";
import { renderHook, act } from "@testing-library/react";
import useLocation, { navigate, useSearch } from "../use-location.js";

it("returns a pair [value, update]", () => {
Expand Down Expand Up @@ -83,39 +84,53 @@ describe("`value` first argument", () => {
});

it("supports search url", () => {
const { result, unmount } = renderHook(() => useLocation());
const { result: searchResult, unmount: searchUnmount } = renderHook(() =>
useSearch()
);
// count how many times each hook is rendered
const locationRenders = { current: 0 };
const searchRenders = { current: 0 };

// count number of rerenders for each hook
const { result, unmount } = renderHook(() => {
useEffect(() => {
locationRenders.current += 1;
});
return useLocation();
});

const { result: searchResult, unmount: searchUnmount } = renderHook(() => {
useEffect(() => {
searchRenders.current += 1;
});
return useSearch();
});

expect(result.current[0]).toBe("/");
expect(result.all.length).toBe(1);
expect(locationRenders.current).toBe(1);
expect(searchResult.current).toBe("");
expect(searchResult.all.length).toBe(1);
expect(searchRenders.current).toBe(1);

act(() => navigate("/foo"));

expect(result.current[0]).toBe("/foo");
expect(result.all.length).toBe(2);
expect(locationRenders.current).toBe(2);

act(() => navigate("/foo"));

expect(result.current[0]).toBe("/foo");
expect(result.all.length).toBe(2); // no re-render
expect(locationRenders.current).toBe(2); // no re-render

act(() => navigate("/foo?hello=world"));

expect(result.current[0]).toBe("/foo");
expect(result.all.length).toBe(2);
expect(locationRenders.current).toBe(2);
expect(searchResult.current).toBe("?hello=world");
expect(searchResult.all.length).toBe(2);
expect(searchRenders.current).toBe(2);

act(() => navigate("/foo?goodbye=world"));

expect(result.current[0]).toBe("/foo");
expect(result.all.length).toBe(2);
expect(locationRenders.current).toBe(2);
expect(searchResult.current).toBe("?goodbye=world");
expect(searchResult.all.length).toBe(3);
expect(searchRenders.current).toBe(3);

unmount();
searchUnmount();
Expand Down
2 changes: 1 addition & 1 deletion test/use-router.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { cloneElement } from "react";
import { renderHook } from "@testing-library/react-hooks";
import { renderHook } from "@testing-library/react";
import TestRenderer from "react-test-renderer";

import { Router, useRouter } from "../index.js";
Expand Down
4 changes: 3 additions & 1 deletion types/router.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface RouterObject {
readonly ownBase: Path;
readonly matcher: MatcherFn;
readonly parent?: RouterObject;
readonly ssrPath?: Path;
}

// basic options to construct a router
Expand All @@ -16,4 +17,5 @@ export type RouterOptions = {
base?: Path;
matcher?: MatcherFn;
parent?: RouterObject;
}
ssrPath?: Path;
};
Loading