diff --git a/README.md b/README.md index dbe36dc..aa24365 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ This package has the following [peer dependencies](https://docs.npmjs.com/files/ With [npm](https://www.npmjs.com): ```shell -$ npm install react-router-guards +npm install react-router-guards ``` With [yarn](https://yarnpkg.com/): ```shell -$ yarn add react-router-guards +yarn add react-router-guards ``` Then with a module bundler like [webpack](https://webpack.github.io/), use as you would anything else: @@ -56,38 +56,55 @@ const GuardedRoute = require('react-router-guards').GuardedRoute; ## Basic usage -Here is a very basic example of how to use React Router Guards. +Here is a basic example of how to use React Router Guards. ```jsx -import React from 'react'; +// src/pages/ProjectDetail.js +import { useGuardData } from 'react-router-guards'; + +export function ProjectDetail() { + const { project } = useGuardData(); + + return ( +
+

{project.title}

+
+ ); +} + +export async function getProjectDetailData(ctx, next) { + const { id } = ctx.to.match.params; + const project = await api.projects.get(id); + return next.data({ project }); +} +``` + +```jsx +// src/app.js import { BrowserRouter } from 'react-router-dom'; import { GuardProvider, GuardedRoute } from 'react-router-guards'; -import { About, Home, Loading, Login, NotFound } from 'pages'; -import { getIsLoggedIn } from 'utils'; - -const requireLogin = (to, from, next) => { - if (to.meta.auth) { - if (getIsLoggedIn()) { - next(); - } - next.redirect('/login'); - } else { - next(); - } -}; - -const App = () => ( - - - - - - - - - - -); +import { Home, Loading, NotFound } from './pages'; +import { ProjectDetail, getProjectDetailData } from './pages/ProjectDetail'; +import { api } from './utils'; + +function App() { + return ( + + + + + + + + + + ); +} ``` Check out our [demos](#demos) for more examples! diff --git a/demos/intermediate/package-lock.json b/demos/intermediate/package-lock.json index 5bec88c..1e5ce99 100644 --- a/demos/intermediate/package-lock.json +++ b/demos/intermediate/package-lock.json @@ -800,11 +800,18 @@ } }, "@babel/runtime": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.4.5.tgz", - "integrity": "sha512-TuI4qpWZP6lGOGIuGWtp9sPluqYICmbk8T/1vpSysqJxRPkudh/ofFWyqdcMsDf2s7KvDL4/YHgKyvcS3g9CJQ==", + "version": "7.15.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz", + "integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==", "requires": { - "regenerator-runtime": "^0.13.2" + "regenerator-runtime": "^0.13.4" + }, + "dependencies": { + "regenerator-runtime": { + "version": "0.13.9", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", + "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" + } } }, "@babel/template": { @@ -904,6 +911,12 @@ "@types/node": "*" } }, + "@types/history": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz", + "integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==", + "dev": true + }, "@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -922,6 +935,50 @@ "integrity": "sha512-LcsGbPomWsad6wmMNv7nBLw7YYYyfdYcz6xryKYQhx89c3XXan+8Q6AJ43G5XDIaklaVkK3mE4fCb0SBvMiPSQ==", "dev": true }, + "@types/prop-types": { + "version": "15.7.4", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz", + "integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==", + "dev": true + }, + "@types/react": { + "version": "17.0.21", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.21.tgz", + "integrity": "sha512-GzzXCpOthOjXvrAUFQwU/svyxu658cwu00Q9ugujS4qc1zXgLFaO0kS2SLOaMWLt2Jik781yuHCWB7UcYdGAeQ==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-router": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.16.tgz", + "integrity": "sha512-8d7nR/fNSqlTFGHti0R3F9WwIertOaaA1UEB8/jr5l5mDMOs4CidEgvvYMw4ivqrBK+vtVLxyTj2P+Pr/dtgzg==", + "dev": true, + "requires": { + "@types/history": "*", + "@types/react": "*" + } + }, + "@types/react-router-dom": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.1.9.tgz", + "integrity": "sha512-Go0vxZSigXTyXx8xPkGiBrrc3YbBs82KE14WENMLS6TSUKcRFSmYVbL19zFOnNFqJhqrPqEs2h5eUpJhSRrwZw==", + "dev": true, + "requires": { + "@types/history": "*", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "@types/scheduler": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", + "dev": true + }, "@webassemblyjs/ast": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.8.5.tgz", @@ -2325,6 +2382,12 @@ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true }, + "csstype": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz", + "integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==", + "dev": true + }, "cyclist": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", @@ -3591,11 +3654,6 @@ "integrity": "sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg==", "dev": true }, - "gud": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz", - "integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw==" - }, "handle-thing": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.0.tgz", @@ -3711,16 +3769,16 @@ "dev": true }, "history": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/history/-/history-4.9.0.tgz", - "integrity": "sha512-H2DkjCjXf0Op9OAr6nJ56fcRkTSNrUiv41vNJ6IswJjif6wlpZK0BTfFbi7qK9dXLSYZxkq5lBsj3vUjlYBYZA==", + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", "requires": { "@babel/runtime": "^7.1.2", "loose-envify": "^1.2.0", - "resolve-pathname": "^2.2.0", + "resolve-pathname": "^3.0.0", "tiny-invariant": "^1.0.2", "tiny-warning": "^1.0.0", - "value-equal": "^0.4.0" + "value-equal": "^1.0.1" } }, "hmac-drbg": { @@ -3735,9 +3793,9 @@ } }, "hoist-non-react-statics": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz", - "integrity": "sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", "requires": { "react-is": "^16.7.0" } @@ -4649,13 +4707,12 @@ "dev": true }, "mini-create-react-context": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.3.2.tgz", - "integrity": "sha512-2v+OeetEyliMt5VHMXsBhABoJ0/M4RCe7fatd/fBy6SMiKazUSEt3gxxypfnk2SHMkdBYvorHRoQxuGoiwbzAw==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz", + "integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==", "requires": { - "@babel/runtime": "^7.4.0", - "gud": "^1.0.0", - "tiny-warning": "^1.0.2" + "@babel/runtime": "^7.12.1", + "tiny-warning": "^1.0.3" } }, "minimalistic-assert": { @@ -5276,9 +5333,9 @@ "dev": true }, "path-to-regexp": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", - "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", "requires": { "isarray": "0.0.1" } @@ -5696,15 +5753,15 @@ "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==" }, "react-router": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.0.1.tgz", - "integrity": "sha512-EM7suCPNKb1NxcTZ2LEOWFtQBQRQXecLxVpdsP4DW4PbbqYWeRiLyV/Tt1SdCrvT2jcyXAXmVTmzvSzrPR63Bg==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz", + "integrity": "sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ==", "requires": { - "@babel/runtime": "^7.1.2", + "@babel/runtime": "^7.12.13", "history": "^4.9.0", "hoist-non-react-statics": "^3.1.0", "loose-envify": "^1.3.1", - "mini-create-react-context": "^0.3.0", + "mini-create-react-context": "^0.4.0", "path-to-regexp": "^1.7.0", "prop-types": "^15.6.2", "react-is": "^16.6.0", @@ -5713,15 +5770,15 @@ } }, "react-router-dom": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.0.1.tgz", - "integrity": "sha512-zaVHSy7NN0G91/Bz9GD4owex5+eop+KvgbxXsP/O+iW1/Ln+BrJ8QiIR5a6xNPtrdTvLkxqlDClx13QO1uB8CA==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.0.tgz", + "integrity": "sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ==", "requires": { - "@babel/runtime": "^7.1.2", + "@babel/runtime": "^7.12.13", "history": "^4.9.0", "loose-envify": "^1.3.1", "prop-types": "^15.6.2", - "react-router": "5.0.1", + "react-router": "5.2.1", "tiny-invariant": "^1.0.2", "tiny-warning": "^1.0.0" } @@ -5794,7 +5851,8 @@ "regenerator-runtime": { "version": "0.13.2", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.2.tgz", - "integrity": "sha512-S/TQAZJO+D3m9xeN1WTI8dLKBBiRgXBlTJvbWjCThHWZj9EvHK70Ff50/tYj2J/fvBY6JtFVwRuazHN2E7M9BA==" + "integrity": "sha512-S/TQAZJO+D3m9xeN1WTI8dLKBBiRgXBlTJvbWjCThHWZj9EvHK70Ff50/tYj2J/fvBY6JtFVwRuazHN2E7M9BA==", + "dev": true }, "regenerator-transform": { "version": "0.14.0", @@ -5980,9 +6038,9 @@ "dev": true }, "resolve-pathname": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-2.2.0.tgz", - "integrity": "sha512-bAFz9ld18RzJfddgrO2e/0S2O81710++chRMUxHjXOYKF6jTAMrUNZrEZ1PvV0zlhfjidm08iRPdTLPno1FuRg==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==" }, "resolve-url": { "version": "0.2.1", @@ -7007,14 +7065,14 @@ } }, "tiny-invariant": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.0.4.tgz", - "integrity": "sha512-lMhRd/djQJ3MoaHEBrw8e2/uM4rs9YMNk0iOr8rHQ0QdbM7D4l0gFl3szKdeixrlyfm9Zqi4dxHCM2qVG8ND5g==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz", + "integrity": "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==" }, "tiny-warning": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.2.tgz", - "integrity": "sha512-rru86D9CpQRLvsFG5XFdy0KdLAvjdQDyZCsRcuu60WtzFylDM3eAWSxEVz5kzL2Gp544XiUvPbVKtOA/txLi9Q==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" }, "tinycolor2": { "version": "1.4.1", @@ -7451,9 +7509,9 @@ "dev": true }, "value-equal": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-0.4.0.tgz", - "integrity": "sha512-x+cYdNnaA3CxvMaTX0INdTCN8m8aF2uY9BvEqmxuYp8bL09cs/kWVQPVGcA35fMktdOsP69IgU7wFj/61dJHEw==" + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" }, "vary": { "version": "1.1.2", diff --git a/demos/intermediate/package.json b/demos/intermediate/package.json index ebfaf74..d715d36 100644 --- a/demos/intermediate/package.json +++ b/demos/intermediate/package.json @@ -5,7 +5,7 @@ "dependencies": { "react": "^16.8.6", "react-dom": "^16.8.6", - "react-router-dom": "^5.0.1", + "react-router-dom": "^5.3.0", "react-router-guards": "^1.0.2", "react-waypoint": "^9.0.2", "rgbaster": "^2.1.1", @@ -22,6 +22,7 @@ "@babel/preset-env": "^7.4.5", "@babel/preset-react": "^7.0.0", "@types/babel__core": "^7.1.2", + "@types/react-router-dom": "^5.1.9", "babel-loader": "^8.0.6", "chalk": "^2.4.2", "copy-webpack-plugin": "^5.0.3", diff --git a/demos/intermediate/src/containers/Detail/detail.module.scss b/demos/intermediate/src/containers/Detail/detail.module.scss index c9f1e46..1eb412a 100644 --- a/demos/intermediate/src/containers/Detail/detail.module.scss +++ b/demos/intermediate/src/containers/Detail/detail.module.scss @@ -39,13 +39,30 @@ .types { display: flex; - margin-bottom: $spacing--xl; + margin-bottom: $spacing--md; } .types-item { margin-right: $spacing--sm; } +.links { + display: flex; + margin-bottom: $spacing--lg; +} + +.links-item { + &:not(:last-child) { + margin-right: $spacing--md; + } +} + +.content { + display: flex; + flex: 1; + flex-direction: column; +} + .physique { display: flex; flex-direction: column; diff --git a/demos/intermediate/src/containers/Detail/index.tsx b/demos/intermediate/src/containers/Detail/index.tsx index 1124c9a..478e32d 100644 --- a/demos/intermediate/src/containers/Detail/index.tsx +++ b/demos/intermediate/src/containers/Detail/index.tsx @@ -1,16 +1,19 @@ import React, { useCallback } from 'react'; -import { GuardFunction } from 'react-router-guards'; -import { LabeledSection, Recirculation, SpriteList, StatChart, Type } from 'components'; +import { Switch, useRouteMatch, Redirect } from 'react-router-dom'; +import { GuardFunction, GuardedRoute, useGuardData } from 'react-router-guards'; +import { LabeledSection, Recirculation, SpriteList, StatChart, Type, Link } from 'components'; +import { waitOneSecond } from 'router/guards'; import { MoveLearnType, SerializedPokemon } from 'types'; import { api, className, serializePokemon } from 'utils'; import styles from './detail.module.scss'; -interface Props { +interface DetailGuardData { pokemon: SerializedPokemon; } -const Detail: React.FunctionComponent = ({ - pokemon: { +const Detail: React.FunctionComponent = () => { + const data = useGuardData(); + const { abilities, baseExperience, entryNumber, @@ -22,8 +25,8 @@ const Detail: React.FunctionComponent = ({ sprites, types, weight, - }, -}) => { + } = data.pokemon; + const renderMoveList = useCallback( (type: MoveLearnType, renderLevel: boolean = false) => (
    @@ -37,6 +40,8 @@ const Detail: React.FunctionComponent = ({ [moves], ); + const { path, url } = useRouteMatch(); + return (
    @@ -52,76 +57,106 @@ const Detail: React.FunctionComponent = ({ ))}
-
- -
    -
  • {height.metric}
  • -
  • {height.imperial}
  • -
-
- -
    -
  • {weight.metric}
  • -
  • {weight.imperial}
  • -
-
-
- -
    - {abilities.map(({ isHidden, name }) => ( -
  • - {name} - {isHidden && Hidden Ability} -
  • - ))} -
-
-
-

Statistics

- -

{baseExperience} XP

-
- - - -
-
-

Moves

- {moves[MoveLearnType.LevelUp].length > 0 && ( - -
    -
  • - - -
  • - {moves[MoveLearnType.LevelUp].map(({ name, level }) => ( -
  • -

    {level}

    -

    {name}

    -
  • - ))} -
-
- )} - {moves[MoveLearnType.Egg].length > 0 && ( - - {renderMoveList(MoveLearnType.Egg)} - - )} - {moves[MoveLearnType.Machine].length > 0 && ( - - {renderMoveList(MoveLearnType.Machine)} - - )} - {moves[MoveLearnType.Tutor].length > 0 && ( - - {renderMoveList(MoveLearnType.Tutor)} - - )} -
+ +
    +
  • + Physique +
  • +
  • + Statistics +
  • +
  • + Moves +
  • +
+ +
+ + +
+ +
    +
  • {height.metric}
  • +
  • {height.imperial}
  • +
+
+ +
    +
  • {weight.metric}
  • +
  • {weight.imperial}
  • +
+
+
+ +
    + {abilities.map(({ isHidden, name }) => ( +
  • + {name} + {isHidden && Hidden Ability} +
  • + ))} +
+
+
+ + +
+

Statistics

+ +

{baseExperience} XP

+
+ + + +
+
+ + +
+

Moves

+ {moves[MoveLearnType.LevelUp].length > 0 && ( + +
    +
  • + + +
  • + {moves[MoveLearnType.LevelUp].map(({ name, level }) => ( +
  • +

    + {level} +

    +

    {name}

    +
  • + ))} +
+
+ )} + {moves[MoveLearnType.Egg].length > 0 && ( + + {renderMoveList(MoveLearnType.Egg)} + + )} + {moves[MoveLearnType.Machine].length > 0 && ( + + {renderMoveList(MoveLearnType.Machine)} + + )} + {moves[MoveLearnType.Tutor].length > 0 && ( + + {renderMoveList(MoveLearnType.Tutor)} + + )} +
+
+ + +
+
+
@@ -132,14 +167,18 @@ const Detail: React.FunctionComponent = ({ export default Detail; -export const beforeRouteEnter: GuardFunction = async (to, from, next) => { - const { name } = to.match.params; +export const beforeRouteEnter: GuardFunction = async (ctx, next) => { + const { name } = ctx.to.match.params; try { - const pokemon = await api.get(name); - next.props({ + const pokemon = await api.get(name, { signal: ctx.signal }); + return next.data({ pokemon: serializePokemon(pokemon), }); - } catch { - throw new Error('Pokemon does not exist.'); + } catch (error) { + if (error.name === 'AbortError') { + throw error; + } else { + throw new Error('Pokemon does not exist.'); + } } }; diff --git a/demos/intermediate/src/containers/List/index.tsx b/demos/intermediate/src/containers/List/index.tsx index e970fd6..19b0a23 100644 --- a/demos/intermediate/src/containers/List/index.tsx +++ b/demos/intermediate/src/containers/List/index.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useCallback, useEffect, useState } from 'react'; +import React, { Fragment, useEffect, useState } from 'react'; import { Waypoint } from 'react-waypoint'; import { Link } from 'components'; import { Pokeball } from 'svgs'; @@ -23,15 +23,23 @@ const List = () => { name, })); - const getPokemon = useCallback(async () => { - const { next, results: newResults } = await api.list(offset); - setResults([...results, ...serializeResults(newResults)]); - setHasMore(!!next); - setOffset(offset + LIST_FETCH_LIMIT); - }, [results, offset]); + const getPokemon = async (signal?: AbortSignal) => { + try { + const { next, results: newResults } = await api.list(offset, { signal }); + setResults([...results, ...serializeResults(newResults)]); + setHasMore(!!next); + setOffset(offset + LIST_FETCH_LIMIT); + } catch { + // Do nothing on error... + } + }; useEffect(() => { - getPokemon(); + const abortController = new AbortController(); + getPokemon(abortController.signal); + return () => { + abortController.abort(); + }; }, []); return ( @@ -51,7 +59,7 @@ const List = () => { ))} - + getPokemon()} /> {hasMore && (
diff --git a/demos/intermediate/src/router/guards/waitOneSecond.ts b/demos/intermediate/src/router/guards/waitOneSecond.ts index 41bd4e7..f24d762 100644 --- a/demos/intermediate/src/router/guards/waitOneSecond.ts +++ b/demos/intermediate/src/router/guards/waitOneSecond.ts @@ -1,7 +1,8 @@ import { GuardFunction } from 'react-router-guards'; -const waitOneSecond: GuardFunction = async (to, from, next) => { - setTimeout(next, 1000); +const waitOneSecond: GuardFunction = async (ctx, next) => { + await new Promise(resolve => setTimeout(resolve, 1000)); + return next(); }; export default waitOneSecond; diff --git a/demos/intermediate/src/router/index.tsx b/demos/intermediate/src/router/index.tsx index 7c2cc20..28986d0 100644 --- a/demos/intermediate/src/router/index.tsx +++ b/demos/intermediate/src/router/index.tsx @@ -18,9 +18,8 @@ const Router: React.FunctionComponent = ({ children }) => ( , diff --git a/demos/intermediate/src/utils/api.ts b/demos/intermediate/src/utils/api.ts index 575343f..a44748d 100644 --- a/demos/intermediate/src/utils/api.ts +++ b/demos/intermediate/src/utils/api.ts @@ -3,24 +3,21 @@ import { LIST_FETCH_LIMIT } from 'utils/constants'; const API_BASE_URL = 'https://pokeapi.co/api/v2'; -interface BasicResponse { - [key: string]: any; -} - -const fetchFromAPI = async ( +async function fetchFromAPI( endpoint: string, options?: Record, -): Promise => { + init?: RequestInit, +): Promise { let queryString = ''; if (options) { queryString = Object.keys(options) .map(key => `${key}=${encodeURIComponent(options[key])}`) .join('&'); } - const response = await fetch(`${API_BASE_URL}${endpoint}?${queryString}`); + const response = await fetch(`${API_BASE_URL}${endpoint}?${queryString}`, init); const data = response.json(); return data; -}; +} interface List { count: number; @@ -30,13 +27,17 @@ interface List { } export default { - async list(offset: number) { - return fetchFromAPI('/pokemon', { - offset, - limit: LIST_FETCH_LIMIT, - }) as Promise; + list(offset: number, init?: RequestInit) { + return fetchFromAPI( + '/pokemon', + { + offset, + limit: LIST_FETCH_LIMIT, + }, + init, + ); }, - get(identifier: string | number) { - return fetchFromAPI(`/pokemon/${identifier}`) as Promise; + get(identifier: string | number, init?: RequestInit) { + return fetchFromAPI(`/pokemon/${identifier}`, undefined, init); }, }; diff --git a/package.json b/package.json index b6daecd..050ee15 100644 --- a/package.json +++ b/package.json @@ -48,10 +48,6 @@ "*.{js,ts,tsx}": [ "eslint --fix", "git add" - ], - "*.scss": [ - "stylelint", - "git add" ] }, "husky": { diff --git a/package/src/ContextWrapper.tsx b/package/src/ContextWrapper.tsx deleted file mode 100644 index 3b65f3a..0000000 --- a/package/src/ContextWrapper.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React, { Fragment } from 'react'; - -interface Props { - context: React.Context; - value: T; -} - -function ContextWrapper({ children, context, value }: React.PropsWithChildren>) { - if (value) { - const { Provider } = context; - return {children}; - } - return {children}; -} - -export default ContextWrapper; diff --git a/package/src/Guard.tsx b/package/src/Guard.tsx index e27d94c..7bdc395 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -1,189 +1,139 @@ -import React, { useCallback, useContext, useEffect, useMemo } from 'react'; -import { __RouterContext as RouterContext } from 'react-router'; -import { matchPath, Redirect, Route } from 'react-router-dom'; -import { ErrorPageContext, FromRouteContext, GuardContext, LoadingPageContext } from './contexts'; -import { usePrevious, useStateRef, useStateWhenMounted } from './hooks'; -import renderPage from './renderPage'; +import React, { useContext, useRef, useState, useEffect, Fragment, createElement } from 'react'; +import { RouteComponentProps, withRouter, RouteProps } from 'react-router'; +import { Redirect, Route } from 'react-router-dom'; import { - GuardFunction, - GuardProps, - GuardType, - GuardTypes, - Next, - NextAction, - NextPropsPayload, - NextRedirectPayload, -} from './types'; - -type PageProps = NextPropsPayload; -type RouteError = string | Record | null; -type RouteRedirect = NextRedirectPayload | null; + ErrorPageContext, + GuardContext, + LoadingPageContext, + FromRouteContext, + GuardDataContext, +} from './contexts'; +import { resolveGuards, ResolvedGuardStatus } from './resolveGuards'; +import { useRouteChangeEffect } from './useRouteChangeEffect'; +import { Meta, Page, PageComponentType } from './types'; + +/** + * Type checks whether the given page is a React component type. + * + * @param page the page to type check + */ +export function isPageComponentType

(page: Page

): page is PageComponentType

{ + return ( + !!page && typeof page !== 'string' && typeof page !== 'boolean' && typeof page !== 'number' + ); +} -interface GuardsResolve { - props: PageProps; - redirect: RouteRedirect; +export interface GuardProps extends RouteProps { + meta?: Meta; } -const Guard: React.FunctionComponent = ({ children, component, meta, render }) => { - const routeProps = useContext(RouterContext); - const routePrevProps = usePrevious(routeProps); - const hasPathChanged = useMemo( - () => routeProps.location.pathname !== routePrevProps.location.pathname, - [routePrevProps, routeProps], - ); - const fromRouteProps = useContext(FromRouteContext); +export const Guard = withRouter(function GuardWithRouter({ + // Guard props + children, + component, + meta, + render, + // Route component props + history, + location, + match, + staticContext, +}) { + // Track whether the component is mounted to prevent setting state after unmount + const isMountedRef = useRef(true); + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); const guards = useContext(GuardContext); - const LoadingPage = useContext(LoadingPageContext); - const ErrorPage = useContext(ErrorPageContext); - - const hasGuards = useMemo(() => !!(guards && guards.length > 0), [guards]); - const [validationsRequested, setValidationsRequested] = useStateRef(0); - const [routeValidated, setRouteValidated] = useStateRef(!hasGuards); - const [routeError, setRouteError] = useStateWhenMounted(null); - const [routeRedirect, setRouteRedirect] = useStateWhenMounted(null); - const [pageProps, setPageProps] = useStateWhenMounted({}); - - /** - * Memoized callback to get the current number of validations requested. - * This is used in order to see if new validations were requested in the - * middle of a validation execution. - */ - const getValidationsRequested = useCallback(() => validationsRequested.current, [ - validationsRequested, - ]); - - /** - * Memoized callback to get the next callback function used in guards. - * Assigns the `props` and `redirect` functions to callback. - */ - const getNextFn = useCallback((resolve: Function): Next => { - const getResolveFn = (type: GuardType) => (payload: NextPropsPayload | NextRedirectPayload) => - resolve({ type, payload }); - const next = () => resolve({ type: GuardTypes.CONTINUE }); - - return Object.assign(next, { - props: getResolveFn(GuardTypes.PROPS), - redirect: getResolveFn(GuardTypes.REDIRECT), - }); - }, []); + type GuardStatus = { type: 'resolving' } | ResolvedGuardStatus; + function getInitialStatus(): GuardStatus { + // If there are no guards in context, the route should immediately render + if (!guards || guards.length === 0) { + return { type: 'render', data: {} }; + } + // Otherwise, the component should start resolving + return { type: 'resolving' }; + } + // Create an immutable status state that React will track + const [immutableStatus, setStatus] = useState(getInitialStatus); + // Create a mutable status variable that we can change for the *current* render + let status = immutableStatus; - /** - * Runs through a single guard, passing it the current route's props, - * the previous route's props, and the next callback function. If an - * error occurs, it will be thrown by the Promise. - * - * @param guard the guard function - * @returns a Promise returning the guard payload - */ - const runGuard = (guard: GuardFunction): Promise => - new Promise(async (resolve, reject) => { - try { - const to = { - ...routeProps, - meta: meta || {}, - }; - await guard(to, fromRouteProps, getNextFn(resolve)); - } catch (error) { - reject(error); - } - }); + const routeProps = { history, location, match, staticContext }; + const fromRouteProps = useContext(FromRouteContext); + const routeChangeAbortControllerRef = useRef(null); + useRouteChangeEffect(routeProps, async () => { + // Abort the guard resolution from the previous route + if (routeChangeAbortControllerRef.current) { + routeChangeAbortControllerRef.current.abort(); + routeChangeAbortControllerRef.current = null; + } - /** - * Loops through all guards in context. If the guard adds new props - * to the page or causes a redirect, these are tracked in the state - * constants defined above. - */ - const resolveAllGuards = async (): Promise => { - let index = 0; - let props = {}; - let redirect = null; - if (guards) { - while (!redirect && index < guards.length) { - const { type, payload } = await runGuard(guards[index]); - if (payload) { - if (type === GuardTypes.REDIRECT) { - redirect = payload; - } else if (type === GuardTypes.PROPS) { - props = Object.assign(props, payload); - } - } - index += 1; + // Determine the initial guard status for the new route + const nextStatus = getInitialStatus(); + // Update status for the *next* render + if (isMountedRef.current) { + setStatus(nextStatus); + } + // Update status for the *current* render (based on the intention for the *next* render) + status = nextStatus; + + // If the next status is to resolve guards, do so! + if (status.type === 'resolving') { + const abortController = new AbortController(); + routeChangeAbortControllerRef.current = abortController; + // Resolve the guards to get the render status + const resolvedStatus = await resolveGuards(guards || [], { + to: routeProps, + from: fromRouteProps, + meta: meta || {}, + signal: abortController.signal, + }); + // If the route hasn't changed during async resolution, set the newly resolved status! + if (isMountedRef.current && !abortController.signal.aborted) { + setStatus(resolvedStatus); } } - return { - props, - redirect, - }; - }; - - /** - * Validates the route using the guards. If an error occurs, it - * will toggle the route error state. - */ - const validateRoute = async (): Promise => { - const currentRequests = validationsRequested.current; + }); - let pageProps: PageProps = {}; - let routeError: RouteError = null; - let routeRedirect: RouteRedirect = null; + const loadingPage = useContext(LoadingPageContext); + const errorPage = useContext(ErrorPageContext); - try { - const { props, redirect } = await resolveAllGuards(); - pageProps = props; - routeRedirect = redirect; - } catch (error) { - routeError = error.message || 'Not found.'; + switch (status.type) { + case 'redirect': { + return ; } - if (currentRequests === getValidationsRequested()) { - setPageProps(pageProps); - setRouteError(routeError); - setRouteRedirect(routeRedirect); - setRouteValidated(true); + case 'render': { + return ( + + + + {children} + + + + ); } - }; - useEffect(() => { - validateRoute(); - }, []); - - useEffect(() => { - if (hasPathChanged) { - setValidationsRequested(requests => requests + 1); - setRouteError(null); - setRouteRedirect(null); - setRouteValidated(!hasGuards); - if (hasGuards) { - validateRoute(); + case 'error': { + if (isPageComponentType(errorPage)) { + return createElement(errorPage, { ...routeProps, error: status.error }); } + return {errorPage}; } - }, [hasPathChanged]); - if (hasPathChanged) { - if (hasGuards) { - return renderPage(LoadingPage, routeProps); - } - return null; - } else if (!routeValidated.current) { - return renderPage(LoadingPage, routeProps); - } else if (routeError) { - return renderPage(ErrorPage, { ...routeProps, error: routeError }); - } else if (routeRedirect) { - const pathToMatch = typeof routeRedirect === 'string' ? routeRedirect : routeRedirect.pathname; - const { path, isExact: exact } = routeProps.match; - if (pathToMatch && !matchPath(pathToMatch, { path, exact })) { - return ; + case 'resolving': + default: { + if (isPageComponentType(loadingPage)) { + return createElement(loadingPage, routeProps); + } + return {loadingPage}; } } - return ( - - - {children} - - - ); -}; - -export default Guard; +}); diff --git a/package/src/GuardProvider.tsx b/package/src/GuardProvider.tsx index 7a76652..22a5cd1 100644 --- a/package/src/GuardProvider.tsx +++ b/package/src/GuardProvider.tsx @@ -1,40 +1,51 @@ import React, { useContext } from 'react'; -import { __RouterContext as RouterContext } from 'react-router'; -import invariant from 'tiny-invariant'; +import { withRouter, RouteComponentProps } from 'react-router'; import { ErrorPageContext, FromRouteContext, GuardContext, LoadingPageContext } from './contexts'; -import { useGlobalGuards, usePrevious } from './hooks'; -import { GuardProviderProps } from './types'; +import { useGlobalGuards } from './useGlobalGuards'; +import { BaseGuardProps } from './types'; +import { useRouteChangeEffect } from './useRouteChangeEffect'; -const GuardProvider: React.FunctionComponent = ({ - children, - guards, - ignoreGlobal, - loading, - error, -}) => { - const routerContext = useContext(RouterContext); - invariant(!!routerContext, 'You should not use outside a '); +export type GuardProviderProps = BaseGuardProps; - const from = usePrevious(routerContext); - const providerGuards = useGlobalGuards(guards, ignoreGlobal); +export const GuardProvider = withRouter( + function GuardProviderWithRouter({ + // Guard provider props + children, + guards, + ignoreGlobal, + loading: loadingPageOverride, + error: errorPageOverride, + // Route component props + history, + location, + match, + staticContext, + }) { + const routeProps = { history, location, match, staticContext }; + const fromRouteProps = useRouteChangeEffect(routeProps, () => {}); + const parentFromRouteProps = useContext(FromRouteContext); - const loadingPage = useContext(LoadingPageContext); - const errorPage = useContext(ErrorPageContext); + const providerGuards = useGlobalGuards(guards, ignoreGlobal); - return ( - - - - {children} - - - - ); -}; + const loadingPage = useContext(LoadingPageContext); + const errorPage = useContext(ErrorPageContext); -GuardProvider.defaultProps = { - guards: [], - ignoreGlobal: false, -}; - -export default GuardProvider; + return ( + + + + {/** + * Prioritize the parent FromRoute props over the child (which uses the closest Route's match) + * https://reactrouter.com/web/api/withRouter + */} + + {children} + + + + + ); + }, +); diff --git a/package/src/GuardedRoute.tsx b/package/src/GuardedRoute.tsx index 2d6fcdf..b9f805e 100644 --- a/package/src/GuardedRoute.tsx +++ b/package/src/GuardedRoute.tsx @@ -1,19 +1,22 @@ import React, { useContext } from 'react'; -import { Route } from 'react-router-dom'; +import { Route, RouteProps } from 'react-router-dom'; import invariant from 'tiny-invariant'; -import ContextWrapper from './ContextWrapper'; -import Guard from './Guard'; +import { Guard } from './Guard'; import { ErrorPageContext, GuardContext, LoadingPageContext } from './contexts'; -import { useGlobalGuards } from './hooks'; -import { GuardedRouteProps, PageComponent } from './types'; +import { useGlobalGuards } from './useGlobalGuards'; +import { BaseGuardProps, Meta } from './types'; -const GuardedRoute: React.FunctionComponent = ({ +export interface GuardedRouteProps extends BaseGuardProps, RouteProps { + meta?: Meta; +} + +export const GuardedRoute: React.FunctionComponent = ({ children, component, - error, + error: errorPageOverride, guards, ignoreGlobal, - loading, + loading: loadingPageOverride, meta, render, path, @@ -24,28 +27,22 @@ const GuardedRoute: React.FunctionComponent = ({ const routeGuards = useGlobalGuards(guards, ignoreGlobal); + const loadingPage = useContext(LoadingPageContext); + const errorPage = useContext(ErrorPageContext); + return ( - ( - - context={LoadingPageContext} value={loading}> - context={ErrorPageContext} value={error}> - - {children} - - - - - )} - /> + + + + + + {children} + + + + + ); }; - -GuardedRoute.defaultProps = { - guards: [], - ignoreGlobal: false, -}; - -export default GuardedRoute; diff --git a/package/src/__tests__/renderPage.test.tsx b/package/src/__tests__/renderPage.test.tsx deleted file mode 100644 index b78eb0b..0000000 --- a/package/src/__tests__/renderPage.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, { Fragment, createElement } from 'react'; -import { shallow } from 'enzyme'; -import renderPage from '../renderPage'; - -const testNullRender = (data: any) => { - expect(renderPage(data)).toEqual(null); -}; - -const testFragmentRender = (data: any) => { - const page = renderPage(data) as React.ReactElement; - const Element = ({ data }: Record) => {data}; - const testPage = shallow(); - expect(testPage.equals(page)).toEqual(true); -}; - -const Component = ({ text }: Record) =>

{text}
; -Component.defaultProps = { - text: 'ok', -}; - -describe('renderPage', () => { - it('renders null as null', () => { - testNullRender(null); - }); - - it('renders undefined as null', () => { - testNullRender(undefined); - }); - - it('renders empty string as null', () => { - testNullRender(''); - }); - - it('renders string as fragment', () => { - testFragmentRender('sample text'); - }); - - it('renders false boolean as null', () => { - testNullRender(false); - }); - - it('renders true boolean as fragment', () => { - testFragmentRender(true); - }); - - it('renders 0 number as null', () => { - testNullRender(0); - }); - - it('renders positive number as fragment', () => { - testFragmentRender(42); - }); - - it('renders negative number as fragment', () => { - testFragmentRender(-42); - }); - - it('renders component without props', () => { - const page = renderPage(Component) as React.ReactElement; - const testPage = createElement(Component); - expect(shallow(page).text()).toEqual(Component.defaultProps.text); - expect(page).toEqual(testPage); - }); - - it('renders component with props', () => { - const text = 'Hello world'; - const page = renderPage(Component, { text }) as React.ReactElement; - const testPage = createElement(Component, { text }); - expect(shallow(page).text()).toEqual(text); - expect(page).toEqual(testPage); - }); -}); diff --git a/package/src/hooks/__tests__/useGlobalGuards.test.tsx b/package/src/__tests__/useGlobalGuards.test.tsx similarity index 96% rename from package/src/hooks/__tests__/useGlobalGuards.test.tsx rename to package/src/__tests__/useGlobalGuards.test.tsx index 8a53008..a574b53 100644 --- a/package/src/hooks/__tests__/useGlobalGuards.test.tsx +++ b/package/src/__tests__/useGlobalGuards.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { GuardFunction } from '../../types'; -import { GuardContext } from '../../contexts'; -import useGlobalGuards from '../useGlobalGuards'; +import { GuardFunction } from '../types'; +import { GuardContext } from '../contexts'; +import { useGlobalGuards } from '../useGlobalGuards'; const guardOne: GuardFunction = (to, from, next) => next(); const guardTwo: GuardFunction = (to, from, next) => next.props({}); diff --git a/package/src/contexts.ts b/package/src/contexts.ts index 145346e..7fa9e0d 100644 --- a/package/src/contexts.ts +++ b/package/src/contexts.ts @@ -1,11 +1,13 @@ import { createContext } from 'react'; import { RouteComponentProps } from 'react-router-dom'; -import { PageComponent, GuardFunction } from './types'; +import { GuardFunction, ErrorPage, LoadingPage } from './types'; -export const ErrorPageContext = createContext(null); +export const ErrorPageContext = createContext(null); export const FromRouteContext = createContext(null); export const GuardContext = createContext(null); -export const LoadingPageContext = createContext(null); +export const GuardDataContext = createContext({}); + +export const LoadingPageContext = createContext(null); diff --git a/package/src/hooks/__tests__/usePrevious.test.tsx b/package/src/hooks/__tests__/usePrevious.test.tsx deleted file mode 100644 index 018ecc8..0000000 --- a/package/src/hooks/__tests__/usePrevious.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; -import usePrevious from '../usePrevious'; - -interface UsePreviousHookProps { - value?: string; -} -const UsePreviousHook: React.FC = ({ value }) => { - const previousValue = usePrevious(value); - return
{previousValue}
; -}; - -describe('usePrevious', () => { - let wrapper: ReactWrapper | null = null; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - } - wrapper = null; - }); - - it('should render', () => { - wrapper = mount(); - expect(wrapper.exists()).toBeTruthy(); - }); - - it('should set init value', () => { - const VALUE = 'value'; - wrapper = mount(); - expect(wrapper.text()).toEqual(VALUE); - }); - - it('stores the previous value of given variable', () => { - const VALUE_1 = 'hello'; - const VALUE_2 = 'world'; - const VALUE_3 = 'okay'; - - let value = VALUE_1; - wrapper = mount(); - - let hookValue = wrapper.text(); - expect(hookValue).toEqual(value); - expect(hookValue).toEqual(VALUE_1); - - value = VALUE_2; - wrapper.setProps({ value }); - hookValue = wrapper.text(); - expect(hookValue).toEqual(VALUE_1); - - value = VALUE_3; - wrapper.setProps({ value }); - hookValue = wrapper.text(); - expect(hookValue).toEqual(VALUE_2); - }); -}); diff --git a/package/src/hooks/__tests__/useStateRef.test.tsx b/package/src/hooks/__tests__/useStateRef.test.tsx deleted file mode 100644 index 30f0786..0000000 --- a/package/src/hooks/__tests__/useStateRef.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { mount, ReactWrapper } from 'enzyme'; -import useStateRef, { State, SetState } from '../useStateRef'; - -interface UseStateRefHookProps { - value?: string; -} -const UseStateRefHook: React.FC = ({ value }) => { - const stateRef = useStateRef(value); - return
; -}; - -function getState(wrapper: ReactWrapper): [State, SetState] { - return wrapper.find('div').prop('data-state-ref'); -} - -describe('usePrevious', () => { - const INIT_VALUE = 'value'; - let wrapper: ReactWrapper | null = null; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - } - wrapper = null; - }); - - it('should render', () => { - wrapper = mount(); - expect(wrapper.exists()).toBeTruthy(); - }); - - it('should return a ref for the state value', () => { - wrapper = mount(); - const [state] = getState(wrapper); - expect(typeof state).toEqual('object'); - expect(state).toHaveProperty('current'); - }); - - it('should set initial state to undefined with no passed value', () => { - wrapper = mount(); - const [state] = getState(wrapper); - expect(state.current).toEqual(undefined); - }); - - it('should set initial state to passed value', () => { - wrapper = mount(); - const [state] = getState(wrapper); - expect(state.current).toEqual(INIT_VALUE); - }); - - it('should update value to new value passed to setState', () => { - const VALUE = 'value'; - wrapper = mount(); - const [state, setState] = getState(wrapper); - expect(state.current).toEqual(VALUE); - - const NEW_VALUE = 'new value'; - act(() => { - setState(NEW_VALUE); - }); - expect(state.current).toEqual(NEW_VALUE); - }); -}); diff --git a/package/src/hooks/__tests__/useStateWhenMounted.test.tsx b/package/src/hooks/__tests__/useStateWhenMounted.test.tsx deleted file mode 100644 index 20d9974..0000000 --- a/package/src/hooks/__tests__/useStateWhenMounted.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { mount, ReactWrapper } from 'enzyme'; -import useStateWhenMounted, { SetState } from '../useStateWhenMounted'; - -interface UseStateWhenMountedHookProps { - value?: string; -} -const UseStateWhenMountedHook: React.FC = ({ value }) => { - const stateRef = useStateWhenMounted(value); - return
; -}; - -function getState(wrapper: ReactWrapper): [T, SetState] { - return wrapper.find('div').prop('data-state-ref'); -} - -describe('usePrevious', () => { - const INIT_VALUE = 'value'; - let wrapper: ReactWrapper | null = null; - - afterEach(() => { - if (wrapper && wrapper.exists()) { - wrapper.unmount(); - } - wrapper = null; - }); - - it('should render', () => { - wrapper = mount(); - expect(wrapper.exists()).toBeTruthy(); - }); - - it('should set initial state to undefined with no passed value', () => { - wrapper = mount(); - const [state] = getState(wrapper); - expect(state).toEqual(undefined); - }); - - it('should set initial state to passed value', () => { - wrapper = mount(); - const [state] = getState(wrapper); - expect(state).toEqual(INIT_VALUE); - }); - - it('should prevent value update when component is unmounted', () => { - const VALUE = 'value'; - wrapper = mount(); - const [state, setState] = getState(wrapper); - expect(state).toEqual(VALUE); - - wrapper.unmount(); - - const NEW_VALUE = 'new value'; - act(() => { - setState(NEW_VALUE); - }); - expect(state).toEqual(VALUE); - }); -}); diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts deleted file mode 100644 index 6055d87..0000000 --- a/package/src/hooks/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { default as useGlobalGuards } from './useGlobalGuards'; -export { default as usePrevious } from './usePrevious'; -export { default as useStateRef } from './useStateRef'; -export { default as useStateWhenMounted } from './useStateWhenMounted'; diff --git a/package/src/hooks/useGlobalGuards.ts b/package/src/hooks/useGlobalGuards.ts deleted file mode 100644 index 549d005..0000000 --- a/package/src/hooks/useGlobalGuards.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useContext, useMemo, useDebugValue } from 'react'; -import { GuardContext } from '../contexts'; -import { GuardFunction } from '../types'; - -/** - * React hook for creating the guards array for a Guarded - * component. - * - * @param guards the component-level guards - * @param ignoreGlobal whether to ignore the global guards or not - * @returns the guards to use on the component - */ -const useGlobalGuards = (guards: GuardFunction[] = [], ignoreGlobal: boolean = false) => { - const globalGuards = useContext(GuardContext); - - const componentGuards = useMemo(() => { - if (ignoreGlobal) { - return [...guards]; - } - return [...(globalGuards || []), ...guards]; - }, [guards, ignoreGlobal]); - - useDebugValue(componentGuards.map(({ name }) => name).join(' | ')); - - return componentGuards; -}; - -export default useGlobalGuards; diff --git a/package/src/hooks/usePrevious.ts b/package/src/hooks/usePrevious.ts deleted file mode 100644 index 4906c5e..0000000 --- a/package/src/hooks/usePrevious.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { useDebugValue, useEffect, useRef } from 'react'; - -/** - * React hook for storing the previous value of the - * given value. - * - * @param value the value to store - * @returns the previous value - */ -function usePrevious(value: T): T { - const ref = useRef(value); - - useEffect(() => { - ref.current = value; - }); - - useDebugValue(ref.current); - - return ref.current; -} - -export default usePrevious; diff --git a/package/src/hooks/useStateRef.ts b/package/src/hooks/useStateRef.ts deleted file mode 100644 index 7142363..0000000 --- a/package/src/hooks/useStateRef.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useRef } from 'react'; -import useStateWhenMounted from './useStateWhenMounted'; - -type NotFunc = Exclude; - -type SetStateFuncAction = (prevState: NotFunc) => NotFunc; -type SetStateAction = NotFunc | SetStateFuncAction; -export type SetState = (newState: SetStateAction) => void; - -export type State = React.MutableRefObject>; - -/** - * React hook that provides a similar API to the `useState` - * hook, but performs updates using refs instead of asynchronous - * actions. - * - * @param initialState the initial state of the state variable - * @returns an array containing a ref of the state variable and a setter - * function for the state - */ -function useStateRef(initialState: NotFunc): [State, SetState] { - const state = useRef(initialState); - const [, setTick] = useStateWhenMounted(0); - - const setState: SetState = newState => { - if (typeof newState === 'function') { - state.current = (newState as SetStateFuncAction)(state.current); - } else { - state.current = newState; - } - setTick(tick => tick + 1); - }; - - return [state, setState]; -} - -export default useStateRef; diff --git a/package/src/hooks/useStateWhenMounted.ts b/package/src/hooks/useStateWhenMounted.ts deleted file mode 100644 index eebaded..0000000 --- a/package/src/hooks/useStateWhenMounted.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useEffect, useRef, useState, useDebugValue } from 'react'; - -export type SetState = (newState: React.SetStateAction) => void; - -/** - * React hook for only updating a component's state when the component is still mounted. - * This is useful for state variables that depend on asynchronous operations to update. - * - * The interface in which this hook is used is identical to that of `useState`. - * - * @param initialState the initial value of the state variable - * @returns an array containing the state variable and the function to update - * the state - */ -function useStateWhenMounted(initialState: T): [T, SetState] { - const mounted = useRef(true); - - const [state, setState] = useState(initialState); - - const setStateWhenMounted: SetState = newState => { - if (mounted.current) { - setState(newState); - } - }; - - useEffect( - () => () => { - mounted.current = false; - }, - [], - ); - - useDebugValue(state); - - return [state, setStateWhenMounted]; -} - -export default useStateWhenMounted; diff --git a/package/src/index.ts b/package/src/index.ts index cdf1dbe..ffb9ddf 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,10 +1,11 @@ -export { default as GuardProvider } from './GuardProvider'; -export { default as GuardedRoute } from './GuardedRoute'; +export { GuardProvider, GuardProviderProps } from './GuardProvider'; +export { GuardedRoute, GuardedRouteProps } from './GuardedRoute'; +export { useGuardData } from './useGuardData'; export { BaseGuardProps, - GuardedRouteProps, GuardFunction, - GuardProviderProps, - Next, - PageComponent, + NextFunction, + Page, + LoadingPageComponentType, + ErrorPageComponentType, } from './types'; diff --git a/package/src/renderPage.tsx b/package/src/renderPage.tsx deleted file mode 100644 index d44d09b..0000000 --- a/package/src/renderPage.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React, { createElement, Fragment } from 'react'; -import { PageComponent } from './types'; - -type BaseProps = Record; - -/** - * Renders a page with the given props. - * - * @param page the page component to render - * @param props the props to pass to the page - * @returns the page component - */ -function renderPage( - page: PageComponent, - props?: Props, -): React.ReactElement | null { - if (!page) { - return null; - } else if (typeof page !== 'string' && typeof page !== 'boolean' && typeof page !== 'number') { - return createElement(page, props || {}); - } - return {page}; -} - -export default renderPage; diff --git a/package/src/resolveGuards.ts b/package/src/resolveGuards.ts new file mode 100644 index 0000000..fae5557 --- /dev/null +++ b/package/src/resolveGuards.ts @@ -0,0 +1,80 @@ +import { + GuardFunction, + NextFunction, + NextDataPayload, + NextRedirectPayload, + GuardFunctionContext, + NextContinueAction, + NextDataAction, + NextRedirectAction, +} from './types'; + +export type ResolvedGuardStatus = + | { type: 'error'; error: unknown } + | { type: 'redirect'; redirect: NextRedirectPayload } + | { type: 'render'; data: NextDataPayload }; + +export const NextFunctionFactory = { + /** + * Builds a new next function using the given `resolve` callback. + */ + build(): NextFunction<{}> { + function next(): NextContinueAction { + return { type: 'continue' }; + } + + return Object.assign(next, { + data(payload: NextDataPayload): NextDataAction { + return { type: 'data', payload }; + }, + redirect(payload: NextRedirectPayload): NextRedirectAction { + return { type: 'redirect', payload }; + }, + }); + }, +}; + +/** + * Resolves a list of guards in the given context. Resolution follows as such: + * - If any guard resolves to a redirect, return that redirect + * - If any guard throws an error, return that error + * - Otherwise, return all merged data + * + * If the abort signal in context is aborted, bubble up that error. + * + * @param guards the list of guards to resolve + * @param context the context of these guards + * @returns a Promise returning the resolved guards' status + */ +export async function resolveGuards( + guards: GuardFunction[], + context: GuardFunctionContext, +): Promise { + try { + let data: NextDataPayload = {}; + for (const guard of guards) { + // If guard resolution has been canceled *before* running guard, bubble up an AbortError + if (context.signal.aborted) { + throw new DOMException('Aborted', 'AbortError'); + } + + // Run the guard and get the resolved action + const action = await guard(context, NextFunctionFactory.build()); + switch (action.type) { + case 'redirect': { + // If the guard calls for a redirect, do so immediately! + return { type: 'redirect', redirect: action.payload }; + } + case 'data': { + // Otherwise, continue to merge data + data = Object.assign(data, action.payload); + break; + } + } + } + // Then return the props after all guards have resolved + return { type: 'render', data }; + } catch (error) { + return { type: 'error', error }; + } +} diff --git a/package/src/types.ts b/package/src/types.ts index 197f2e4..f0ef5ce 100644 --- a/package/src/types.ts +++ b/package/src/types.ts @@ -1,83 +1,89 @@ import { ComponentType } from 'react'; import { LocationDescriptor } from 'history'; -import { RouteComponentProps, RouteProps } from 'react-router-dom'; +import { RouteComponentProps } from 'react-router-dom'; -/** - * General - */ +/////////////////////////////// +// General +/////////////////////////////// export type Meta = Record; -export type RouteMatchParams = Record; - -/** - * Guard Function Types - */ -export const GuardTypes = Object.freeze({ - CONTINUE: 'CONTINUE', - PROPS: 'PROPS', - REDIRECT: 'REDIRECT', -}); - -export type GUARD_TYPES_CONTINUE = typeof GuardTypes.CONTINUE; -export type GUARD_TYPES_PROPS = typeof GuardTypes.PROPS; -export type GUARD_TYPES_REDIRECT = typeof GuardTypes.REDIRECT; -export type GuardType = GUARD_TYPES_CONTINUE | GUARD_TYPES_PROPS | GUARD_TYPES_REDIRECT; +/////////////////////////////// +// Next Functions +/////////////////////////////// export interface NextContinueAction { - type: GUARD_TYPES_CONTINUE; - payload?: any; + type: 'continue'; } -export type NextPropsPayload = Record; -export interface NextPropsAction { - type: GUARD_TYPES_PROPS; - payload: NextPropsPayload; +export type NextDataPayload = Record; +export interface NextDataAction { + type: 'data'; + payload: NextDataPayload; } export type NextRedirectPayload = LocationDescriptor; export interface NextRedirectAction { - type: GUARD_TYPES_REDIRECT; + type: 'redirect'; payload: NextRedirectPayload; } -export type NextAction = NextContinueAction | NextPropsAction | NextRedirectAction; +export type NextAction = NextContinueAction | NextDataAction | NextRedirectAction; -export interface Next { - (): void; - props(props: NextPropsPayload): void; - redirect(to: LocationDescriptor): void; +export interface NextFunction { + /** Resolve the guard and continue to the next, if any. */ + (): NextContinueAction; + /** Pass the data to the resolved route and continue to the next, if any. */ + data(data: Data): NextDataAction; + /** Redirect to the given route. */ + redirect(to: LocationDescriptor): NextRedirectAction; } -export type GuardFunctionRouteProps = RouteComponentProps; -export type GuardToRoute = GuardFunctionRouteProps & { +/////////////////////////////// +// Guards +/////////////////////////////// +export interface GuardFunctionContext { + /** The route being navigated to. */ + to: RouteComponentProps>; + /** The route being navigated from, if any. */ + from: RouteComponentProps> | null; + /** Metadata attached on the `to` route. */ meta: Meta; -}; -export type GuardFunction = ( - to: GuardToRoute, - from: GuardFunctionRouteProps | null, - next: Next, -) => void; + /** + * A signal that determines if the current guard resolution has been aborted. + * + * Attach to `fetch` calls to cancel outdated requests before they're resolved. + */ + signal: AbortSignal; +} + +export type GuardFunction = ( + /** Context for this guard's execution */ + context: GuardFunctionContext, + /** The guard's next function */ + next: NextFunction, +) => NextAction | Promise; -/** - * Page Component Types - */ -export type PageComponent = ComponentType | null | undefined | string | boolean | number; +/////////////////////////////// +// Page Types +/////////////////////////////// +export type PageComponentType

= ComponentType; +export type Page

= PageComponentType

| null | string | boolean | number; -/** - * Props - */ +export type LoadingPage = Page; +export type ErrorPage = Page<{ error: unknown }>; + +export type LoadingPageComponentType = PageComponentType; +export type ErrorPageComponentType = PageComponentType<{ error: unknown }>; + +/////////////////////////////// +// Props +/////////////////////////////// export interface BaseGuardProps { + /** Guards to attach as middleware. */ guards?: GuardFunction[]; + /** Whether to ignore guards attached to parent providers. */ ignoreGlobal?: boolean; - loading?: PageComponent; - error?: PageComponent; + /** A custom loading page component. */ + loading?: LoadingPage; + /** A custom error page component. */ + error?: ErrorPage; } - -export type PropsWithMeta = T & { - meta?: Meta; -}; - -export type GuardProviderProps = BaseGuardProps; -export type GuardedRouteProps = PropsWithMeta; -export type GuardProps = PropsWithMeta & { - name?: string | string[]; -}; diff --git a/package/src/useGlobalGuards.ts b/package/src/useGlobalGuards.ts new file mode 100644 index 0000000..6088c1c --- /dev/null +++ b/package/src/useGlobalGuards.ts @@ -0,0 +1,20 @@ +import { useContext } from 'react'; +import { GuardContext } from './contexts'; +import { GuardFunction } from './types'; + +/** + * React hook for creating the guards array for a Guarded component. + * + * @param guards the component-level guards + * @param ignoreGlobal whether to ignore the global guards or not + * @returns the guards to use on the component + */ +export const useGlobalGuards = (guards: GuardFunction[] = [], ignoreGlobal: boolean = false) => { + const globalGuards = useContext(GuardContext); + + if (ignoreGlobal) { + return [...guards]; + } else { + return [...(globalGuards || []), ...guards]; + } +}; diff --git a/package/src/useGuardData.tsx b/package/src/useGuardData.tsx new file mode 100644 index 0000000..8d2b858 --- /dev/null +++ b/package/src/useGuardData.tsx @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { GuardDataContext } from './contexts'; + +export function useGuardData

() { + return useContext(GuardDataContext) as P; +} diff --git a/package/src/useRouteChangeEffect.ts b/package/src/useRouteChangeEffect.ts new file mode 100644 index 0000000..6b59820 --- /dev/null +++ b/package/src/useRouteChangeEffect.ts @@ -0,0 +1,78 @@ +import { useRef } from 'react'; +import { RouteComponentProps } from 'react-router'; + +/** + * Compares the matched route's path and params to check whether the + * route has changed. + * + * @param routeA a route to compare + * @param routeB a route to compare + * @returns whether the route has changed + */ +export function getHasRouteChanged( + routeA: RouteComponentProps>, + routeB: RouteComponentProps>, +) { + // Perform shallow string comparison to check that path hasn't changed + const doPathsMatch = routeA.match.path === routeB.match.path; + if (!doPathsMatch) { + return true; + } + + // Perform deep object comparison to check that params haven't changed + // NOTE: the param keys won't change so long as path doesn't change (which is already checked above) + const doParamsMatch = Object.keys(routeA.match.params).every( + key => routeA.match.params[key] === routeB.match.params[key], + ); + if (!doParamsMatch) { + return true; + } + + // If neither path nor params have changed, then route has stayed the same! + return false; +} + +/** + * Custom effect hook that runs on init and whenever the route changes. + * + * This hook runs inline with React's render function to ensure state is updated + * immediately for the upcoming render. This is preferable to `useEffect` or + * `useLayoutEffect` which only updates state _after_ a component has already rendered. + * + * @param route the current route + * @param onInitOrChange a callback for when the route changes (and on init) + * @returns the previous route (if any) + */ +export function useRouteChangeEffect( + route: RouteComponentProps, + onInitOrChange: () => void, +): RouteComponentProps | null { + // Store whether effect has run before in ref + const hasEffectRunRef = useRef(false); + + // Store the current and previous values of route in ref + // https://dev.to/chrismilson/problems-with-useprevious-me + const routeStoreRef = useRef<{ + target: RouteComponentProps; + previous: RouteComponentProps | null; + }>({ + target: route, + previous: null, + }); + + if (getHasRouteChanged(routeStoreRef.current.target, route)) { + // When the route changes, update previous + target values and run the effect + routeStoreRef.current.previous = routeStoreRef.current.target; + routeStoreRef.current.target = route; + onInitOrChange(); + } else if (!hasEffectRunRef.current) { + // Otherwise if the effect hasn't run before, run it now! + onInitOrChange(); + } + + // Always set hasEffectRun to true (to prevent duplicate runs) + hasEffectRunRef.current = true; + + // Then return the previous route (if any) + return routeStoreRef.current.previous; +}