Skip to content

Commit

Permalink
SSR support in syncExternalStore (#305)
Browse files Browse the repository at this point in the history
* Use React@18.

* `ssrPath` proof-of-concept implementation.

* Migrate to the latest version of @testing-library/react.
@testing-library/react is no longer needed. `result.all` was removed,
so I've adapted these tests to use a simple render counter instead.

* Prettier.

* Reformat `useLocation` export.

* Deprecation warning in static-location.js

* TS4.1 types for `ssrPath`.

* Update README with newer recipe for SSR.

* Fix SSR section anchor.

* Preview release.

* Allow Router to be properly hydrated with `ssrPath` omitted.

* Update types.

* Update README with hydration example.

* Dev release.
  • Loading branch information
molefrog committed May 10, 2023
1 parent 9ed9488 commit 294a78f
Show file tree
Hide file tree
Showing 14 changed files with 149 additions and 145 deletions.
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;
};

0 comments on commit 294a78f

Please sign in to comment.