From e6e784b3fa491346732e53f0243f265ae9118f18 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 May 2023 09:36:55 +0300 Subject: [PATCH 01/14] Use React@18. --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 8f7ee902..16f4024c 100644 --- a/package.json +++ b/package.json @@ -164,9 +164,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", From 658f613dcca3e8728daa7a2c1cac73bfa444ee2a Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 May 2023 09:37:21 +0300 Subject: [PATCH 02/14] `ssrPath` proof-of-concept implementation. --- index.js | 11 ++++++++++- test/ssr.test.js | 8 ++++---- use-location.js | 9 +++++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/index.js b/index.js index 78971fd4..90fa04db 100644 --- a/index.js +++ b/index.js @@ -26,6 +26,7 @@ import { const defaultRouter = { hook: locationHook, matcher: matcherWithCache(), + ssrPath: "/", base: "", }; @@ -53,11 +54,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 diff --git a/test/ssr.test.js b/test/ssr.test.js index d9dd3880..230f2f84 100644 --- a/test/ssr.test.js +++ b/test/ssr.test.js @@ -11,7 +11,7 @@ import staticLocationHook from "../static-location.js"; describe("server-side rendering", () => { it("works via staticHistory", () => { const App = () => ( - + foo bar {(params) => params.id} @@ -30,7 +30,7 @@ describe("server-side rendering", () => { }; const App = () => ( - + ); @@ -41,7 +41,7 @@ describe("server-side rendering", () => { it("renders valid and accessible link elements", () => { const App = () => ( - + Mark @@ -54,7 +54,7 @@ describe("server-side rendering", () => { it("renders redirects however they have effect only on a client-side", () => { const App = () => ( - + diff --git a/use-location.js b/use-location.js index e411514a..e1f7c6ac 100644 --- a/use-location.js +++ b/use-location.js @@ -37,14 +37,15 @@ const subscribeToLocationUpdates = (callback) => { }; }; -export const useLocationProperty = (fn) => - useSyncExternalStore(subscribeToLocationUpdates, fn); +export const useLocationProperty = (fn, ssrFn) => + useSyncExternalStore(subscribeToLocationUpdates, fn, ssrFn); const currentSearch = () => location.search; export const useSearch = () => useLocationProperty(currentSearch); const currentPathname = () => location.pathname; -export const usePathname = () => useLocationProperty(currentPathname); +export const usePathname = (ssrPath) => + useLocationProperty(currentPathname, () => ssrPath); export const navigate = (to, { replace = false } = {}) => history[replace ? eventReplaceState : eventPushState](null, "", to); @@ -56,7 +57,7 @@ export const navigate = (to, { replace = false } = {}) => // it can be passed down as an element prop without any performance concerns. // (This is achieved via `useEvent`.) export default (opts = {}) => [ - relativePath(opts.base, usePathname()), + relativePath(opts.base, usePathname(opts.ssrPath)), useEvent((to, navOpts) => navigate(absolutePath(to, opts.base), navOpts)), ]; From 235e14d48a35f3a64643fa26ee2684e999b0b968 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 May 2023 10:54:52 +0300 Subject: [PATCH 03/14] 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. --- package.json | 5 ++-- test/static-location.test.js | 2 +- test/use-location.test.js | 49 ++++++++++++++++++++++-------------- test/use-router.test.js | 6 ++--- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 16f4024c..160e2c9a 100644 --- a/package.json +++ b/package.json @@ -152,9 +152,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", diff --git a/test/static-location.test.js b/test/static-location.test.js index f4400f4e..36074f6c 100644 --- a/test/static-location.test.js +++ b/test/static-location.test.js @@ -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", () => { diff --git a/test/use-location.test.js b/test/use-location.test.js index 1672586e..565b33cf 100644 --- a/test/use-location.test.js +++ b/test/use-location.test.js @@ -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]", () => { @@ -63,9 +64,7 @@ describe("`value` first argument", () => { }); it("basepath should be case-insensitive", () => { - const { result, unmount } = renderHook(() => - useLocation({ base: "/MyApp" }) - ); + const { result, unmount } = renderHook(() => useLocation({ base: "/MyApp" })); act(() => history.pushState(null, "", "/myAPP/users/JohnDoe")); expect(result.current[0]).toBe("/users/JohnDoe"); @@ -73,9 +72,7 @@ describe("`value` first argument", () => { }); it("returns an absolute path in case of unmatched base path", () => { - const { result, unmount } = renderHook(() => - useLocation({ base: "/MyApp" }) - ); + const { result, unmount } = renderHook(() => useLocation({ base: "/MyApp" })); act(() => history.pushState(null, "", "/MyOtherApp/users/JohnDoe")); expect(result.current[0]).toBe("~/MyOtherApp/users/JohnDoe"); @@ -83,39 +80,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(); diff --git a/test/use-router.test.js b/test/use-router.test.js index 95958a44..5eb7d892 100644 --- a/test/use-router.test.js +++ b/test/use-router.test.js @@ -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"; @@ -54,9 +54,7 @@ it("shares one router instance between components", () => { ); - const uniqRouters = [ - ...new Set(root.findAllByType("div").map((x) => x.props.router)), - ]; + const uniqRouters = [...new Set(root.findAllByType("div").map((x) => x.props.router))]; expect(uniqRouters.length).toBe(1); }); From 27634ef7ab8ec055b7200536363a2cb37e2563d1 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 May 2023 11:16:10 +0300 Subject: [PATCH 04/14] Prettier. --- test/use-location.test.js | 8 ++++++-- test/use-router.test.js | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/test/use-location.test.js b/test/use-location.test.js index 565b33cf..98b4b028 100644 --- a/test/use-location.test.js +++ b/test/use-location.test.js @@ -64,7 +64,9 @@ describe("`value` first argument", () => { }); it("basepath should be case-insensitive", () => { - const { result, unmount } = renderHook(() => useLocation({ base: "/MyApp" })); + const { result, unmount } = renderHook(() => + useLocation({ base: "/MyApp" }) + ); act(() => history.pushState(null, "", "/myAPP/users/JohnDoe")); expect(result.current[0]).toBe("/users/JohnDoe"); @@ -72,7 +74,9 @@ describe("`value` first argument", () => { }); it("returns an absolute path in case of unmatched base path", () => { - const { result, unmount } = renderHook(() => useLocation({ base: "/MyApp" })); + const { result, unmount } = renderHook(() => + useLocation({ base: "/MyApp" }) + ); act(() => history.pushState(null, "", "/MyOtherApp/users/JohnDoe")); expect(result.current[0]).toBe("~/MyOtherApp/users/JohnDoe"); diff --git a/test/use-router.test.js b/test/use-router.test.js index 5eb7d892..08f85fc3 100644 --- a/test/use-router.test.js +++ b/test/use-router.test.js @@ -54,7 +54,9 @@ it("shares one router instance between components", () => { ); - const uniqRouters = [...new Set(root.findAllByType("div").map((x) => x.props.router))]; + const uniqRouters = [ + ...new Set(root.findAllByType("div").map((x) => x.props.router)), + ]; expect(uniqRouters.length).toBe(1); }); From fa08a49b58879ffa09b8899b689aa8a85eac604d Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 May 2023 11:20:34 +0300 Subject: [PATCH 05/14] Reformat `useLocation` export. --- use-location.js | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/use-location.js b/use-location.js index e1f7c6ac..c39769f9 100644 --- a/use-location.js +++ b/use-location.js @@ -5,12 +5,9 @@ import { useSyncExternalStore, useEvent } from "./react-deps.js"; * If base isn't part of the path provided returns absolute path e.g. `~/app` */ const relativePath = (base = "", path = location.pathname) => - !path.toLowerCase().indexOf(base.toLowerCase()) - ? path.slice(base.length) || "/" - : "~" + path; + !path.toLowerCase().indexOf(base.toLowerCase()) ? path.slice(base.length) || "/" : "~" + path; -const absolutePath = (to, base = "") => - to[0] === "~" ? to.slice(1) : base + to; +const absolutePath = (to, base = "") => (to[0] === "~" ? to.slice(1) : base + to); /** * History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History @@ -19,12 +16,7 @@ const eventPopstate = "popstate"; const eventPushState = "pushState"; const eventReplaceState = "replaceState"; const eventHashchange = "hashchange"; -export const events = [ - eventPopstate, - eventPushState, - eventReplaceState, - eventHashchange, -]; +export const events = [eventPopstate, eventPushState, eventReplaceState, eventHashchange]; const subscribeToLocationUpdates = (callback) => { for (const event of events) { @@ -44,8 +36,7 @@ const currentSearch = () => location.search; export const useSearch = () => useLocationProperty(currentSearch); const currentPathname = () => location.pathname; -export const usePathname = (ssrPath) => - useLocationProperty(currentPathname, () => ssrPath); +export const usePathname = (ssrPath) => useLocationProperty(currentPathname, () => ssrPath); export const navigate = (to, { replace = false } = {}) => history[replace ? eventReplaceState : eventPushState](null, "", to); @@ -56,11 +47,13 @@ export const navigate = (to, { replace = false } = {}) => // the function reference should stay the same between re-renders, so that // it can be passed down as an element prop without any performance concerns. // (This is achieved via `useEvent`.) -export default (opts = {}) => [ +const useLocation = (opts = {}) => [ relativePath(opts.base, usePathname(opts.ssrPath)), useEvent((to, navOpts) => navigate(absolutePath(to, opts.base), navOpts)), ]; +export default useLocation; + // While History API does have `popstate` event, the only // proper way to listen to changes via `push/replaceState` // is to monkey-patch these methods. From 67a9caa780de0fa48d37f7d976532646037f4600 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 May 2023 11:24:56 +0300 Subject: [PATCH 06/14] Deprecation warning in static-location.js --- static-location.js | 13 ++++++++----- use-location.js | 17 +++++++++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/static-location.js b/static-location.js index 2f862135..c2045df0 100644 --- a/static-location.js +++ b/static-location.js @@ -2,17 +2,20 @@ // that will also pull in react, use-sync-external-store, and then // monkeypatch `history` *again* in the generated build files! const relativePath = (base = "", path = location.pathname) => - !path.toLowerCase().indexOf(base.toLowerCase()) - ? path.slice(base.length) || "/" - : "~" + path; + !path.toLowerCase().indexOf(base.toLowerCase()) ? path.slice(base.length) || "/" : "~" + path; -const absolutePath = (to, base = "") => - to[0] === "~" ? to.slice(1) : base + to; +const absolutePath = (to, base = "") => (to[0] === "~" ? to.slice(1) : base + to); // Generates static `useLocation` hook. The hook always // 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 " + + "`` component." + ); + const hook = ({ base = "" } = {}) => [ relativePath(base, path), (to, { replace } = {}) => { diff --git a/use-location.js b/use-location.js index c39769f9..d9fe0a28 100644 --- a/use-location.js +++ b/use-location.js @@ -5,9 +5,12 @@ import { useSyncExternalStore, useEvent } from "./react-deps.js"; * If base isn't part of the path provided returns absolute path e.g. `~/app` */ const relativePath = (base = "", path = location.pathname) => - !path.toLowerCase().indexOf(base.toLowerCase()) ? path.slice(base.length) || "/" : "~" + path; + !path.toLowerCase().indexOf(base.toLowerCase()) + ? path.slice(base.length) || "/" + : "~" + path; -const absolutePath = (to, base = "") => (to[0] === "~" ? to.slice(1) : base + to); +const absolutePath = (to, base = "") => + to[0] === "~" ? to.slice(1) : base + to; /** * History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History @@ -16,7 +19,12 @@ const eventPopstate = "popstate"; const eventPushState = "pushState"; const eventReplaceState = "replaceState"; const eventHashchange = "hashchange"; -export const events = [eventPopstate, eventPushState, eventReplaceState, eventHashchange]; +export const events = [ + eventPopstate, + eventPushState, + eventReplaceState, + eventHashchange, +]; const subscribeToLocationUpdates = (callback) => { for (const event of events) { @@ -36,7 +44,8 @@ const currentSearch = () => location.search; export const useSearch = () => useLocationProperty(currentSearch); const currentPathname = () => location.pathname; -export const usePathname = (ssrPath) => useLocationProperty(currentPathname, () => ssrPath); +export const usePathname = (ssrPath) => + useLocationProperty(currentPathname, () => ssrPath); export const navigate = (to, { replace = false } = {}) => history[replace ? eventReplaceState : eventPushState](null, "", to); From e55aabc3568848484394bdeff42feccc599cbafd Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 1 May 2023 12:50:50 +0300 Subject: [PATCH 07/14] TS4.1 types for `ssrPath`. --- types/router.d.ts | 4 ++- types/ts4.1/index.d.ts | 74 +++++++++++++++----------------------- types/ts4.1/type-specs.tsx | 4 +++ 3 files changed, 35 insertions(+), 47 deletions(-) diff --git a/types/router.d.ts b/types/router.d.ts index 13067be0..61790de0 100644 --- a/types/router.d.ts +++ b/types/router.d.ts @@ -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 @@ -16,4 +17,5 @@ export type RouterOptions = { base?: Path; matcher?: MatcherFn; parent?: RouterObject; -} + ssrPath?: Path; +}; diff --git a/types/ts4.1/index.d.ts b/types/ts4.1/index.d.ts index 033a3e9f..a3943455 100644 --- a/types/ts4.1/index.d.ts +++ b/types/ts4.1/index.d.ts @@ -31,36 +31,27 @@ export * from "../router"; // React <18 only: fixes incorrect `ReactNode` declaration that had `{}` in the union. // This issue has been fixed in React 18 type declaration. // https://github.com/DefinitelyTyped/DefinitelyTyped/pull/56210 -type ReactNode = - | ReactChild - | Iterable - | ReactPortal - | boolean - | null - | undefined; - -export type ExtractRouteOptionalParam = - PathType extends `${infer Param}?` - ? { readonly [k in Param]: string | undefined } - : PathType extends `${infer Param}*` - ? { readonly [k in Param]: string | undefined } - : PathType extends `${infer Param}+` - ? { readonly [k in Param]: string } - : { readonly [k in PathType]: string }; - -export type ExtractRouteParams = - string extends PathType - ? DefaultParams - : PathType extends `${infer _Start}:${infer ParamWithOptionalRegExp}/${infer Rest}` - ? ParamWithOptionalRegExp extends `${infer Param}(${infer _RegExp})` - ? ExtractRouteOptionalParam & ExtractRouteParams - : ExtractRouteOptionalParam & - ExtractRouteParams - : PathType extends `${infer _Start}:${infer ParamWithOptionalRegExp}` - ? ParamWithOptionalRegExp extends `${infer Param}(${infer _RegExp})` - ? ExtractRouteOptionalParam - : ExtractRouteOptionalParam - : {}; +type ReactNode = ReactChild | Iterable | ReactPortal | boolean | null | undefined; + +export type ExtractRouteOptionalParam = PathType extends `${infer Param}?` + ? { readonly [k in Param]: string | undefined } + : PathType extends `${infer Param}*` + ? { readonly [k in Param]: string | undefined } + : PathType extends `${infer Param}+` + ? { readonly [k in Param]: string } + : { readonly [k in PathType]: string }; + +export type ExtractRouteParams = string extends PathType + ? DefaultParams + : PathType extends `${infer _Start}:${infer ParamWithOptionalRegExp}/${infer Rest}` + ? ParamWithOptionalRegExp extends `${infer Param}(${infer _RegExp})` + ? ExtractRouteOptionalParam & ExtractRouteParams + : ExtractRouteOptionalParam & ExtractRouteParams + : PathType extends `${infer _Start}:${infer ParamWithOptionalRegExp}` + ? ParamWithOptionalRegExp extends `${infer Param}(${infer _RegExp})` + ? ExtractRouteOptionalParam + : ExtractRouteOptionalParam + : {}; /* * Components: @@ -75,15 +66,11 @@ export interface RouteProps< RoutePath extends Path = Path > { children?: - | (( - params: T extends DefaultParams ? T : ExtractRouteParams - ) => ReactNode) + | ((params: T extends DefaultParams ? T : ExtractRouteParams) => ReactNode) | ReactNode; path?: RoutePath; component?: ComponentType< - RouteComponentProps< - T extends DefaultParams ? T : ExtractRouteParams - > + RouteComponentProps> >; } @@ -108,10 +95,9 @@ export type LinkProps = Omit< > & NavigationalProps; -export type RedirectProps = - NavigationalProps & { - children?: never; - }; +export type RedirectProps = NavigationalProps & { + children?: never; +}; export function Redirect( props: PropsWithChildren>, @@ -152,12 +138,8 @@ export function useRouter(): RouterObject; export function useRoute< T extends DefaultParams | undefined = undefined, RoutePath extends Path = Path ->( - pattern: RoutePath -): Match>; +>(pattern: RoutePath): Match>; -export function useLocation< - H extends BaseLocationHook = LocationHook ->(): HookReturnValue; +export function useLocation(): HookReturnValue; // tslint:enable:no-unnecessary-generics diff --git a/types/ts4.1/type-specs.tsx b/types/ts4.1/type-specs.tsx index 55eb0835..84c9aeea 100644 --- a/types/ts4.1/type-specs.tsx +++ b/types/ts4.1/type-specs.tsx @@ -222,12 +222,16 @@ const useNetwork: UseNetworkLocation = (() => {}) as UseNetworkLocation; const parentRouter = { base: "/app", + ownBase: "/app", + ssrPath: "/", matcher: (() => null) as unknown as MatcherFn, hook: useLocation, }; Parent router is inherited; +SSR; + /* * Hooks API */ From fcbdbda25897658a345342ac5f441b4a8ec9556a Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Wed, 3 May 2023 14:59:23 +0300 Subject: [PATCH 08/14] Update README with newer recipe for SSR. --- README.md | 69 ++++++++++++++----------------------------------------- 1 file changed, 17 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 8bb0f63a..e4f64167 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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); @@ -339,20 +340,18 @@ import { Route, Switch } from "wouter"; - + {/* 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. */} - - This is rendered when nothing above has matched - + This is rendered when nothing above has matched ; ``` -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`. ### `` @@ -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? @@ -689,27 +689,21 @@ 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). Once hydrated, your app will start +using browser location as usual. ```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 + // If you omit `ssrPath` prop or remove the top-level Router, SSR will + // still work, but it will always render the app using root "/" location const prerendered = renderToString( - + ); @@ -718,35 +712,6 @@ 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: - -```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( - - - - ); - - // location.history is an array matching the history a - // user's browser would capture after loading the page - - const finalPage = locationHook.history.slice(-1)[0]; - if (finalPage !== req.path) { - // perform redirect - } -}; -``` - ### 1KB is too much, I can't afford it! We've got some great news for you! If you're a minimalist bundle-size nomad and you need a damn From 6ef6d23b1fa109151ad46f14c58832660e54ed5a Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Wed, 3 May 2023 16:31:51 +0300 Subject: [PATCH 09/14] Fix SSR section anchor. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e4f64167..c8c8aec8 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ projects that use wouter: **[Ultra](https://ultrajs.dev/)**, - [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 From 2e5eaf77e388b01a45a0e46b08f65b50abcdef6a Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Wed, 3 May 2023 16:32:04 +0300 Subject: [PATCH 10/14] Preview release. --- package.json | 2 +- static-location.js | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 160e2c9a..cb901332 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wouter", - "version": "2.10.1", + "version": "2.11.0-dev.0", "description": "A minimalistic routing for React and Preact. Nothing extra, just HOOKS.", "keywords": [ "react", diff --git a/static-location.js b/static-location.js index c2045df0..5f6006e0 100644 --- a/static-location.js +++ b/static-location.js @@ -2,9 +2,12 @@ // that will also pull in react, use-sync-external-store, and then // monkeypatch `history` *again* in the generated build files! const relativePath = (base = "", path = location.pathname) => - !path.toLowerCase().indexOf(base.toLowerCase()) ? path.slice(base.length) || "/" : "~" + path; + !path.toLowerCase().indexOf(base.toLowerCase()) + ? path.slice(base.length) || "/" + : "~" + path; -const absolutePath = (to, base = "") => (to[0] === "~" ? to.slice(1) : base + to); +const absolutePath = (to, base = "") => + to[0] === "~" ? to.slice(1) : base + to; // Generates static `useLocation` hook. The hook always // responds with initial path provided. From bfde79d3741485fc0fd0da620ddd5cd8dc260ba4 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 8 May 2023 16:05:03 +0300 Subject: [PATCH 11/14] Allow Router to be properly hydrated with `ssrPath` omitted. --- index.js | 24 +++++------------------- use-location.js | 21 +++++++-------------- 2 files changed, 12 insertions(+), 33 deletions(-) diff --git a/index.js b/index.js index 90fa04db..1030e89f 100644 --- a/index.js +++ b/index.js @@ -26,8 +26,9 @@ import { const defaultRouter = { hook: locationHook, matcher: matcherWithCache(), - ssrPath: "/", base: "", + // this option is used to override the current location during SSR + // ssrPath: undefined, }; const RouterCtx = createContext(defaultRouter); @@ -54,14 +55,7 @@ export const useRoute = (pattern) => { * Part 2, Low Carb Router API: Router, Route, Link, Switch */ -export const Router = ({ - hook, - matcher, - ssrPath, - 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; @@ -123,13 +117,7 @@ export const Link = forwardRef((props, ref) => { const handleClick = useEvent((event) => { // ignores the navigation when clicked using right mouse button or // by holding a special modifier key: ctrl, command, win, alt, shift - if ( - event.ctrlKey || - event.metaKey || - event.altKey || - event.shiftKey || - event.button !== 0 - ) + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey || event.button !== 0) return; onClick && onClick(event); @@ -156,9 +144,7 @@ const flattenChildren = (children) => { return Array.isArray(children) ? [].concat( ...children.map((c) => - c && c.type === Fragment - ? flattenChildren(c.props.children) - : flattenChildren(c) + c && c.type === Fragment ? flattenChildren(c.props.children) : flattenChildren(c) ) ) : [children]; diff --git a/use-location.js b/use-location.js index d9fe0a28..1d3fd74d 100644 --- a/use-location.js +++ b/use-location.js @@ -5,12 +5,9 @@ import { useSyncExternalStore, useEvent } from "./react-deps.js"; * If base isn't part of the path provided returns absolute path e.g. `~/app` */ const relativePath = (base = "", path = location.pathname) => - !path.toLowerCase().indexOf(base.toLowerCase()) - ? path.slice(base.length) || "/" - : "~" + path; + !path.toLowerCase().indexOf(base.toLowerCase()) ? path.slice(base.length) || "/" : "~" + path; -const absolutePath = (to, base = "") => - to[0] === "~" ? to.slice(1) : base + to; +const absolutePath = (to, base = "") => (to[0] === "~" ? to.slice(1) : base + to); /** * History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History @@ -19,12 +16,7 @@ const eventPopstate = "popstate"; const eventPushState = "pushState"; const eventReplaceState = "replaceState"; const eventHashchange = "hashchange"; -export const events = [ - eventPopstate, - eventPushState, - eventReplaceState, - eventHashchange, -]; +export const events = [eventPopstate, eventPushState, eventReplaceState, eventHashchange]; const subscribeToLocationUpdates = (callback) => { for (const event of events) { @@ -44,8 +36,9 @@ const currentSearch = () => location.search; export const useSearch = () => useLocationProperty(currentSearch); const currentPathname = () => location.pathname; -export const usePathname = (ssrPath) => - useLocationProperty(currentPathname, () => ssrPath); + +export const usePathname = ({ ssrPath } = {}) => + useLocationProperty(currentPathname, ssrPath ? () => ssrPath : currentPathname); export const navigate = (to, { replace = false } = {}) => history[replace ? eventReplaceState : eventPushState](null, "", to); @@ -57,7 +50,7 @@ export const navigate = (to, { replace = false } = {}) => // it can be passed down as an element prop without any performance concerns. // (This is achieved via `useEvent`.) const useLocation = (opts = {}) => [ - relativePath(opts.base, usePathname(opts.ssrPath)), + relativePath(opts.base, usePathname(opts)), useEvent((to, navOpts) => navigate(absolutePath(to, opts.base), navOpts)), ]; From 10e5a8782ff06a861dc53e7f5bd8bc49446121ef Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 8 May 2023 16:13:35 +0300 Subject: [PATCH 12/14] Update types. --- index.js | 21 ++++++++++++++++++--- preact/types/use-location.d.ts | 18 +++++++++--------- types/router.d.ts | 2 +- types/use-location.d.ts | 18 +++++++++--------- use-location.js | 19 +++++++++++++++---- 5 files changed, 52 insertions(+), 26 deletions(-) diff --git a/index.js b/index.js index 1030e89f..6eb487ae 100644 --- a/index.js +++ b/index.js @@ -55,7 +55,14 @@ export const useRoute = (pattern) => { * Part 2, Low Carb Router API: Router, Route, Link, Switch */ -export const Router = ({ hook, matcher, ssrPath, 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; @@ -117,7 +124,13 @@ export const Link = forwardRef((props, ref) => { const handleClick = useEvent((event) => { // ignores the navigation when clicked using right mouse button or // by holding a special modifier key: ctrl, command, win, alt, shift - if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey || event.button !== 0) + if ( + event.ctrlKey || + event.metaKey || + event.altKey || + event.shiftKey || + event.button !== 0 + ) return; onClick && onClick(event); @@ -144,7 +157,9 @@ const flattenChildren = (children) => { return Array.isArray(children) ? [].concat( ...children.map((c) => - c && c.type === Fragment ? flattenChildren(c.props.children) : flattenChildren(c) + c && c.type === Fragment + ? flattenChildren(c.props.children) + : flattenChildren(c) ) ) : [children]; diff --git a/preact/types/use-location.d.ts b/preact/types/use-location.d.ts index a7298e31..96c6ec2b 100644 --- a/preact/types/use-location.d.ts +++ b/preact/types/use-location.d.ts @@ -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 @@ -18,22 +16,24 @@ export type BaseLocationHook = ( export type HookReturnValue = ReturnType; // Returns the type of the navigation options that hook's push function accepts. -export type HookNavigationOptions = HookReturnValue< - H ->[1] extends (path: Path, options: infer R, ...rest: any[]) => any +export type HookNavigationOptions = HookReturnValue[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: (fn: () => S) => S; +export const useLocationProperty: (fn: () => S, ssrFn?: () => S) => S; export const useSearch: () => string; -export const usePathname: () => Path; +export const usePathname: (options?: { ssrPath?: string }) => Path; -export const navigate: (to: string | URL, options?: { replace?: boolean }) => void +export const navigate: (to: string | URL, options?: { replace?: boolean }) => void; /* * Default `useLocation` diff --git a/types/router.d.ts b/types/router.d.ts index 61790de0..0dd328bc 100644 --- a/types/router.d.ts +++ b/types/router.d.ts @@ -8,7 +8,7 @@ export interface RouterObject { readonly ownBase: Path; readonly matcher: MatcherFn; readonly parent?: RouterObject; - readonly ssrPath: Path; + readonly ssrPath?: Path; } // basic options to construct a router diff --git a/types/use-location.d.ts b/types/use-location.d.ts index a7298e31..c3b7e2ae 100644 --- a/types/use-location.d.ts +++ b/types/use-location.d.ts @@ -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 @@ -18,22 +16,24 @@ export type BaseLocationHook = ( export type HookReturnValue = ReturnType; // Returns the type of the navigation options that hook's push function accepts. -export type HookNavigationOptions = HookReturnValue< - H ->[1] extends (path: Path, options: infer R, ...rest: any[]) => any +export type HookNavigationOptions = HookReturnValue[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: (fn: () => S) => S; +export const useLocationProperty: (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` diff --git a/use-location.js b/use-location.js index 1d3fd74d..57fd278f 100644 --- a/use-location.js +++ b/use-location.js @@ -5,9 +5,12 @@ import { useSyncExternalStore, useEvent } from "./react-deps.js"; * If base isn't part of the path provided returns absolute path e.g. `~/app` */ const relativePath = (base = "", path = location.pathname) => - !path.toLowerCase().indexOf(base.toLowerCase()) ? path.slice(base.length) || "/" : "~" + path; + !path.toLowerCase().indexOf(base.toLowerCase()) + ? path.slice(base.length) || "/" + : "~" + path; -const absolutePath = (to, base = "") => (to[0] === "~" ? to.slice(1) : base + to); +const absolutePath = (to, base = "") => + to[0] === "~" ? to.slice(1) : base + to; /** * History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History @@ -16,7 +19,12 @@ const eventPopstate = "popstate"; const eventPushState = "pushState"; const eventReplaceState = "replaceState"; const eventHashchange = "hashchange"; -export const events = [eventPopstate, eventPushState, eventReplaceState, eventHashchange]; +export const events = [ + eventPopstate, + eventPushState, + eventReplaceState, + eventHashchange, +]; const subscribeToLocationUpdates = (callback) => { for (const event of events) { @@ -38,7 +46,10 @@ export const useSearch = () => useLocationProperty(currentSearch); const currentPathname = () => location.pathname; export const usePathname = ({ ssrPath } = {}) => - useLocationProperty(currentPathname, ssrPath ? () => ssrPath : currentPathname); + useLocationProperty( + currentPathname, + ssrPath ? () => ssrPath : currentPathname + ); export const navigate = (to, { replace = false } = {}) => history[replace ? eventReplaceState : eventPushState](null, "", to); From 6c7b13ead7e285908d0404e1e92ccd5a0908cfa6 Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 8 May 2023 16:25:27 +0300 Subject: [PATCH 13/14] Update README with hydration example. --- README.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c8c8aec8..b65ef3db 100644 --- a/README.md +++ b/README.md @@ -692,16 +692,14 @@ You might need to ensure you have the latest version of ### Server-side Rendering support (SSR)? 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). Once hydrated, your app will start -using browser location as usual. +specify `ssrPath` prop (usually, derived from current request). ```js import { renderToString } from "react-dom/server"; import { Router } from "wouter"; const handleRequest = (req, res) => { - // If you omit `ssrPath` prop or remove the top-level Router, SSR will - // still work, but it will always render the app using root "/" location + // top-level Router is mandatory in SSR mode const prerendered = renderToString( @@ -712,6 +710,22 @@ const handleRequest = (req, res) => { }; ``` +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 { hydrateRoot } from "react-dom/server"; + +const root = hydrateRoot( + domNode, + // during hydration `ssrPath` is set to `location.pathname` + + + +); +``` + ### 1KB is too much, I can't afford it! We've got some great news for you! If you're a minimalist bundle-size nomad and you need a damn From b0220d8e207c87293b31786540c795937afe53eb Mon Sep 17 00:00:00 2001 From: Alexey Taktarov Date: Mon, 8 May 2023 21:00:52 +0300 Subject: [PATCH 14/14] Dev release. --- package.json | 2 +- preact/types/use-location.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cb901332..0f488e84 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wouter", - "version": "2.11.0-dev.0", + "version": "2.11.0-dev.1", "description": "A minimalistic routing for React and Preact. Nothing extra, just HOOKS.", "keywords": [ "react", diff --git a/preact/types/use-location.d.ts b/preact/types/use-location.d.ts index 96c6ec2b..c3b7e2ae 100644 --- a/preact/types/use-location.d.ts +++ b/preact/types/use-location.d.ts @@ -31,7 +31,7 @@ export const useLocationProperty: (fn: () => S, ssrFn?: () export const useSearch: () => string; -export const usePathname: (options?: { ssrPath?: string }) => Path; +export const usePathname: (options?: { ssrPath?: Path }) => Path; export const navigate: (to: string | URL, options?: { replace?: boolean }) => void;