From fa8392279d98f086277b1b13809e043f384752e0 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Wed, 15 Sep 2021 10:52:31 -0400 Subject: [PATCH 01/12] testing nested guards --- demos/intermediate/package-lock.json | 156 ++++++++++----- demos/intermediate/package.json | 3 +- .../src/containers/Detail/detail.module.scss | 19 +- .../src/containers/Detail/index.tsx | 180 +++++++++++------- demos/intermediate/src/router/index.tsx | 1 - package.json | 4 - package/src/Guard.tsx | 27 ++- 7 files changed, 252 insertions(+), 138 deletions(-) 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..308fb03 100644 --- a/demos/intermediate/src/containers/Detail/index.tsx +++ b/demos/intermediate/src/containers/Detail/index.tsx @@ -1,6 +1,8 @@ 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, GuardProvider, GuardedRoute } 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'; @@ -37,6 +39,8 @@ const Detail: React.FunctionComponent = ({ [moves], ); + const { path, url } = useRouteMatch(); + return (
@@ -52,76 +56,108 @@ 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)} + + )} +
+
+ + +
+
+
+
diff --git a/demos/intermediate/src/router/index.tsx b/demos/intermediate/src/router/index.tsx index 7c2cc20..0dcc137 100644 --- a/demos/intermediate/src/router/index.tsx +++ b/demos/intermediate/src/router/index.tsx @@ -18,7 +18,6 @@ const Router: React.FunctionComponent = ({ children }) => ( 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/Guard.tsx b/package/src/Guard.tsx index e27d94c..253bd58 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useContext, useEffect, useMemo } from 'react'; -import { __RouterContext as RouterContext } from 'react-router'; +import { __RouterContext as RouterContext, RouteComponentProps } from 'react-router'; import { matchPath, Redirect, Route } from 'react-router-dom'; import { ErrorPageContext, FromRouteContext, GuardContext, LoadingPageContext } from './contexts'; import { usePrevious, useStateRef, useStateWhenMounted } from './hooks'; @@ -25,12 +25,19 @@ interface GuardsResolve { } const Guard: React.FunctionComponent = ({ children, component, meta, render }) => { - const routeProps = useContext(RouterContext); + const routeProps: RouteComponentProps> = useContext(RouterContext); const routePrevProps = usePrevious(routeProps); - const hasPathChanged = useMemo( - () => routeProps.location.pathname !== routePrevProps.location.pathname, - [routePrevProps, routeProps], - ); + const hasRouteChanged = useMemo(() => { + // Check if the route path has changed + if (routeProps.match.path !== routePrevProps.match.path) { + return true; + } + // Check if the parameters have changed + return Object.keys(routeProps.match.params).some( + key => routeProps.match.params[key] !== routePrevProps.match.params[key], + ); + }, [routePrevProps, routeProps]); + const fromRouteProps = useContext(FromRouteContext); const guards = useContext(GuardContext); @@ -150,7 +157,7 @@ const Guard: React.FunctionComponent = ({ children, component, meta, }, []); useEffect(() => { - if (hasPathChanged) { + if (hasRouteChanged) { setValidationsRequested(requests => requests + 1); setRouteError(null); setRouteRedirect(null); @@ -159,13 +166,13 @@ const Guard: React.FunctionComponent = ({ children, component, meta, validateRoute(); } } - }, [hasPathChanged]); + }, [hasRouteChanged]); - if (hasPathChanged) { + if (hasRouteChanged) { if (hasGuards) { return renderPage(LoadingPage, routeProps); } - return null; + // return null; } else if (!routeValidated.current) { return renderPage(LoadingPage, routeProps); } else if (routeError) { From b52ca6659f1c6d7ed57e4987bacdde726e7b1a08 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Wed, 15 Sep 2021 11:32:05 -0400 Subject: [PATCH 02/12] remove internal RouterContext usage, remove ContextWrapper component --- .../src/containers/Detail/index.tsx | 160 +++++++++--------- package/src/ContextWrapper.tsx | 16 -- package/src/Guard.tsx | 26 ++- package/src/GuardProvider.tsx | 25 ++- package/src/GuardedRoute.tsx | 19 +-- 5 files changed, 118 insertions(+), 128 deletions(-) delete mode 100644 package/src/ContextWrapper.tsx diff --git a/demos/intermediate/src/containers/Detail/index.tsx b/demos/intermediate/src/containers/Detail/index.tsx index 308fb03..e3b8361 100644 --- a/demos/intermediate/src/containers/Detail/index.tsx +++ b/demos/intermediate/src/containers/Detail/index.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; import { Switch, useRouteMatch, Redirect } from 'react-router-dom'; -import { GuardFunction, GuardProvider, GuardedRoute } from 'react-router-guards'; +import { GuardFunction, GuardedRoute } from 'react-router-guards'; import { LabeledSection, Recirculation, SpriteList, StatChart, Type, Link } from 'components'; import { waitOneSecond } from 'router/guards'; import { MoveLearnType, SerializedPokemon } from 'types'; @@ -70,92 +70,90 @@ const Detail: React.FunctionComponent = ({
- - - -
- -
    -
  • {height.metric}
  • -
  • {height.imperial}
  • -
-
- -
    -
  • {weight.metric}
  • -
  • {weight.imperial}
  • -
-
-
- -
    - {abilities.map(({ isHidden, name }) => ( -
  • - {name} - {isHidden && Hidden Ability} -
  • - ))} + + +
    + +
      +
    • {height.metric}
    • +
    • {height.imperial}
    - + +
      +
    • {weight.metric}
    • +
    • {weight.imperial}
    • +
    +
    +
    + +
      + {abilities.map(({ isHidden, name }) => ( +
    • + {name} + {isHidden && Hidden Ability} +
    • + ))} +
    +
    +
    - -
    -

    Statistics

    - -

    {baseExperience} XP

    -
    - - - -
    -
    + +
    +

    Statistics

    + +

    {baseExperience} XP

    +
    + + + +
    +
    - -
    -

    Moves

    - {moves[MoveLearnType.LevelUp].length > 0 && ( - -
      -
    • - - + +
      +

      Moves

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

        + {level} +

        +

        {name}

      • - {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)} - - )} -
      -
      + ))} +
    +
    + )} + {moves[MoveLearnType.Egg].length > 0 && ( + + {renderMoveList(MoveLearnType.Egg)} + + )} + {moves[MoveLearnType.Machine].length > 0 && ( + + {renderMoveList(MoveLearnType.Machine)} + + )} + {moves[MoveLearnType.Tutor].length > 0 && ( + + {renderMoveList(MoveLearnType.Tutor)} + + )} +
    +
    - -
    - + +
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 253bd58..e17b9bf 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useContext, useEffect, useMemo } from 'react'; -import { __RouterContext as RouterContext, RouteComponentProps } from 'react-router'; +import { __RouterContext as RouterContext, RouteComponentProps, withRouter } from 'react-router'; import { matchPath, Redirect, Route } from 'react-router-dom'; import { ErrorPageContext, FromRouteContext, GuardContext, LoadingPageContext } from './contexts'; import { usePrevious, useStateRef, useStateWhenMounted } from './hooks'; @@ -14,6 +14,7 @@ import { NextPropsPayload, NextRedirectPayload, } from './types'; +import GuardProvider from './GuardProvider'; type PageProps = NextPropsPayload; type RouteError = string | Record | null; @@ -24,8 +25,17 @@ interface GuardsResolve { redirect: RouteRedirect; } -const Guard: React.FunctionComponent = ({ children, component, meta, render }) => { - const routeProps: RouteComponentProps> = useContext(RouterContext); +const Guard: React.FunctionComponent>> = ({ + children, + component, + meta, + render, + history, + location, + match, + staticContext, +}) => { + const routeProps = { history, location, match, staticContext }; const routePrevProps = usePrevious(routeProps); const hasRouteChanged = useMemo(() => { // Check if the route path has changed @@ -186,11 +196,13 @@ const Guard: React.FunctionComponent = ({ children, component, meta, } return ( - - {children} - + + + {children} + + ); }; -export default Guard; +export default withRouter(Guard); diff --git a/package/src/GuardProvider.tsx b/package/src/GuardProvider.tsx index 7a76652..7847d51 100644 --- a/package/src/GuardProvider.tsx +++ b/package/src/GuardProvider.tsx @@ -1,21 +1,25 @@ 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'; -const GuardProvider: React.FunctionComponent = ({ +const GuardProvider: React.FunctionComponent< + GuardProviderProps & RouteComponentProps> +> = ({ children, guards, ignoreGlobal, loading, error, + history, + location, + match, + staticContext, }) => { - const routerContext = useContext(RouterContext); - invariant(!!routerContext, 'You should not use outside a '); + const routeProps: RouteComponentProps = { history, location, match, staticContext }; + const fromRouteProps = usePrevious(routeProps); - const from = usePrevious(routerContext); const providerGuards = useGlobalGuards(guards, ignoreGlobal); const loadingPage = useContext(LoadingPageContext); @@ -25,16 +29,11 @@ const GuardProvider: React.FunctionComponent = ({ - {children} + {children} ); }; -GuardProvider.defaultProps = { - guards: [], - ignoreGlobal: false, -}; - -export default GuardProvider; +export default withRouter(GuardProvider); diff --git a/package/src/GuardedRoute.tsx b/package/src/GuardedRoute.tsx index 2d6fcdf..1649073 100644 --- a/package/src/GuardedRoute.tsx +++ b/package/src/GuardedRoute.tsx @@ -1,11 +1,10 @@ import React, { useContext } from 'react'; import { Route } from 'react-router-dom'; import invariant from 'tiny-invariant'; -import ContextWrapper from './ContextWrapper'; import Guard from './Guard'; import { ErrorPageContext, GuardContext, LoadingPageContext } from './contexts'; import { useGlobalGuards } from './hooks'; -import { GuardedRouteProps, PageComponent } from './types'; +import { GuardedRouteProps } from './types'; const GuardedRoute: React.FunctionComponent = ({ children, @@ -24,28 +23,26 @@ 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} - - + + )} /> ); }; -GuardedRoute.defaultProps = { - guards: [], - ignoreGlobal: false, -}; - export default GuardedRoute; From 93fc4a379cd040bdc57ac89271a68479c4260be8 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Wed, 15 Sep 2021 16:02:10 -0400 Subject: [PATCH 03/12] using status variable to track guard render state --- .../src/containers/Detail/index.tsx | 10 +- demos/intermediate/src/router/index.tsx | 2 +- demos/intermediate/src/utils/api.ts | 31 +- package/src/Guard.tsx | 268 +++++++----------- package/src/GuardProvider.tsx | 14 +- package/src/GuardedRoute.tsx | 26 +- package/src/resolveGuards.ts | 93 ++++++ package/src/types.ts | 23 +- 8 files changed, 230 insertions(+), 237 deletions(-) create mode 100644 package/src/resolveGuards.ts diff --git a/demos/intermediate/src/containers/Detail/index.tsx b/demos/intermediate/src/containers/Detail/index.tsx index e3b8361..184d646 100644 --- a/demos/intermediate/src/containers/Detail/index.tsx +++ b/demos/intermediate/src/containers/Detail/index.tsx @@ -166,14 +166,16 @@ const Detail: React.FunctionComponent = ({ export default Detail; -export const beforeRouteEnter: GuardFunction = async (to, from, next) => { +export const beforeRouteEnter: GuardFunction = async (to, from, next, signal) => { const { name } = to.match.params; try { - const pokemon = await api.get(name); + const pokemon = await api.get(name, { signal }); next.props({ pokemon: serializePokemon(pokemon), }); - } catch { - throw new Error('Pokemon does not exist.'); + } catch (error) { + if (error && error.name !== 'AbortError') { + throw new Error('Pokemon does not exist.'); + } } }; diff --git a/demos/intermediate/src/router/index.tsx b/demos/intermediate/src/router/index.tsx index 0dcc137..28986d0 100644 --- a/demos/intermediate/src/router/index.tsx +++ b/demos/intermediate/src/router/index.tsx @@ -19,7 +19,7 @@ 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/src/Guard.tsx b/package/src/Guard.tsx index e17b9bf..6598092 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -1,28 +1,32 @@ -import React, { useCallback, useContext, useEffect, useMemo } from 'react'; +import React, { useContext, useRef, useState, useEffect } from 'react'; import { __RouterContext as RouterContext, RouteComponentProps, withRouter } 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 { Redirect, Route } from 'react-router-dom'; +import { ErrorPageContext, GuardContext, LoadingPageContext, FromRouteContext } from './contexts'; import renderPage from './renderPage'; -import { - GuardFunction, - GuardProps, - GuardType, - GuardTypes, - Next, - NextAction, - NextPropsPayload, - NextRedirectPayload, -} from './types'; -import GuardProvider from './GuardProvider'; +import { GuardProps } from './types'; +import { GuardStatus, resolveGuards } from './resolveGuards'; -type PageProps = NextPropsPayload; -type RouteError = string | Record | null; -type RouteRedirect = NextRedirectPayload | null; +function usePreviousV2(value: T, hasChanged: (from: T, to: T) => boolean) { + const ref = useRef<{ target: T; previous: T | null }>({ target: value, previous: null }); -interface GuardsResolve { - props: PageProps; - redirect: RouteRedirect; + if (hasChanged(ref.current.target, value)) { + // The value changed. + ref.current.previous = ref.current.target; + ref.current.target = value; + } + + return ref.current.previous; +} + +function hasRouteChanged( + from: RouteComponentProps> | null, + to: RouteComponentProps>, +) { + return ( + !from || + to.match.path !== from.match.path || + Object.keys(to.match.params).some(key => to.match.params[key] !== from.match.params[key]) + ); } const Guard: React.FunctionComponent>> = ({ @@ -33,176 +37,96 @@ const Guard: React.FunctionComponent { - const routeProps = { history, location, match, staticContext }; - const routePrevProps = usePrevious(routeProps); - const hasRouteChanged = useMemo(() => { - // Check if the route path has changed - if (routeProps.match.path !== routePrevProps.match.path) { - return true; - } - // Check if the parameters have changed - return Object.keys(routeProps.match.params).some( - key => routeProps.match.params[key] !== routePrevProps.match.params[key], - ); - }, [routePrevProps, routeProps]); - - const fromRouteProps = useContext(FromRouteContext); - - 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), - }); - }, []); - - /** - * 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 guards = useContext(GuardContext); + const hasGuards = !!guards && guards.length > 0; - /** - * 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; - } + function getInitialStatus(): GuardStatus { + if (hasGuards) { + return { type: 'resolving' }; + } else { + return { type: 'render', props: {} }; } - 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; + // 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; + + const routeProps = { history, location, match }; + const routePrevProps = usePreviousV2(routeProps, (from, to) => { + const hasChanged = hasRouteChanged(from, to); + if (hasChanged) { + // Determine the next guard status + const nextStatus = getInitialStatus(); + // Update status for the *next* render + setStatus(nextStatus); + // Update status for the *current* render (based on the intention for the *next* render) + status = nextStatus; + } + return hasChanged; + }); + const fromRouteProps = useContext(FromRouteContext); + async function onRouteChangeAsync(signal: AbortSignal): Promise { try { - const { props, redirect } = await resolveAllGuards(); - pageProps = props; - routeRedirect = redirect; + // Resolve the guards to get the render status + const status = await resolveGuards({ + guards: guards || [], + toRoute: { ...routeProps, meta: meta || {} }, + fromRoute: fromRouteProps, + signal, + }); + // If the signal hasn't been aborted, set the new status! + if (!signal.aborted) { + setStatus(status); + } } catch (error) { - routeError = error.message || 'Not found.'; - } - - if (currentRequests === getValidationsRequested()) { - setPageProps(pageProps); - setRouteError(routeError); - setRouteRedirect(routeRedirect); - setRouteValidated(true); + // Route has changed, wait until next function call.. } - }; - - useEffect(() => { - validateRoute(); - }, []); + } + const abortControllerRef = useRef(null); useEffect(() => { - if (hasRouteChanged) { - setValidationsRequested(requests => requests + 1); - setRouteError(null); - setRouteRedirect(null); - setRouteValidated(!hasGuards); - if (hasGuards) { - validateRoute(); + if (hasRouteChanged(routePrevProps, routeProps)) { + // Abort the previous validation when the route changes + if (abortControllerRef.current) { + abortControllerRef.current.abort(); } + // Then start route's guard validation anew! + const abortController = new AbortController(); + abortControllerRef.current = abortController; + onRouteChangeAsync(abortController.signal); } - }, [hasRouteChanged]); + }, [routeProps.match.path, routeProps.match.params]); - if (hasRouteChanged) { - if (hasGuards) { - return renderPage(LoadingPage, routeProps); + switch (status.type) { + case 'redirect': { + return ; + } + case 'render': { + return ( + + + + {children} + + + + ); } - // 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 'error': { + return renderPage(ErrorPage, { ...routeProps, error: status.error }); + } + case 'resolving': + default: { + return renderPage(LoadingPage, routeProps); } } - return ( - - - - {children} - - - - ); }; export default withRouter(Guard); diff --git a/package/src/GuardProvider.tsx b/package/src/GuardProvider.tsx index 7847d51..c3e5ed5 100644 --- a/package/src/GuardProvider.tsx +++ b/package/src/GuardProvider.tsx @@ -6,18 +6,8 @@ import { GuardProviderProps } from './types'; const GuardProvider: React.FunctionComponent< GuardProviderProps & RouteComponentProps> -> = ({ - children, - guards, - ignoreGlobal, - loading, - error, - history, - location, - match, - staticContext, -}) => { - const routeProps: RouteComponentProps = { history, location, match, staticContext }; +> = ({ children, guards, ignoreGlobal, loading, error, history, location, match }) => { + const routeProps: RouteComponentProps = { history, location, match }; const fromRouteProps = usePrevious(routeProps); const providerGuards = useGlobalGuards(guards, ignoreGlobal); diff --git a/package/src/GuardedRoute.tsx b/package/src/GuardedRoute.tsx index 1649073..c233967 100644 --- a/package/src/GuardedRoute.tsx +++ b/package/src/GuardedRoute.tsx @@ -27,21 +27,17 @@ const GuardedRoute: React.FunctionComponent = ({ const errorPage = useContext(ErrorPageContext); return ( - ( - - - - - {children} - - - - - )} - /> + + + + + + {children} + + + + + ); }; diff --git a/package/src/resolveGuards.ts b/package/src/resolveGuards.ts new file mode 100644 index 0000000..58abd9b --- /dev/null +++ b/package/src/resolveGuards.ts @@ -0,0 +1,93 @@ +import { RouteComponentProps } from 'react-router'; +import { + GuardFunction, + Next, + NextAction, + NextPropsPayload, + NextRedirectPayload, + GuardToRoute, +} from './types'; + +export type ResolvedGuardStatus = + | { type: 'error'; error: unknown } + | { type: 'redirect'; redirect: NextRedirectPayload } + | { type: 'render'; props: NextPropsPayload }; + +export type GuardStatus = { type: 'resolving' } | ResolvedGuardStatus; + +export interface ValidateRouteConfig { + guards: GuardFunction[]; + toRoute: GuardToRoute; + fromRoute: RouteComponentProps> | null; + signal: AbortSignal; +} + +const NextFunctionFactory = { + /** + * Builds a new next function using the given `resolve` callback. + */ + build: (resolve: (action: NextAction) => void): Next => { + function next() { + resolve({ type: 'continue' }); + } + + return Object.assign(next, { + props(payload: NextPropsPayload) { + resolve({ type: 'props', payload }); + }, + redirect(payload: NextRedirectPayload) { + resolve({ type: 'redirect', payload }); + }, + }); + }, +}; + +/** + * 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 + */ +function runGuard(guard: GuardFunction, config: ValidateRouteConfig) { + return new Promise(async (resolve, reject) => { + try { + await guard( + config.toRoute, + config.fromRoute, + NextFunctionFactory.build(resolve), + config.signal, + ); + } catch (error) { + reject(error); + } + }); +} + +/** + * 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. + */ +export async function resolveGuards(config: ValidateRouteConfig): Promise { + try { + let props: NextPropsPayload = {}; + for (const guard of config.guards) { + const action = await runGuard(guard, config); + if (action.type === 'redirect') { + return { type: 'redirect', redirect: action.payload }; + } else if (action.type === 'props') { + props = Object.assign(props, action.payload); + } + } + return { type: 'render', props }; + } catch (error) { + // If the guard fails because the signal is aborted, throw up the error + if (error && error.name === 'AbortError') { + throw error; + } + // Otherwise, return the error status with the guard-thrown error + return { type: 'error', error }; + } +} diff --git a/package/src/types.ts b/package/src/types.ts index 197f2e4..95c507f 100644 --- a/package/src/types.ts +++ b/package/src/types.ts @@ -7,39 +7,25 @@ import { RouteComponentProps, RouteProps } from 'react-router-dom'; */ 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; - export interface NextContinueAction { - type: GUARD_TYPES_CONTINUE; + type: 'continue'; payload?: any; } export type NextPropsPayload = Record; export interface NextPropsAction { - type: GUARD_TYPES_PROPS; + type: 'props'; payload: NextPropsPayload; } export type NextRedirectPayload = LocationDescriptor; export interface NextRedirectAction { - type: GUARD_TYPES_REDIRECT; + type: 'redirect'; payload: NextRedirectPayload; } export type NextAction = NextContinueAction | NextPropsAction | NextRedirectAction; +export type GuardType = NextAction['type']; export interface Next { (): void; @@ -55,6 +41,7 @@ export type GuardFunction = ( to: GuardToRoute, from: GuardFunctionRouteProps | null, next: Next, + signal: AbortSignal, ) => void; /** From 550d5af5510a8feacb38f3d658a182591dd418e2 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Wed, 15 Sep 2021 17:07:01 -0400 Subject: [PATCH 04/12] move logic out of useEffect, remove unused hooks --- .../src/containers/List/index.tsx | 24 ++++-- package/src/Guard.tsx | 78 ++++++------------- package/src/GuardProvider.tsx | 7 +- .../src/hooks/__tests__/usePrevious.test.tsx | 56 ------------- .../src/hooks/__tests__/useStateRef.test.tsx | 65 ---------------- .../__tests__/useStateWhenMounted.test.tsx | 60 -------------- package/src/hooks/index.ts | 3 - package/src/hooks/usePrevious.ts | 22 ------ package/src/hooks/useRouteChangeEffect.ts | 54 +++++++++++++ package/src/hooks/useStateRef.ts | 37 --------- package/src/hooks/useStateWhenMounted.ts | 38 --------- 11 files changed, 97 insertions(+), 347 deletions(-) delete mode 100644 package/src/hooks/__tests__/usePrevious.test.tsx delete mode 100644 package/src/hooks/__tests__/useStateRef.test.tsx delete mode 100644 package/src/hooks/__tests__/useStateWhenMounted.test.tsx delete mode 100644 package/src/hooks/usePrevious.ts create mode 100644 package/src/hooks/useRouteChangeEffect.ts delete mode 100644 package/src/hooks/useStateRef.ts delete mode 100644 package/src/hooks/useStateWhenMounted.ts diff --git a/demos/intermediate/src/containers/List/index.tsx b/demos/intermediate/src/containers/List/index.tsx index e970fd6..e3a9dd9 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 ( diff --git a/package/src/Guard.tsx b/package/src/Guard.tsx index 6598092..a23f8f0 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -1,33 +1,11 @@ -import React, { useContext, useRef, useState, useEffect } from 'react'; +import React, { useContext, useRef, useState } from 'react'; import { __RouterContext as RouterContext, RouteComponentProps, withRouter } from 'react-router'; import { Redirect, Route } from 'react-router-dom'; import { ErrorPageContext, GuardContext, LoadingPageContext, FromRouteContext } from './contexts'; import renderPage from './renderPage'; import { GuardProps } from './types'; import { GuardStatus, resolveGuards } from './resolveGuards'; - -function usePreviousV2(value: T, hasChanged: (from: T, to: T) => boolean) { - const ref = useRef<{ target: T; previous: T | null }>({ target: value, previous: null }); - - if (hasChanged(ref.current.target, value)) { - // The value changed. - ref.current.previous = ref.current.target; - ref.current.target = value; - } - - return ref.current.previous; -} - -function hasRouteChanged( - from: RouteComponentProps> | null, - to: RouteComponentProps>, -) { - return ( - !from || - to.match.path !== from.match.path || - Object.keys(to.match.params).some(key => to.match.params[key] !== from.match.params[key]) - ); -} +import { useRouteChangeEffect } from './hooks/useRouteChangeEffect'; const Guard: React.FunctionComponent>> = ({ children, @@ -58,51 +36,41 @@ const Guard: React.FunctionComponent { - const hasChanged = hasRouteChanged(from, to); - if (hasChanged) { - // Determine the next guard status - const nextStatus = getInitialStatus(); - // Update status for the *next* render - setStatus(nextStatus); - // Update status for the *current* render (based on the intention for the *next* render) - status = nextStatus; + const fromRouteProps = useContext(FromRouteContext); + + const abortControllerRef = useRef(null); + useRouteChangeEffect(routeProps, async () => { + // Abort the previous validation when the route changes + if (abortControllerRef.current) { + abortControllerRef.current.abort(); } - return hasChanged; - }); - const fromRouteProps = useContext(FromRouteContext); - async function onRouteChangeAsync(signal: AbortSignal): Promise { + // Determine the initial guard status for the new route + const nextStatus = getInitialStatus(); + // Update status for the *next* render + setStatus(nextStatus); // TODO: prevent setState on unmount + // Update status for the *current* render (based on the intention for the *next* render) + status = nextStatus; + + // Then start route's guard validation anew! + const abortController = new AbortController(); + abortControllerRef.current = abortController; try { // Resolve the guards to get the render status const status = await resolveGuards({ guards: guards || [], toRoute: { ...routeProps, meta: meta || {} }, fromRoute: fromRouteProps, - signal, + signal: abortController.signal, }); // If the signal hasn't been aborted, set the new status! - if (!signal.aborted) { - setStatus(status); + if (!abortController.signal.aborted) { + setStatus(status); // TODO: prevent setState on unmount } } catch (error) { // Route has changed, wait until next function call.. } - } - - const abortControllerRef = useRef(null); - useEffect(() => { - if (hasRouteChanged(routePrevProps, routeProps)) { - // Abort the previous validation when the route changes - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - // Then start route's guard validation anew! - const abortController = new AbortController(); - abortControllerRef.current = abortController; - onRouteChangeAsync(abortController.signal); - } - }, [routeProps.match.path, routeProps.match.params]); + }); switch (status.type) { case 'redirect': { diff --git a/package/src/GuardProvider.tsx b/package/src/GuardProvider.tsx index c3e5ed5..2542748 100644 --- a/package/src/GuardProvider.tsx +++ b/package/src/GuardProvider.tsx @@ -1,14 +1,15 @@ import React, { useContext } from 'react'; import { withRouter, RouteComponentProps } from 'react-router'; import { ErrorPageContext, FromRouteContext, GuardContext, LoadingPageContext } from './contexts'; -import { useGlobalGuards, usePrevious } from './hooks'; +import { useGlobalGuards } from './hooks'; import { GuardProviderProps } from './types'; +import { useRouteChangeEffect } from './hooks/useRouteChangeEffect'; const GuardProvider: React.FunctionComponent< GuardProviderProps & RouteComponentProps> > = ({ children, guards, ignoreGlobal, loading, error, history, location, match }) => { - const routeProps: RouteComponentProps = { history, location, match }; - const fromRouteProps = usePrevious(routeProps); + const routeProps = { history, location, match }; + const fromRouteProps = useRouteChangeEffect(routeProps, () => {}); const providerGuards = useGlobalGuards(guards, ignoreGlobal); 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 index 6055d87..814547c 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -1,4 +1 @@ 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/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/useRouteChangeEffect.ts b/package/src/hooks/useRouteChangeEffect.ts new file mode 100644 index 0000000..811627a --- /dev/null +++ b/package/src/hooks/useRouteChangeEffect.ts @@ -0,0 +1,54 @@ +import { useRef } from 'react'; +import { RouteComponentProps } from 'react-router'; + +export function getHasRouteChanged( + from: RouteComponentProps>, + to: RouteComponentProps>, +) { + // Perform shallow string comparison to check that path hasn't changed + const doPathsMatch = to.match.path === from.match.path; + if (!doPathsMatch) { + return true; + } + + // Perform deep object comparison to check that params haven't changed + const doParamsMatch = Object.keys(to.match.params).every( + key => to.match.params[key] === from.match.params[key], + ); + if (!doParamsMatch) { + return true; + } + + // If neither path nor params have changed, then route has stayed the same! + return false; +} + +export function useRouteChangeEffect( + value: RouteComponentProps>, + onChange: () => void, +): RouteComponentProps> | null { + // Store whether effect has init before in ref + const hasInitRef = useRef(false); + + // Store the current and previous values of variable in ref + const valuesRef = useRef<{ + target: RouteComponentProps>; + previous: RouteComponentProps> | null; + }>({ target: value, previous: null }); + + if (getHasRouteChanged(valuesRef.current.target, value)) { + // When the route changes, update previous + target values + valuesRef.current.previous = valuesRef.current.target; + valuesRef.current.target = value; + onChange(); + } else if (!hasInitRef.current) { + // Otherwise if the effect hasn't run before, run it now! + onChange(); + } + + // Always set hasInit to true (to prevent duplicate runs) + hasInitRef.current = true; + + // Then return the previous route (if any) + return valuesRef.current.previous; +} 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; From 26d7dc4380114dc7323834a82d2daae69d03fbe9 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Thu, 16 Sep 2021 09:51:49 -0400 Subject: [PATCH 05/12] moving prop types to components, adding comments, clarify resolveGuards signature --- .../src/containers/Detail/index.tsx | 9 +- package/src/Guard.tsx | 180 ++++++++++-------- package/src/GuardProvider.tsx | 23 ++- package/src/GuardedRoute.tsx | 16 +- .../hooks/__tests__/useGlobalGuards.test.tsx | 2 +- package/src/hooks/index.ts | 1 - package/src/hooks/useGlobalGuards.ts | 4 +- package/src/index.ts | 13 +- package/src/renderPage.tsx | 4 +- package/src/resolveGuards.ts | 53 +++--- package/src/types.ts | 26 +-- 11 files changed, 171 insertions(+), 160 deletions(-) delete mode 100644 package/src/hooks/index.ts diff --git a/demos/intermediate/src/containers/Detail/index.tsx b/demos/intermediate/src/containers/Detail/index.tsx index 184d646..b9e9d54 100644 --- a/demos/intermediate/src/containers/Detail/index.tsx +++ b/demos/intermediate/src/containers/Detail/index.tsx @@ -166,7 +166,12 @@ const Detail: React.FunctionComponent = ({ export default Detail; -export const beforeRouteEnter: GuardFunction = async (to, from, next, signal) => { +export const beforeRouteEnter: GuardFunction<{ pokemon: SerializedPokemon }> = async ( + to, + from, + next, + signal, +) => { const { name } = to.match.params; try { const pokemon = await api.get(name, { signal }); @@ -174,7 +179,7 @@ export const beforeRouteEnter: GuardFunction = async (to, from, next, signal) => pokemon: serializePokemon(pokemon), }); } catch (error) { - if (error && error.name !== 'AbortError') { + if (!(error && error.name === 'AbortError')) { throw new Error('Pokemon does not exist.'); } } diff --git a/package/src/Guard.tsx b/package/src/Guard.tsx index a23f8f0..acf92e9 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -1,100 +1,112 @@ -import React, { useContext, useRef, useState } from 'react'; -import { __RouterContext as RouterContext, RouteComponentProps, withRouter } from 'react-router'; +import React, { useContext, useRef, useState, useEffect } from 'react'; +import { + __RouterContext as RouterContext, + RouteComponentProps, + withRouter, + RouteProps, +} from 'react-router'; import { Redirect, Route } from 'react-router-dom'; import { ErrorPageContext, GuardContext, LoadingPageContext, FromRouteContext } from './contexts'; -import renderPage from './renderPage'; -import { GuardProps } from './types'; +import { renderPage } from './renderPage'; import { GuardStatus, resolveGuards } from './resolveGuards'; import { useRouteChangeEffect } from './hooks/useRouteChangeEffect'; +import { Meta } from './types'; -const Guard: React.FunctionComponent>> = ({ - children, - component, - meta, - render, - history, - location, - match, -}) => { - const LoadingPage = useContext(LoadingPageContext); - const ErrorPage = useContext(ErrorPageContext); +export interface GuardProps extends RouteProps { + meta?: Meta; + name?: RouteProps['path']; +} - const guards = useContext(GuardContext); - const hasGuards = !!guards && guards.length > 0; +export const Guard = withRouter>>( + function GuardWithRouter({ children, component, meta, render, history, location, match }) { + // Track whether the component is mounted to prevent setting state after unmount + const isMountedRef = useRef(true); + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); - function getInitialStatus(): GuardStatus { - if (hasGuards) { + const guards = useContext(GuardContext); + function getInitialStatus(): GuardStatus { + // If there are no guards in context, the route should immediately render + if (!guards || guards.length === 0) { + return { type: 'render', props: {} }; + } + // Otherwise, the component should start resolving return { type: 'resolving' }; - } else { - return { type: 'render', props: {} }; } - } + // 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; - // 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; + const routeProps = { history, location, match }; + 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; + } - const routeProps = { history, location, match }; - const fromRouteProps = useContext(FromRouteContext); + // 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; - const abortControllerRef = useRef(null); - useRouteChangeEffect(routeProps, async () => { - // Abort the previous validation when the route changes - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } + // If the next status is to resolve guards, do so! + if (status.type === 'resolving') { + const abortController = new AbortController(); + routeChangeAbortControllerRef.current = abortController; + try { + // Resolve the guards to get the render status + const status = await resolveGuards(guards || [], { + to: { ...routeProps, meta: meta || {} }, + from: fromRouteProps, + signal: abortController.signal, + }); + // If the signal hasn't been aborted, set the new status! + if (isMountedRef.current && !abortController.signal.aborted) { + setStatus(status); + } + } catch (error) { + // Route has changed, wait until the effect runs again... + } + } + }); - // Determine the initial guard status for the new route - const nextStatus = getInitialStatus(); - // Update status for the *next* render - setStatus(nextStatus); // TODO: prevent setState on unmount - // Update status for the *current* render (based on the intention for the *next* render) - status = nextStatus; + const LoadingPage = useContext(LoadingPageContext); + const ErrorPage = useContext(ErrorPageContext); - // Then start route's guard validation anew! - const abortController = new AbortController(); - abortControllerRef.current = abortController; - try { - // Resolve the guards to get the render status - const status = await resolveGuards({ - guards: guards || [], - toRoute: { ...routeProps, meta: meta || {} }, - fromRoute: fromRouteProps, - signal: abortController.signal, - }); - // If the signal hasn't been aborted, set the new status! - if (!abortController.signal.aborted) { - setStatus(status); // TODO: prevent setState on unmount + switch (status.type) { + case 'redirect': { + return ; + } + case 'render': { + return ( + + + + {children} + + + + ); + } + case 'error': { + return renderPage(ErrorPage, { ...routeProps, error: status.error }); + } + case 'resolving': + default: { + return renderPage(LoadingPage, routeProps); } - } catch (error) { - // Route has changed, wait until next function call.. - } - }); - - switch (status.type) { - case 'redirect': { - return ; - } - case 'render': { - return ( - - - - {children} - - - - ); - } - case 'error': { - return renderPage(ErrorPage, { ...routeProps, error: status.error }); - } - case 'resolving': - default: { - return renderPage(LoadingPage, routeProps); } - } -}; - -export default withRouter(Guard); + }, +); diff --git a/package/src/GuardProvider.tsx b/package/src/GuardProvider.tsx index 2542748..a1a8654 100644 --- a/package/src/GuardProvider.tsx +++ b/package/src/GuardProvider.tsx @@ -1,13 +1,24 @@ import React, { useContext } from 'react'; import { withRouter, RouteComponentProps } from 'react-router'; import { ErrorPageContext, FromRouteContext, GuardContext, LoadingPageContext } from './contexts'; -import { useGlobalGuards } from './hooks'; -import { GuardProviderProps } from './types'; +import { useGlobalGuards } from './hooks/useGlobalGuards'; +import { BaseGuardProps } from './types'; import { useRouteChangeEffect } from './hooks/useRouteChangeEffect'; -const GuardProvider: React.FunctionComponent< +export type GuardProviderProps = BaseGuardProps; + +export const GuardProvider = withRouter< GuardProviderProps & RouteComponentProps> -> = ({ children, guards, ignoreGlobal, loading, error, history, location, match }) => { +>(function GuardProviderWithRouter({ + children, + guards, + ignoreGlobal, + loading, + error, + history, + location, + match, +}) { const routeProps = { history, location, match }; const fromRouteProps = useRouteChangeEffect(routeProps, () => {}); @@ -25,6 +36,4 @@ const GuardProvider: React.FunctionComponent< ); -}; - -export default withRouter(GuardProvider); +}); diff --git a/package/src/GuardedRoute.tsx b/package/src/GuardedRoute.tsx index c233967..72b985c 100644 --- a/package/src/GuardedRoute.tsx +++ b/package/src/GuardedRoute.tsx @@ -1,12 +1,16 @@ import React, { useContext } from 'react'; -import { Route } from 'react-router-dom'; +import { Route, RouteProps } from 'react-router-dom'; import invariant from 'tiny-invariant'; -import Guard from './Guard'; +import { Guard } from './Guard'; import { ErrorPageContext, GuardContext, LoadingPageContext } from './contexts'; -import { useGlobalGuards } from './hooks'; -import { GuardedRouteProps } from './types'; +import { useGlobalGuards } from './hooks/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, @@ -40,5 +44,3 @@ const GuardedRoute: React.FunctionComponent = ({ ); }; - -export default GuardedRoute; diff --git a/package/src/hooks/__tests__/useGlobalGuards.test.tsx b/package/src/hooks/__tests__/useGlobalGuards.test.tsx index 8a53008..0c6b9c3 100644 --- a/package/src/hooks/__tests__/useGlobalGuards.test.tsx +++ b/package/src/hooks/__tests__/useGlobalGuards.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { GuardFunction } from '../../types'; import { GuardContext } from '../../contexts'; -import useGlobalGuards from '../useGlobalGuards'; +import { useGlobalGuards } from '../useGlobalGuards'; const guardOne: GuardFunction = (to, from, next) => next(); const guardTwo: GuardFunction = (to, from, next) => next.props({}); diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts deleted file mode 100644 index 814547c..0000000 --- a/package/src/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as useGlobalGuards } from './useGlobalGuards'; diff --git a/package/src/hooks/useGlobalGuards.ts b/package/src/hooks/useGlobalGuards.ts index 549d005..77ee2c5 100644 --- a/package/src/hooks/useGlobalGuards.ts +++ b/package/src/hooks/useGlobalGuards.ts @@ -10,7 +10,7 @@ import { GuardFunction } from '../types'; * @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) => { +export const useGlobalGuards = (guards: GuardFunction[] = [], ignoreGlobal: boolean = false) => { const globalGuards = useContext(GuardContext); const componentGuards = useMemo(() => { @@ -24,5 +24,3 @@ const useGlobalGuards = (guards: GuardFunction[] = [], ignoreGlobal: boolean = f return componentGuards; }; - -export default useGlobalGuards; diff --git a/package/src/index.ts b/package/src/index.ts index cdf1dbe..ae5b83e 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,10 +1,3 @@ -export { default as GuardProvider } from './GuardProvider'; -export { default as GuardedRoute } from './GuardedRoute'; -export { - BaseGuardProps, - GuardedRouteProps, - GuardFunction, - GuardProviderProps, - Next, - PageComponent, -} from './types'; +export { GuardProvider, GuardProviderProps } from './GuardProvider'; +export { GuardedRoute, GuardedRouteProps } from './GuardedRoute'; +export { BaseGuardProps, GuardFunction, Next, PageComponent } from './types'; diff --git a/package/src/renderPage.tsx b/package/src/renderPage.tsx index d44d09b..e9aa191 100644 --- a/package/src/renderPage.tsx +++ b/package/src/renderPage.tsx @@ -10,7 +10,7 @@ type BaseProps = Record; * @param props the props to pass to the page * @returns the page component */ -function renderPage( +export function renderPage( page: PageComponent, props?: Props, ): React.ReactElement | null { @@ -21,5 +21,3 @@ function renderPage( } return {page}; } - -export default renderPage; diff --git a/package/src/resolveGuards.ts b/package/src/resolveGuards.ts index 58abd9b..ee3dd46 100644 --- a/package/src/resolveGuards.ts +++ b/package/src/resolveGuards.ts @@ -15,10 +15,9 @@ export type ResolvedGuardStatus = export type GuardStatus = { type: 'resolving' } | ResolvedGuardStatus; -export interface ValidateRouteConfig { - guards: GuardFunction[]; - toRoute: GuardToRoute; - fromRoute: RouteComponentProps> | null; +export interface ResolveGuardsContext { + to: GuardToRoute; + from: RouteComponentProps> | null; signal: AbortSignal; } @@ -26,7 +25,7 @@ const NextFunctionFactory = { /** * Builds a new next function using the given `resolve` callback. */ - build: (resolve: (action: NextAction) => void): Next => { + build: (resolve: (action: NextAction) => void): Next<{}> => { function next() { resolve({ type: 'continue' }); } @@ -43,22 +42,17 @@ const NextFunctionFactory = { }; /** - * 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. + * Handles running a single guard function in the given context. + * Bubbles up any errors thrown in the guard. * * @param guard the guard function - * @returns a Promise returning the guard payload + * @param context the context of this guard's resolution + * @returns a Promise returning the resolved guard action */ -function runGuard(guard: GuardFunction, config: ValidateRouteConfig) { +function runGuard(guard: GuardFunction, context: ResolveGuardsContext): Promise { return new Promise(async (resolve, reject) => { try { - await guard( - config.toRoute, - config.fromRoute, - NextFunctionFactory.build(resolve), - config.signal, - ); + await guard(context.to, context.from, NextFunctionFactory.build(resolve), context.signal); } catch (error) { reject(error); } @@ -66,24 +60,37 @@ function runGuard(guard: GuardFunction, config: ValidateRouteConfig) { } /** - * 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. + * 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 props + * + * 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' resolution + * @returns a Promise returning the resolved guards' status */ -export async function resolveGuards(config: ValidateRouteConfig): Promise { +export async function resolveGuards( + guards: GuardFunction[], + context: ResolveGuardsContext, +): Promise { try { let props: NextPropsPayload = {}; - for (const guard of config.guards) { - const action = await runGuard(guard, config); + for (const guard of guards) { + const action = await runGuard(guard, context); if (action.type === 'redirect') { + // If the guard calls for a redirect, do so immediately! return { type: 'redirect', redirect: action.payload }; } else if (action.type === 'props') { + // Otherwise, continue to merge props props = Object.assign(props, action.payload); } } + // Then return the props after all guards have resolved return { type: 'render', props }; } catch (error) { - // If the guard fails because the signal is aborted, throw up the error + // If the guard fails because the signal is aborted, bubbles up the error if (error && error.name === 'AbortError') { throw error; } diff --git a/package/src/types.ts b/package/src/types.ts index 95c507f..c7bd45d 100644 --- a/package/src/types.ts +++ b/package/src/types.ts @@ -1,15 +1,14 @@ import { ComponentType } from 'react'; import { LocationDescriptor } from 'history'; -import { RouteComponentProps, RouteProps } from 'react-router-dom'; +import { RouteComponentProps } from 'react-router-dom'; /** * General */ export type Meta = Record; -export type RouteMatchParams = Record; + export interface NextContinueAction { type: 'continue'; - payload?: any; } export type NextPropsPayload = Record; @@ -25,22 +24,21 @@ export interface NextRedirectAction { } export type NextAction = NextContinueAction | NextPropsAction | NextRedirectAction; -export type GuardType = NextAction['type']; -export interface Next { +export interface Next { (): void; - props(props: NextPropsPayload): void; + props(props: Props): void; redirect(to: LocationDescriptor): void; } -export type GuardFunctionRouteProps = RouteComponentProps; +export type GuardFunctionRouteProps = RouteComponentProps>; export type GuardToRoute = GuardFunctionRouteProps & { meta: Meta; }; -export type GuardFunction = ( +export type GuardFunction = ( to: GuardToRoute, from: GuardFunctionRouteProps | null, - next: Next, + next: Next, signal: AbortSignal, ) => void; @@ -58,13 +56,3 @@ export interface BaseGuardProps { loading?: PageComponent; error?: PageComponent; } - -export type PropsWithMeta = T & { - meta?: Meta; -}; - -export type GuardProviderProps = BaseGuardProps; -export type GuardedRouteProps = PropsWithMeta; -export type GuardProps = PropsWithMeta & { - name?: string | string[]; -}; From 87952933a209381e72e02f59101a1db4efc7b4ed Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Thu, 16 Sep 2021 10:30:32 -0400 Subject: [PATCH 06/12] rename PageComponent -> Page, remove renderPage in favor of inlining --- package/src/Guard.tsx | 202 +++++++++++++--------- package/src/GuardProvider.tsx | 54 +++--- package/src/GuardedRoute.tsx | 2 +- package/src/contexts.ts | 6 +- package/src/hooks/useGlobalGuards.ts | 15 +- package/src/hooks/useRouteChangeEffect.ts | 26 +-- package/src/index.ts | 9 +- package/src/renderPage.tsx | 23 --- package/src/types.ts | 37 ++-- 9 files changed, 201 insertions(+), 173 deletions(-) delete mode 100644 package/src/renderPage.tsx diff --git a/package/src/Guard.tsx b/package/src/Guard.tsx index acf92e9..8bec516 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useRef, useState, useEffect } from 'react'; +import React, { useContext, useRef, useState, useEffect, Fragment, createElement } from 'react'; import { __RouterContext as RouterContext, RouteComponentProps, @@ -7,106 +7,140 @@ import { } from 'react-router'; import { Redirect, Route } from 'react-router-dom'; import { ErrorPageContext, GuardContext, LoadingPageContext, FromRouteContext } from './contexts'; -import { renderPage } from './renderPage'; import { GuardStatus, resolveGuards } from './resolveGuards'; import { useRouteChangeEffect } from './hooks/useRouteChangeEffect'; -import { Meta } from './types'; +import { Meta, Page, PageComponentType } from './types'; + +/** + * Checks whether the given page component is a React component type. + * + * @param pageComponent the page component to check + */ +function isPageComponentType

(pageComponent: Page

): pageComponent is PageComponentType

{ + return ( + !!pageComponent && + typeof pageComponent !== 'string' && + typeof pageComponent !== 'boolean' && + typeof pageComponent !== 'number' + ); +} export interface GuardProps extends RouteProps { meta?: Meta; name?: RouteProps['path']; } -export const Guard = withRouter>>( - function GuardWithRouter({ children, component, meta, render, history, location, match }) { - // Track whether the component is mounted to prevent setting state after unmount - const isMountedRef = useRef(true); - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; - }, []); +export const Guard = withRouter(function GuardWithRouter({ + children, + component, + meta, + render, + history, + location, + match, +}) { + // 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); - function getInitialStatus(): GuardStatus { - // If there are no guards in context, the route should immediately render - if (!guards || guards.length === 0) { - return { type: 'render', props: {} }; - } - // Otherwise, the component should start resolving - return { type: 'resolving' }; + const guards = useContext(GuardContext); + function getInitialStatus(): GuardStatus { + // If there are no guards in context, the route should immediately render + if (!guards || guards.length === 0) { + return { type: 'render', props: {} }; } - // 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; + // 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; - const routeProps = { history, location, match }; - 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; - } + const routeProps = { history, location, match }; + 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; + } - // 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; + // 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; - try { - // Resolve the guards to get the render status - const status = await resolveGuards(guards || [], { - to: { ...routeProps, meta: meta || {} }, - from: fromRouteProps, - signal: abortController.signal, - }); - // If the signal hasn't been aborted, set the new status! - if (isMountedRef.current && !abortController.signal.aborted) { - setStatus(status); - } - } catch (error) { - // Route has changed, wait until the effect runs again... + // If the next status is to resolve guards, do so! + if (status.type === 'resolving') { + const abortController = new AbortController(); + routeChangeAbortControllerRef.current = abortController; + try { + // Resolve the guards to get the render status + const status = await resolveGuards(guards || [], { + to: { ...routeProps, meta: meta || {} }, + from: fromRouteProps, + signal: abortController.signal, + }); + // If the signal hasn't been aborted, set the new status! + if (isMountedRef.current && !abortController.signal.aborted) { + setStatus(status); } + } catch (error) { + // Route has changed, wait until the effect runs again... } - }); + } + }); - const LoadingPage = useContext(LoadingPageContext); - const ErrorPage = useContext(ErrorPageContext); + const loadingPage = useContext(LoadingPageContext); + const errorPage = useContext(ErrorPageContext); - switch (status.type) { - case 'redirect': { - return ; - } - case 'render': { - return ( - - - - {children} - - - - ); - } - case 'error': { - return renderPage(ErrorPage, { ...routeProps, error: status.error }); + switch (status.type) { + case 'redirect': { + return ; + } + + case 'render': { + return ( + + + + {children} + + + + ); + } + + case 'error': { + if (isPageComponentType(errorPage)) { + return createElement(errorPage, { ...routeProps, error: status.error }); } - case 'resolving': - default: { - return renderPage(LoadingPage, routeProps); + return {errorPage}; + } + + case 'resolving': + default: { + if (isPageComponentType(loadingPage)) { + return createElement(loadingPage, routeProps); } + return {loadingPage}; } - }, -); + } +}); diff --git a/package/src/GuardProvider.tsx b/package/src/GuardProvider.tsx index a1a8654..7cd01d2 100644 --- a/package/src/GuardProvider.tsx +++ b/package/src/GuardProvider.tsx @@ -7,33 +7,33 @@ import { useRouteChangeEffect } from './hooks/useRouteChangeEffect'; export type GuardProviderProps = BaseGuardProps; -export const GuardProvider = withRouter< - GuardProviderProps & RouteComponentProps> ->(function GuardProviderWithRouter({ - children, - guards, - ignoreGlobal, - loading, - error, - history, - location, - match, -}) { - const routeProps = { history, location, match }; - const fromRouteProps = useRouteChangeEffect(routeProps, () => {}); +export const GuardProvider = withRouter( + function GuardProviderWithRouter({ + children, + guards, + ignoreGlobal, + loading, + error, + history, + location, + match, + }) { + const routeProps = { history, location, match }; + const fromRouteProps = useRouteChangeEffect(routeProps, () => {}); - const providerGuards = useGlobalGuards(guards, ignoreGlobal); + const providerGuards = useGlobalGuards(guards, ignoreGlobal); - const loadingPage = useContext(LoadingPageContext); - const errorPage = useContext(ErrorPageContext); + const loadingPage = useContext(LoadingPageContext); + const errorPage = useContext(ErrorPageContext); - return ( - - - - {children} - - - - ); -}); + return ( + + + + {children} + + + + ); + }, +); diff --git a/package/src/GuardedRoute.tsx b/package/src/GuardedRoute.tsx index 72b985c..834abbd 100644 --- a/package/src/GuardedRoute.tsx +++ b/package/src/GuardedRoute.tsx @@ -35,7 +35,7 @@ export const GuardedRoute: React.FunctionComponent = ({ - + {children} diff --git a/package/src/contexts.ts b/package/src/contexts.ts index 145346e..b380382 100644 --- a/package/src/contexts.ts +++ b/package/src/contexts.ts @@ -1,11 +1,11 @@ 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 LoadingPageContext = createContext(null); diff --git a/package/src/hooks/useGlobalGuards.ts b/package/src/hooks/useGlobalGuards.ts index 77ee2c5..45a5c36 100644 --- a/package/src/hooks/useGlobalGuards.ts +++ b/package/src/hooks/useGlobalGuards.ts @@ -1,4 +1,4 @@ -import { useContext, useMemo, useDebugValue } from 'react'; +import { useContext } from 'react'; import { GuardContext } from '../contexts'; import { GuardFunction } from '../types'; @@ -13,14 +13,9 @@ import { GuardFunction } from '../types'; export const useGlobalGuards = (guards: GuardFunction[] = [], ignoreGlobal: boolean = false) => { const globalGuards = useContext(GuardContext); - const componentGuards = useMemo(() => { - if (ignoreGlobal) { - return [...guards]; - } + if (ignoreGlobal) { + return [...guards]; + } else { return [...(globalGuards || []), ...guards]; - }, [guards, ignoreGlobal]); - - useDebugValue(componentGuards.map(({ name }) => name).join(' | ')); - - return componentGuards; + } }; diff --git a/package/src/hooks/useRouteChangeEffect.ts b/package/src/hooks/useRouteChangeEffect.ts index 811627a..9293c95 100644 --- a/package/src/hooks/useRouteChangeEffect.ts +++ b/package/src/hooks/useRouteChangeEffect.ts @@ -12,6 +12,7 @@ export function getHasRouteChanged( } // 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(to.match.params).every( key => to.match.params[key] === from.match.params[key], ); @@ -24,30 +25,31 @@ export function getHasRouteChanged( } export function useRouteChangeEffect( - value: RouteComponentProps>, + value: RouteComponentProps, onChange: () => void, -): RouteComponentProps> | null { - // Store whether effect has init before in ref - const hasInitRef = useRef(false); +): RouteComponentProps | null { + // Store whether effect has run before in ref + const hasEffectRunRef = useRef(false); // Store the current and previous values of variable in ref - const valuesRef = useRef<{ - target: RouteComponentProps>; - previous: RouteComponentProps> | null; - }>({ target: value, previous: null }); + // https://dev.to/chrismilson/problems-with-useprevious-me + const valuesRef = useRef<{ target: RouteComponentProps; previous: RouteComponentProps | null }>({ + target: value, + previous: null, + }); if (getHasRouteChanged(valuesRef.current.target, value)) { - // When the route changes, update previous + target values + // When the route changes, update previous + target values and run the effect valuesRef.current.previous = valuesRef.current.target; valuesRef.current.target = value; onChange(); - } else if (!hasInitRef.current) { + } else if (!hasEffectRunRef.current) { // Otherwise if the effect hasn't run before, run it now! onChange(); } - // Always set hasInit to true (to prevent duplicate runs) - hasInitRef.current = true; + // Always set hasEffectRun to true (to prevent duplicate runs) + hasEffectRunRef.current = true; // Then return the previous route (if any) return valuesRef.current.previous; diff --git a/package/src/index.ts b/package/src/index.ts index ae5b83e..09013f1 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,3 +1,10 @@ export { GuardProvider, GuardProviderProps } from './GuardProvider'; export { GuardedRoute, GuardedRouteProps } from './GuardedRoute'; -export { BaseGuardProps, GuardFunction, Next, PageComponent } from './types'; +export { + BaseGuardProps, + GuardFunction, + Next, + Page, + LoadingPageComponentType, + ErrorPageComponentType, +} from './types'; diff --git a/package/src/renderPage.tsx b/package/src/renderPage.tsx deleted file mode 100644 index e9aa191..0000000 --- a/package/src/renderPage.tsx +++ /dev/null @@ -1,23 +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 - */ -export 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}; -} diff --git a/package/src/types.ts b/package/src/types.ts index c7bd45d..490046c 100644 --- a/package/src/types.ts +++ b/package/src/types.ts @@ -2,11 +2,14 @@ import { ComponentType } from 'react'; import { LocationDescriptor } from 'history'; import { RouteComponentProps } from 'react-router-dom'; -/** - * General - */ +/////////////////////////////// +// General +/////////////////////////////// export type Meta = Record; +/////////////////////////////// +// Next Functions +/////////////////////////////// export interface NextContinueAction { type: 'continue'; } @@ -31,6 +34,9 @@ export interface Next { redirect(to: LocationDescriptor): void; } +/////////////////////////////// +// Guards +/////////////////////////////// export type GuardFunctionRouteProps = RouteComponentProps>; export type GuardToRoute = GuardFunctionRouteProps & { meta: Meta; @@ -42,17 +48,24 @@ export type GuardFunction = ( signal: AbortSignal, ) => void; -/** - * Page Component Types - */ -export type PageComponent = ComponentType | null | undefined | string | boolean | number; +/////////////////////////////// +// Page Types +/////////////////////////////// +export type PageComponentType

= ComponentType; +export type Page

= PageComponentType

| null | undefined | 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?: GuardFunction[]; ignoreGlobal?: boolean; - loading?: PageComponent; - error?: PageComponent; + loading?: LoadingPage; + error?: ErrorPage; } From 3dab53fa01a1a4cad86f28a3dc36b1c2950f8b26 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Thu, 16 Sep 2021 10:33:53 -0400 Subject: [PATCH 07/12] move hooks top-level --- package/src/Guard.tsx | 2 +- package/src/GuardProvider.tsx | 4 +- package/src/GuardedRoute.tsx | 2 +- package/src/__tests__/renderPage.test.tsx | 72 ------------------- .../__tests__/useGlobalGuards.test.tsx | 4 +- package/src/{hooks => }/useGlobalGuards.ts | 4 +- .../src/{hooks => }/useRouteChangeEffect.ts | 0 7 files changed, 8 insertions(+), 80 deletions(-) delete mode 100644 package/src/__tests__/renderPage.test.tsx rename package/src/{hooks => }/__tests__/useGlobalGuards.test.tsx (97%) rename package/src/{hooks => }/useGlobalGuards.ts (86%) rename package/src/{hooks => }/useRouteChangeEffect.ts (100%) diff --git a/package/src/Guard.tsx b/package/src/Guard.tsx index 8bec516..fa06df5 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -8,7 +8,7 @@ import { import { Redirect, Route } from 'react-router-dom'; import { ErrorPageContext, GuardContext, LoadingPageContext, FromRouteContext } from './contexts'; import { GuardStatus, resolveGuards } from './resolveGuards'; -import { useRouteChangeEffect } from './hooks/useRouteChangeEffect'; +import { useRouteChangeEffect } from './useRouteChangeEffect'; import { Meta, Page, PageComponentType } from './types'; /** diff --git a/package/src/GuardProvider.tsx b/package/src/GuardProvider.tsx index 7cd01d2..929c31c 100644 --- a/package/src/GuardProvider.tsx +++ b/package/src/GuardProvider.tsx @@ -1,9 +1,9 @@ import React, { useContext } from 'react'; import { withRouter, RouteComponentProps } from 'react-router'; import { ErrorPageContext, FromRouteContext, GuardContext, LoadingPageContext } from './contexts'; -import { useGlobalGuards } from './hooks/useGlobalGuards'; +import { useGlobalGuards } from './useGlobalGuards'; import { BaseGuardProps } from './types'; -import { useRouteChangeEffect } from './hooks/useRouteChangeEffect'; +import { useRouteChangeEffect } from './useRouteChangeEffect'; export type GuardProviderProps = BaseGuardProps; diff --git a/package/src/GuardedRoute.tsx b/package/src/GuardedRoute.tsx index 834abbd..ca5a305 100644 --- a/package/src/GuardedRoute.tsx +++ b/package/src/GuardedRoute.tsx @@ -3,7 +3,7 @@ import { Route, RouteProps } from 'react-router-dom'; import invariant from 'tiny-invariant'; import { Guard } from './Guard'; import { ErrorPageContext, GuardContext, LoadingPageContext } from './contexts'; -import { useGlobalGuards } from './hooks/useGlobalGuards'; +import { useGlobalGuards } from './useGlobalGuards'; import { BaseGuardProps, Meta } from './types'; export interface GuardedRouteProps extends BaseGuardProps, RouteProps { 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 97% rename from package/src/hooks/__tests__/useGlobalGuards.test.tsx rename to package/src/__tests__/useGlobalGuards.test.tsx index 0c6b9c3..a574b53 100644 --- a/package/src/hooks/__tests__/useGlobalGuards.test.tsx +++ b/package/src/__tests__/useGlobalGuards.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; -import { GuardFunction } from '../../types'; -import { GuardContext } from '../../contexts'; +import { GuardFunction } from '../types'; +import { GuardContext } from '../contexts'; import { useGlobalGuards } from '../useGlobalGuards'; const guardOne: GuardFunction = (to, from, next) => next(); diff --git a/package/src/hooks/useGlobalGuards.ts b/package/src/useGlobalGuards.ts similarity index 86% rename from package/src/hooks/useGlobalGuards.ts rename to package/src/useGlobalGuards.ts index 45a5c36..6e06e84 100644 --- a/package/src/hooks/useGlobalGuards.ts +++ b/package/src/useGlobalGuards.ts @@ -1,6 +1,6 @@ import { useContext } from 'react'; -import { GuardContext } from '../contexts'; -import { GuardFunction } from '../types'; +import { GuardContext } from './contexts'; +import { GuardFunction } from './types'; /** * React hook for creating the guards array for a Guarded diff --git a/package/src/hooks/useRouteChangeEffect.ts b/package/src/useRouteChangeEffect.ts similarity index 100% rename from package/src/hooks/useRouteChangeEffect.ts rename to package/src/useRouteChangeEffect.ts From 2ac45d2f08edfc40b987a2f32059b82d2b45951b Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Thu, 16 Sep 2021 11:14:03 -0400 Subject: [PATCH 08/12] cleanup useRouteChangeEffect with comments, remove Guard name prop --- package/src/Guard.tsx | 12 +++---- package/src/GuardedRoute.tsx | 2 +- package/src/useGlobalGuards.ts | 3 +- package/src/useRouteChangeEffect.ts | 54 ++++++++++++++++++++--------- 4 files changed, 44 insertions(+), 27 deletions(-) diff --git a/package/src/Guard.tsx b/package/src/Guard.tsx index fa06df5..1cbd955 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -12,22 +12,18 @@ import { useRouteChangeEffect } from './useRouteChangeEffect'; import { Meta, Page, PageComponentType } from './types'; /** - * Checks whether the given page component is a React component type. + * Type checks whether the given page is a React component type. * - * @param pageComponent the page component to check + * @param page the page to type check */ -function isPageComponentType

(pageComponent: Page

): pageComponent is PageComponentType

{ +function isPageComponentType

(page: Page

): page is PageComponentType

{ return ( - !!pageComponent && - typeof pageComponent !== 'string' && - typeof pageComponent !== 'boolean' && - typeof pageComponent !== 'number' + !!page && typeof page !== 'string' && typeof page !== 'boolean' && typeof page !== 'number' ); } export interface GuardProps extends RouteProps { meta?: Meta; - name?: RouteProps['path']; } export const Guard = withRouter(function GuardWithRouter({ diff --git a/package/src/GuardedRoute.tsx b/package/src/GuardedRoute.tsx index ca5a305..898b4d0 100644 --- a/package/src/GuardedRoute.tsx +++ b/package/src/GuardedRoute.tsx @@ -35,7 +35,7 @@ export const GuardedRoute: React.FunctionComponent = ({ - + {children} diff --git a/package/src/useGlobalGuards.ts b/package/src/useGlobalGuards.ts index 6e06e84..6088c1c 100644 --- a/package/src/useGlobalGuards.ts +++ b/package/src/useGlobalGuards.ts @@ -3,8 +3,7 @@ import { GuardContext } from './contexts'; import { GuardFunction } from './types'; /** - * React hook for creating the guards array for a Guarded - * component. + * 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 diff --git a/package/src/useRouteChangeEffect.ts b/package/src/useRouteChangeEffect.ts index 9293c95..6b59820 100644 --- a/package/src/useRouteChangeEffect.ts +++ b/package/src/useRouteChangeEffect.ts @@ -1,20 +1,28 @@ 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( - from: RouteComponentProps>, - to: RouteComponentProps>, + routeA: RouteComponentProps>, + routeB: RouteComponentProps>, ) { // Perform shallow string comparison to check that path hasn't changed - const doPathsMatch = to.match.path === from.match.path; + 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(to.match.params).every( - key => to.match.params[key] === from.match.params[key], + const doParamsMatch = Object.keys(routeA.match.params).every( + key => routeA.match.params[key] === routeB.match.params[key], ); if (!doParamsMatch) { return true; @@ -24,33 +32,47 @@ export function getHasRouteChanged( 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( - value: RouteComponentProps, - onChange: () => void, + 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 variable in ref + // Store the current and previous values of route in ref // https://dev.to/chrismilson/problems-with-useprevious-me - const valuesRef = useRef<{ target: RouteComponentProps; previous: RouteComponentProps | null }>({ - target: value, + const routeStoreRef = useRef<{ + target: RouteComponentProps; + previous: RouteComponentProps | null; + }>({ + target: route, previous: null, }); - if (getHasRouteChanged(valuesRef.current.target, value)) { + if (getHasRouteChanged(routeStoreRef.current.target, route)) { // When the route changes, update previous + target values and run the effect - valuesRef.current.previous = valuesRef.current.target; - valuesRef.current.target = value; - onChange(); + 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! - onChange(); + onInitOrChange(); } // Always set hasEffectRun to true (to prevent duplicate runs) hasEffectRunRef.current = true; // Then return the previous route (if any) - return valuesRef.current.previous; + return routeStoreRef.current.previous; } From ea902fd4202f64618d42ecae0613516473198f79 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Thu, 16 Sep 2021 11:48:29 -0400 Subject: [PATCH 09/12] inherit from route from parent, only use page override when not undefined --- .../src/containers/Detail/index.tsx | 6 ++-- package/src/Guard.tsx | 18 +++++++--- package/src/GuardProvider.tsx | 24 +++++++++---- package/src/GuardedRoute.tsx | 10 +++--- package/src/index.ts | 2 +- package/src/resolveGuards.ts | 18 +++++----- package/src/types.ts | 35 ++++++++++++++----- 7 files changed, 75 insertions(+), 38 deletions(-) diff --git a/demos/intermediate/src/containers/Detail/index.tsx b/demos/intermediate/src/containers/Detail/index.tsx index b9e9d54..937deff 100644 --- a/demos/intermediate/src/containers/Detail/index.tsx +++ b/demos/intermediate/src/containers/Detail/index.tsx @@ -170,16 +170,16 @@ export const beforeRouteEnter: GuardFunction<{ pokemon: SerializedPokemon }> = a to, from, next, - signal, + ctx, ) => { const { name } = to.match.params; try { - const pokemon = await api.get(name, { signal }); + const pokemon = await api.get(name, { signal: ctx.signal }); next.props({ pokemon: serializePokemon(pokemon), }); } catch (error) { - if (!(error && error.name === 'AbortError')) { + if (error.name !== 'AbortError') { throw new Error('Pokemon does not exist.'); } } diff --git a/package/src/Guard.tsx b/package/src/Guard.tsx index 1cbd955..8241c20 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -7,7 +7,7 @@ import { } from 'react-router'; import { Redirect, Route } from 'react-router-dom'; import { ErrorPageContext, GuardContext, LoadingPageContext, FromRouteContext } from './contexts'; -import { GuardStatus, resolveGuards } from './resolveGuards'; +import { resolveGuards, ResolvedGuardStatus } from './resolveGuards'; import { useRouteChangeEffect } from './useRouteChangeEffect'; import { Meta, Page, PageComponentType } from './types'; @@ -16,7 +16,7 @@ import { Meta, Page, PageComponentType } from './types'; * * @param page the page to type check */ -function isPageComponentType

(page: Page

): page is PageComponentType

{ +export function isPageComponentType

(page: Page

): page is PageComponentType

{ return ( !!page && typeof page !== 'string' && typeof page !== 'boolean' && typeof page !== 'number' ); @@ -27,13 +27,16 @@ export interface GuardProps extends RouteProps { } 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); @@ -45,6 +48,8 @@ export const Guard = withRouter(function Guard }, []); const guards = useContext(GuardContext); + + 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) { @@ -58,7 +63,7 @@ export const Guard = withRouter(function Guard // Create a mutable status variable that we can change for the *current* render let status = immutableStatus; - const routeProps = { history, location, match }; + const routeProps = { history, location, match, staticContext }; const fromRouteProps = useContext(FromRouteContext); const routeChangeAbortControllerRef = useRef(null); useRouteChangeEffect(routeProps, async () => { @@ -84,9 +89,12 @@ export const Guard = withRouter(function Guard try { // Resolve the guards to get the render status const status = await resolveGuards(guards || [], { - to: { ...routeProps, meta: meta || {} }, + to: routeProps, from: fromRouteProps, - signal: abortController.signal, + context: { + meta: meta || {}, + signal: abortController.signal, + }, }); // If the signal hasn't been aborted, set the new status! if (isMountedRef.current && !abortController.signal.aborted) { diff --git a/package/src/GuardProvider.tsx b/package/src/GuardProvider.tsx index 929c31c..22a5cd1 100644 --- a/package/src/GuardProvider.tsx +++ b/package/src/GuardProvider.tsx @@ -9,17 +9,21 @@ export type GuardProviderProps = BaseGuardProps; export const GuardProvider = withRouter( function GuardProviderWithRouter({ + // Guard provider props children, guards, ignoreGlobal, - loading, - error, + loading: loadingPageOverride, + error: errorPageOverride, + // Route component props history, location, match, + staticContext, }) { - const routeProps = { history, location, match }; + const routeProps = { history, location, match, staticContext }; const fromRouteProps = useRouteChangeEffect(routeProps, () => {}); + const parentFromRouteProps = useContext(FromRouteContext); const providerGuards = useGlobalGuards(guards, ignoreGlobal); @@ -28,9 +32,17 @@ export const GuardProvider = withRouter - - - {children} + + + {/** + * 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 898b4d0..b9f805e 100644 --- a/package/src/GuardedRoute.tsx +++ b/package/src/GuardedRoute.tsx @@ -13,10 +13,10 @@ export interface GuardedRouteProps extends BaseGuardProps, RouteProps { export const GuardedRoute: React.FunctionComponent = ({ children, component, - error, + error: errorPageOverride, guards, ignoreGlobal, - loading, + loading: loadingPageOverride, meta, render, path, @@ -33,8 +33,10 @@ export const GuardedRoute: React.FunctionComponent = ({ return ( - - + + {children} diff --git a/package/src/index.ts b/package/src/index.ts index 09013f1..987d26b 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -3,7 +3,7 @@ export { GuardedRoute, GuardedRouteProps } from './GuardedRoute'; export { BaseGuardProps, GuardFunction, - Next, + NextFunction, Page, LoadingPageComponentType, ErrorPageComponentType, diff --git a/package/src/resolveGuards.ts b/package/src/resolveGuards.ts index ee3dd46..6ecdf72 100644 --- a/package/src/resolveGuards.ts +++ b/package/src/resolveGuards.ts @@ -1,11 +1,11 @@ import { RouteComponentProps } from 'react-router'; import { GuardFunction, - Next, + NextFunction, NextAction, NextPropsPayload, NextRedirectPayload, - GuardToRoute, + GuardFunctionContext, } from './types'; export type ResolvedGuardStatus = @@ -13,19 +13,17 @@ export type ResolvedGuardStatus = | { type: 'redirect'; redirect: NextRedirectPayload } | { type: 'render'; props: NextPropsPayload }; -export type GuardStatus = { type: 'resolving' } | ResolvedGuardStatus; - export interface ResolveGuardsContext { - to: GuardToRoute; + to: RouteComponentProps>; from: RouteComponentProps> | null; - signal: AbortSignal; + context: GuardFunctionContext; } -const NextFunctionFactory = { +export const NextFunctionFactory = { /** * Builds a new next function using the given `resolve` callback. */ - build: (resolve: (action: NextAction) => void): Next<{}> => { + build: (resolve: (action: NextAction) => void): NextFunction<{}> => { function next() { resolve({ type: 'continue' }); } @@ -49,10 +47,10 @@ const NextFunctionFactory = { * @param context the context of this guard's resolution * @returns a Promise returning the resolved guard action */ -function runGuard(guard: GuardFunction, context: ResolveGuardsContext): Promise { +export function runGuard(guard: GuardFunction, context: ResolveGuardsContext): Promise { return new Promise(async (resolve, reject) => { try { - await guard(context.to, context.from, NextFunctionFactory.build(resolve), context.signal); + await guard(context.to, context.from, NextFunctionFactory.build(resolve), context.context); } catch (error) { reject(error); } diff --git a/package/src/types.ts b/package/src/types.ts index 490046c..f875906 100644 --- a/package/src/types.ts +++ b/package/src/types.ts @@ -28,31 +28,44 @@ export interface NextRedirectAction { export type NextAction = NextContinueAction | NextPropsAction | NextRedirectAction; -export interface Next { +export interface NextFunction { + /** Resolve the guard and continue to the next, if any. */ (): void; + /** Pass the props to the resolved route and continue to the next, if any. */ props(props: Props): void; + /** Redirect to the given route. */ redirect(to: LocationDescriptor): void; } /////////////////////////////// // Guards /////////////////////////////// -export type GuardFunctionRouteProps = RouteComponentProps>; -export type GuardToRoute = GuardFunctionRouteProps & { +export interface GuardFunctionContext { + /** Metadata attached on the `to` route. */ meta: Meta; -}; + /** + * 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 = ( - to: GuardToRoute, - from: GuardFunctionRouteProps | null, - next: Next, - signal: AbortSignal, + /** The route being navigated to. */ + to: RouteComponentProps>, + /** The route being navigated from, if any */ + from: RouteComponentProps> | null, + /** The guard's next function */ + next: NextFunction, + /** Context for this guard's execution */ + context: GuardFunctionContext, ) => void; /////////////////////////////// // Page Types /////////////////////////////// export type PageComponentType

= ComponentType; -export type Page

= PageComponentType

| null | undefined | string | boolean | number; +export type Page

= PageComponentType

| null | string | boolean | number; export type LoadingPage = Page; export type ErrorPage = Page<{ error: unknown }>; @@ -64,8 +77,12 @@ 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; + /** A custom loading page component. */ loading?: LoadingPage; + /** A custom error page component. */ error?: ErrorPage; } From b83407ec4e151634bed421860d9c618629375379 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Thu, 16 Sep 2021 13:22:49 -0400 Subject: [PATCH 10/12] remove use on internal __RouterContext in favor of useGuardData hook --- .../src/containers/Detail/index.tsx | 22 ++++++------- package/src/Guard.tsx | 31 ++++++++----------- package/src/contexts.ts | 2 ++ package/src/index.ts | 1 + package/src/resolveGuards.ts | 18 +++++------ package/src/types.ts | 20 ++++++------ package/src/useGuardData.tsx | 6 ++++ 7 files changed, 50 insertions(+), 50 deletions(-) create mode 100644 package/src/useGuardData.tsx diff --git a/demos/intermediate/src/containers/Detail/index.tsx b/demos/intermediate/src/containers/Detail/index.tsx index 937deff..1bb619f 100644 --- a/demos/intermediate/src/containers/Detail/index.tsx +++ b/demos/intermediate/src/containers/Detail/index.tsx @@ -1,18 +1,19 @@ import React, { useCallback } from 'react'; import { Switch, useRouteMatch, Redirect } from 'react-router-dom'; -import { GuardFunction, GuardedRoute } from 'react-router-guards'; +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, @@ -24,8 +25,8 @@ const Detail: React.FunctionComponent = ({ sprites, types, weight, - }, -}) => { + } = data.pokemon; + const renderMoveList = useCallback( (type: MoveLearnType, renderLevel: boolean = false) => (

    @@ -166,16 +167,11 @@ const Detail: React.FunctionComponent = ({ export default Detail; -export const beforeRouteEnter: GuardFunction<{ pokemon: SerializedPokemon }> = async ( - to, - from, - next, - ctx, -) => { +export const beforeRouteEnter: GuardFunction = async (to, from, next, ctx) => { const { name } = to.match.params; try { const pokemon = await api.get(name, { signal: ctx.signal }); - next.props({ + next.data({ pokemon: serializePokemon(pokemon), }); } catch (error) { diff --git a/package/src/Guard.tsx b/package/src/Guard.tsx index 8241c20..d041ae9 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -1,12 +1,13 @@ import React, { useContext, useRef, useState, useEffect, Fragment, createElement } from 'react'; -import { - __RouterContext as RouterContext, - RouteComponentProps, - withRouter, - RouteProps, -} from 'react-router'; +import { RouteComponentProps, withRouter, RouteProps } from 'react-router'; import { Redirect, Route } from 'react-router-dom'; -import { ErrorPageContext, GuardContext, LoadingPageContext, FromRouteContext } from './contexts'; +import { + ErrorPageContext, + GuardContext, + LoadingPageContext, + FromRouteContext, + GuardDataContext, +} from './contexts'; import { resolveGuards, ResolvedGuardStatus } from './resolveGuards'; import { useRouteChangeEffect } from './useRouteChangeEffect'; import { Meta, Page, PageComponentType } from './types'; @@ -53,7 +54,7 @@ export const Guard = withRouter(function Guard function getInitialStatus(): GuardStatus { // If there are no guards in context, the route should immediately render if (!guards || guards.length === 0) { - return { type: 'render', props: {} }; + return { type: 'render', data: {} }; } // Otherwise, the component should start resolving return { type: 'resolving' }; @@ -116,19 +117,13 @@ export const Guard = withRouter(function Guard case 'render': { return ( - - + + {children} - - + + ); } diff --git a/package/src/contexts.ts b/package/src/contexts.ts index b380382..7fa9e0d 100644 --- a/package/src/contexts.ts +++ b/package/src/contexts.ts @@ -8,4 +8,6 @@ export const FromRouteContext = createContext(null); export const GuardContext = createContext(null); +export const GuardDataContext = createContext({}); + export const LoadingPageContext = createContext(null); diff --git a/package/src/index.ts b/package/src/index.ts index 987d26b..ffb9ddf 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,5 +1,6 @@ export { GuardProvider, GuardProviderProps } from './GuardProvider'; export { GuardedRoute, GuardedRouteProps } from './GuardedRoute'; +export { useGuardData } from './useGuardData'; export { BaseGuardProps, GuardFunction, diff --git a/package/src/resolveGuards.ts b/package/src/resolveGuards.ts index 6ecdf72..726e71a 100644 --- a/package/src/resolveGuards.ts +++ b/package/src/resolveGuards.ts @@ -3,7 +3,7 @@ import { GuardFunction, NextFunction, NextAction, - NextPropsPayload, + NextDataPayload, NextRedirectPayload, GuardFunctionContext, } from './types'; @@ -11,7 +11,7 @@ import { export type ResolvedGuardStatus = | { type: 'error'; error: unknown } | { type: 'redirect'; redirect: NextRedirectPayload } - | { type: 'render'; props: NextPropsPayload }; + | { type: 'render'; data: NextDataPayload }; export interface ResolveGuardsContext { to: RouteComponentProps>; @@ -29,8 +29,8 @@ export const NextFunctionFactory = { } return Object.assign(next, { - props(payload: NextPropsPayload) { - resolve({ type: 'props', payload }); + data(payload: NextDataPayload) { + resolve({ type: 'data', payload }); }, redirect(payload: NextRedirectPayload) { resolve({ type: 'redirect', payload }); @@ -74,19 +74,19 @@ export async function resolveGuards( context: ResolveGuardsContext, ): Promise { try { - let props: NextPropsPayload = {}; + let data: NextDataPayload = {}; for (const guard of guards) { const action = await runGuard(guard, context); if (action.type === 'redirect') { // If the guard calls for a redirect, do so immediately! return { type: 'redirect', redirect: action.payload }; - } else if (action.type === 'props') { - // Otherwise, continue to merge props - props = Object.assign(props, action.payload); + } else if (action.type === 'data') { + // Otherwise, continue to merge data + data = Object.assign(data, action.payload); } } // Then return the props after all guards have resolved - return { type: 'render', props }; + return { type: 'render', data }; } catch (error) { // If the guard fails because the signal is aborted, bubbles up the error if (error && error.name === 'AbortError') { diff --git a/package/src/types.ts b/package/src/types.ts index f875906..b1de746 100644 --- a/package/src/types.ts +++ b/package/src/types.ts @@ -14,10 +14,10 @@ export interface NextContinueAction { type: 'continue'; } -export type NextPropsPayload = Record; -export interface NextPropsAction { - type: 'props'; - payload: NextPropsPayload; +export type NextDataPayload = Record; +export interface NextDataAction { + type: 'data'; + payload: NextDataPayload; } export type NextRedirectPayload = LocationDescriptor; @@ -26,13 +26,13 @@ export interface NextRedirectAction { payload: NextRedirectPayload; } -export type NextAction = NextContinueAction | NextPropsAction | NextRedirectAction; +export type NextAction = NextContinueAction | NextDataAction | NextRedirectAction; -export interface NextFunction { +export interface NextFunction { /** Resolve the guard and continue to the next, if any. */ (): void; - /** Pass the props to the resolved route and continue to the next, if any. */ - props(props: Props): void; + /** Pass the data to the resolved route and continue to the next, if any. */ + data(data: Data): void; /** Redirect to the given route. */ redirect(to: LocationDescriptor): void; } @@ -50,13 +50,13 @@ export interface GuardFunctionContext { signal: AbortSignal; } -export type GuardFunction = ( +export type GuardFunction = ( /** The route being navigated to. */ to: RouteComponentProps>, /** The route being navigated from, if any */ from: RouteComponentProps> | null, /** The guard's next function */ - next: NextFunction, + next: NextFunction, /** Context for this guard's execution */ context: GuardFunctionContext, ) => void; 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; +} From c9e2ffd9058edcab44df5281f9b51e7326f07b6d Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Thu, 16 Sep 2021 14:36:49 -0400 Subject: [PATCH 11/12] change GuardFunction signature, force next to be returned, simplify resolveGuards --- .../src/containers/Detail/index.tsx | 10 ++- .../src/containers/List/index.tsx | 4 +- .../src/router/guards/waitOneSecond.ts | 5 +- package/src/Guard.tsx | 26 +++---- package/src/resolveGuards.ts | 78 +++++++------------ package/src/types.ts | 23 +++--- 6 files changed, 63 insertions(+), 83 deletions(-) diff --git a/demos/intermediate/src/containers/Detail/index.tsx b/demos/intermediate/src/containers/Detail/index.tsx index 1bb619f..478e32d 100644 --- a/demos/intermediate/src/containers/Detail/index.tsx +++ b/demos/intermediate/src/containers/Detail/index.tsx @@ -167,15 +167,17 @@ const Detail: React.FunctionComponent = () => { export default Detail; -export const beforeRouteEnter: GuardFunction = async (to, from, next, ctx) => { - 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, { signal: ctx.signal }); - next.data({ + return next.data({ pokemon: serializePokemon(pokemon), }); } catch (error) { - if (error.name !== 'AbortError') { + 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 e3a9dd9..19b0a23 100644 --- a/demos/intermediate/src/containers/List/index.tsx +++ b/demos/intermediate/src/containers/List/index.tsx @@ -23,7 +23,7 @@ const List = () => { name, })); - const getPokemon = async (signal: AbortSignal) => { + const getPokemon = async (signal?: AbortSignal) => { try { const { next, results: newResults } = await api.list(offset, { signal }); setResults([...results, ...serializeResults(newResults)]); @@ -59,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/package/src/Guard.tsx b/package/src/Guard.tsx index d041ae9..9247688 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -87,22 +87,16 @@ export const Guard = withRouter(function Guard if (status.type === 'resolving') { const abortController = new AbortController(); routeChangeAbortControllerRef.current = abortController; - try { - // Resolve the guards to get the render status - const status = await resolveGuards(guards || [], { - to: routeProps, - from: fromRouteProps, - context: { - meta: meta || {}, - signal: abortController.signal, - }, - }); - // If the signal hasn't been aborted, set the new status! - if (isMountedRef.current && !abortController.signal.aborted) { - setStatus(status); - } - } catch (error) { - // Route has changed, wait until the effect runs again... + // Resolve the guards to get the render status + const status = await resolveGuards(guards || [], { + to: routeProps, + from: fromRouteProps, + meta: meta || {}, + signal: abortController.signal, + }); + // If the signal hasn't been aborted, set the new status! + if (isMountedRef.current && !abortController.signal.aborted) { + setStatus(status); } } }); diff --git a/package/src/resolveGuards.ts b/package/src/resolveGuards.ts index 726e71a..fae5557 100644 --- a/package/src/resolveGuards.ts +++ b/package/src/resolveGuards.ts @@ -1,11 +1,12 @@ -import { RouteComponentProps } from 'react-router'; import { GuardFunction, NextFunction, - NextAction, NextDataPayload, NextRedirectPayload, GuardFunctionContext, + NextContinueAction, + NextDataAction, + NextRedirectAction, } from './types'; export type ResolvedGuardStatus = @@ -13,86 +14,67 @@ export type ResolvedGuardStatus = | { type: 'redirect'; redirect: NextRedirectPayload } | { type: 'render'; data: NextDataPayload }; -export interface ResolveGuardsContext { - to: RouteComponentProps>; - from: RouteComponentProps> | null; - context: GuardFunctionContext; -} - export const NextFunctionFactory = { /** * Builds a new next function using the given `resolve` callback. */ - build: (resolve: (action: NextAction) => void): NextFunction<{}> => { - function next() { - resolve({ type: 'continue' }); + build(): NextFunction<{}> { + function next(): NextContinueAction { + return { type: 'continue' }; } return Object.assign(next, { - data(payload: NextDataPayload) { - resolve({ type: 'data', payload }); + data(payload: NextDataPayload): NextDataAction { + return { type: 'data', payload }; }, - redirect(payload: NextRedirectPayload) { - resolve({ type: 'redirect', payload }); + redirect(payload: NextRedirectPayload): NextRedirectAction { + return { type: 'redirect', payload }; }, }); }, }; -/** - * Handles running a single guard function in the given context. - * Bubbles up any errors thrown in the guard. - * - * @param guard the guard function - * @param context the context of this guard's resolution - * @returns a Promise returning the resolved guard action - */ -export function runGuard(guard: GuardFunction, context: ResolveGuardsContext): Promise { - return new Promise(async (resolve, reject) => { - try { - await guard(context.to, context.from, NextFunctionFactory.build(resolve), context.context); - } catch (error) { - reject(error); - } - }); -} - /** * 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 props + * - 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' resolution + * @param context the context of these guards * @returns a Promise returning the resolved guards' status */ export async function resolveGuards( guards: GuardFunction[], - context: ResolveGuardsContext, + context: GuardFunctionContext, ): Promise { try { let data: NextDataPayload = {}; for (const guard of guards) { - const action = await runGuard(guard, context); - if (action.type === 'redirect') { - // If the guard calls for a redirect, do so immediately! - return { type: 'redirect', redirect: action.payload }; - } else if (action.type === 'data') { - // Otherwise, continue to merge data - data = Object.assign(data, action.payload); + // 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) { - // If the guard fails because the signal is aborted, bubbles up the error - if (error && error.name === 'AbortError') { - throw error; - } - // Otherwise, return the error status with the guard-thrown error return { type: 'error', error }; } } diff --git a/package/src/types.ts b/package/src/types.ts index b1de746..f0ef5ce 100644 --- a/package/src/types.ts +++ b/package/src/types.ts @@ -30,36 +30,37 @@ export type NextAction = NextContinueAction | NextDataAction | NextRedirectActio export interface NextFunction { /** Resolve the guard and continue to the next, if any. */ - (): void; + (): NextContinueAction; /** Pass the data to the resolved route and continue to the next, if any. */ - data(data: Data): void; + data(data: Data): NextDataAction; /** Redirect to the given route. */ - redirect(to: LocationDescriptor): void; + redirect(to: LocationDescriptor): NextRedirectAction; } /////////////////////////////// // 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; /** * A signal that determines if the current guard resolution has been aborted. - * Attach to fetch calls to cancel outdated requests before they're resolved. + * + * Attach to `fetch` calls to cancel outdated requests before they're resolved. */ signal: AbortSignal; } export type GuardFunction = ( - /** The route being navigated to. */ - to: RouteComponentProps>, - /** The route being navigated from, if any */ - from: RouteComponentProps> | null, - /** The guard's next function */ - next: NextFunction, /** Context for this guard's execution */ context: GuardFunctionContext, -) => void; + /** The guard's next function */ + next: NextFunction, +) => NextAction | Promise; /////////////////////////////// // Page Types From c13edc0595535390a462c5c32a75b9e552cb84c1 Mon Sep 17 00:00:00 2001 From: Josh Pensky Date: Thu, 16 Sep 2021 16:19:29 -0400 Subject: [PATCH 12/12] update basic usage example --- README.md | 77 ++++++++++++++++++++++++++----------------- package/src/Guard.tsx | 6 ++-- 2 files changed, 50 insertions(+), 33 deletions(-) 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/package/src/Guard.tsx b/package/src/Guard.tsx index 9247688..7bdc395 100644 --- a/package/src/Guard.tsx +++ b/package/src/Guard.tsx @@ -88,15 +88,15 @@ export const Guard = withRouter(function Guard const abortController = new AbortController(); routeChangeAbortControllerRef.current = abortController; // Resolve the guards to get the render status - const status = await resolveGuards(guards || [], { + const resolvedStatus = await resolveGuards(guards || [], { to: routeProps, from: fromRouteProps, meta: meta || {}, signal: abortController.signal, }); - // If the signal hasn't been aborted, set the new status! + // If the route hasn't changed during async resolution, set the newly resolved status! if (isMountedRef.current && !abortController.signal.aborted) { - setStatus(status); + setStatus(resolvedStatus); } } });