diff --git a/.gitignore b/.gitignore index 509667a660..1387c7fdff 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ node_modules/ /packages/react-router-dom-v5-compat/react-router-dom .eslintcache -/.env \ No newline at end of file +/.env +/NOTES.md diff --git a/contributors.yml b/contributors.yml index bf6174d459..df570ef044 100644 --- a/contributors.yml +++ b/contributors.yml @@ -79,6 +79,7 @@ - janpaepke - jasonpaulos - jdufresne +- jenseng - JesusTheHun - jimniels - jmargeta @@ -98,6 +99,7 @@ - latin-1 - lequangdongg - liuhanqu +- lkwr - lopezac - lordofthecactus - loun4 diff --git a/examples/navigation-blocking/.gitignore b/examples/navigation-blocking/.gitignore new file mode 100644 index 0000000000..d451ff16c1 --- /dev/null +++ b/examples/navigation-blocking/.gitignore @@ -0,0 +1,5 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local diff --git a/examples/navigation-blocking/.stackblitzrc b/examples/navigation-blocking/.stackblitzrc new file mode 100644 index 0000000000..d98146f4d0 --- /dev/null +++ b/examples/navigation-blocking/.stackblitzrc @@ -0,0 +1,4 @@ +{ + "installDependencies": true, + "startCommand": "npm run dev" +} diff --git a/examples/navigation-blocking/README.md b/examples/navigation-blocking/README.md new file mode 100644 index 0000000000..f2875a1c51 --- /dev/null +++ b/examples/navigation-blocking/README.md @@ -0,0 +1,15 @@ +--- +title: Navigation Blocking +toc: false +order: 1 +--- + +# Navigation Blocking + +This example demonstrates using `unstable_useBlocker` to prevent navigating away from a page where you might lose user-entered form data. A potentially better UX for this is storing user-entered information in `sessionStorage` and pre-populating the form on return. + +## Preview + +Open this example on [StackBlitz](https://stackblitz.com): + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router/tree/main/examples/navigation-blocking?file=src/App.tsx) diff --git a/examples/navigation-blocking/index.html b/examples/navigation-blocking/index.html new file mode 100644 index 0000000000..a8e66e86e0 --- /dev/null +++ b/examples/navigation-blocking/index.html @@ -0,0 +1,12 @@ + + + + + + React Router - Navigation Blocking + + +
+ + + diff --git a/examples/navigation-blocking/package-lock.json b/examples/navigation-blocking/package-lock.json new file mode 100644 index 0000000000..10fcd53696 --- /dev/null +++ b/examples/navigation-blocking/package-lock.json @@ -0,0 +1,2456 @@ +{ + "name": "navigation-blocking", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "navigation-blocking", + "dependencies": { + "react": "18.1.0", + "react-dom": "18.1.0", + "react-router-dom": "^6.7.0-pre.3" + }, + "devDependencies": { + "@rollup/plugin-replace": "4.0.0", + "@types/node": "17.0.32", + "@types/react": "18.0.9", + "@types/react-dom": "18.0.3", + "@vitejs/plugin-react": "1.3.2", + "typescript": "4.6.4", + "vite": "2.9.9" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", + "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.1.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.10.tgz", + "integrity": "sha512-GZt/TCsG70Ms19gfZO1tM4CVnXsPgEPBCpJu+Qz3L0LUDsY5nZqFZglIoPC1kIYOtNBZlrnFT+klg12vFGZXrw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.10.tgz", + "integrity": "sha512-liKoppandF3ZcBnIYFjfSDHZLKdLHGJRkoWtG8zQyGJBQfIYobpnVGI5+pLBNtS6psFLDzyq8+h5HiVljW9PNA==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.10", + "@babel/helper-compilation-targets": "^7.17.10", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.10", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.10", + "@babel/types": "^7.17.10", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.10.tgz", + "integrity": "sha512-46MJZZo9y3o4kmhBVc7zW7i8dtR1oIK/sdO5NcfcZRhTGYi+KKJRtHNgsU6c4VUcJmUNV/LQdebD/9Dlv4K+Tg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.17.10", + "@jridgewell/gen-mapping": "^0.1.0", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz", + "integrity": "sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.17.10.tgz", + "integrity": "sha512-gh3RxjWbauw/dFiU/7whjd0qN9K6nPJMqe6+Er7rOavFh0CQUSwhAE3IcTho2rywPJFxej6TUUHDkWcYI6gGqQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.17.10", + "@babel/helper-validator-option": "^7.16.7", + "browserslist": "^4.20.2", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", + "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.17.9.tgz", + "integrity": "sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.16.7", + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", + "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", + "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz", + "integrity": "sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-simple-access": "^7.17.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/helper-validator-identifier": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.3", + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", + "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz", + "integrity": "sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==", + "dev": true, + "dependencies": { + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", + "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", + "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.9.tgz", + "integrity": "sha512-cPCt915ShDWUEzEp3+UNRktO2n6v49l5RSnG9M5pS24hA+2FAc5si+Pn1i4VVbQQ+jh+bIZhPFQOJOzbrOYY1Q==", + "dev": true, + "dependencies": { + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz", + "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.10.tgz", + "integrity": "sha512-n2Q6i+fnJqzOaq2VkdXxy2TCPCWQZHiCo0XqmrCvDWcZQKRyZzYi4Z0yxlBuN0w+r2ZHmre+Q087DSrw3pbJDQ==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz", + "integrity": "sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.17.3.tgz", + "integrity": "sha512-9tjBm4O07f7mzKSIlEmPdiE6ub7kfIe6Cd+w+oQebpATfTQMAgW+YOuWxogbKVTulA+MEO7byMeIUtQ1z+z+ZQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-jsx": "^7.16.7", + "@babel/types": "^7.17.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.16.7.tgz", + "integrity": "sha512-RMvQWvpla+xy6MlBpPlrKZCMRs2AGiHOGHY3xRwl0pEeim348dDyxeH4xBsMPbIMhujeq7ihE702eM2Ew0Wo+A==", + "dev": true, + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.16.7.tgz", + "integrity": "sha512-oe5VuWs7J9ilH3BCCApGoYjHoSO48vkjX2CbA5bFVhIuO2HKxA3vyF7rleA4o6/4rTDbk6r8hBW7Ul8E+UZrpA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.16.7.tgz", + "integrity": "sha512-rONFiQz9vgbsnaMtQlZCjIRwhJvlrPET8TabIUK2hzlXw9B9s2Ieaxte1SCOOXMbWRHodbKixNf3BLcWVOQ8Bw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", + "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.16.7", + "@babel/parser": "^7.16.7", + "@babel/types": "^7.16.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.10.tgz", + "integrity": "sha512-VmbrTHQteIdUUQNTb+zE12SHS/xQVIShmBPhlNP12hD5poF2pbITW1Z4172d03HegaQWhLffdkRJYtAzp0AGcw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.10", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.17.9", + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/parser": "^7.17.10", + "@babel/types": "^7.17.10", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.10.tgz", + "integrity": "sha512-9O26jG0mBYfGkUYCYZRnBwbVLd1UZOICEr2Em6InB6jVfsAv1GKgwXHmrSg+WFWDmeKTA6vyTZiN8tCSM5Oo3A==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", + "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.0", + "@jridgewell/sourcemap-codec": "^1.4.10" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.7.tgz", + "integrity": "sha512-8cXDaBBHOr2pQ7j77Y6Vp5VDT2sIqWyWQ56TjEq4ih/a4iST3dItRe8Q9fp0rrIl9DoKhWQtUQz/YpOxLkXbNA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.1.tgz", + "integrity": "sha512-Ct5MqZkLGEXTVmQYbGtx9SVqD2fqwvdubdps5D3djjAkgkKwT918VNOz65pEHFaYTeWcukmJmH5SwsA9Tn2ObQ==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz", + "integrity": "sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.13.tgz", + "integrity": "sha512-o1xbKhp9qnIAoHJSWd6KlCZfqslL4valSF81H8ImioOAxluWYWOpWkpyktY2vnt4tbrX9XYaxovq6cgowaJp2w==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@remix-run/router": { + "version": "1.3.0-pre.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.0-pre.2.tgz", + "integrity": "sha512-XWpQkUMfuJy5BPA+fivgJtKPiRu1VC5M3LdrUw6Wb310liR3LUIUmY2b5xP6kpcbpIQOTMgF4NVrdwZ4NRG0lA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-4.0.0.tgz", + "integrity": "sha512-+rumQFiaNac9y64OHtkHGmdjm7us9bo1PlbgQfdihQtuNxzjpaB064HbRnewUOggLQxVCCyINfStkgmBeQpv1g==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, + "node_modules/@types/node": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz", + "integrity": "sha512-eAIcfAvhf/BkHcf4pkLJ7ECpBAhh9kcxRBpip9cTiO+hf+aJrsxYxBeS6OXvOd9WqNAJmavXVpZvY1rBjNsXmw==", + "dev": true + }, + "node_modules/@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 + }, + "node_modules/@types/react": { + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.9.tgz", + "integrity": "sha512-9bjbg1hJHUm4De19L1cHiW0Jvx3geel6Qczhjd0qY5VKVE2X5+x77YxAepuCwVh4vrgZJdgEJw48zrhRIeF4Nw==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.3.tgz", + "integrity": "sha512-1RRW9kst+67gveJRYPxGmVy8eVJ05O43hg77G2j5m76/RFJtMbcfAs2viQ2UNsvvDg8F7OfQZx8qQcl6ymygaQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@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 + }, + "node_modules/@vitejs/plugin-react": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-1.3.2.tgz", + "integrity": "sha512-aurBNmMo0kz1O4qRoY+FM4epSA39y3ShWGuqfLRA/3z0oEJAdtoSfgA3aO98/PCCHAqMaduLxIxErWrVKIFzXA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.17.10", + "@babel/plugin-transform-react-jsx": "^7.17.3", + "@babel/plugin-transform-react-jsx-development": "^7.16.7", + "@babel/plugin-transform-react-jsx-self": "^7.16.7", + "@babel/plugin-transform-react-jsx-source": "^7.16.7", + "@rollup/pluginutils": "^4.2.1", + "react-refresh": "^0.13.0", + "resolve": "^1.22.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "dependencies": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/browserslist": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.3.tgz", + "integrity": "sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001332", + "electron-to-chromium": "^1.4.118", + "escalade": "^3.1.1", + "node-releases": "^2.0.3", + "picocolors": "^1.0.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001339", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001339.tgz", + "integrity": "sha512-Es8PiVqCe+uXdms0Gu5xP5PF2bxLR7OBp3wUzUnuO7OHzhOfCyg3hdiGWVPVxhiuniOzng+hTc1u3fEQ0TlkSQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + } + ] + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", + "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.1.1" + } + }, + "node_modules/csstype": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz", + "integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==", + "dev": true + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.4.137", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.137.tgz", + "integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.38.tgz", + "integrity": "sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "esbuild-android-64": "0.14.38", + "esbuild-android-arm64": "0.14.38", + "esbuild-darwin-64": "0.14.38", + "esbuild-darwin-arm64": "0.14.38", + "esbuild-freebsd-64": "0.14.38", + "esbuild-freebsd-arm64": "0.14.38", + "esbuild-linux-32": "0.14.38", + "esbuild-linux-64": "0.14.38", + "esbuild-linux-arm": "0.14.38", + "esbuild-linux-arm64": "0.14.38", + "esbuild-linux-mips64le": "0.14.38", + "esbuild-linux-ppc64le": "0.14.38", + "esbuild-linux-riscv64": "0.14.38", + "esbuild-linux-s390x": "0.14.38", + "esbuild-netbsd-64": "0.14.38", + "esbuild-openbsd-64": "0.14.38", + "esbuild-sunos-64": "0.14.38", + "esbuild-windows-32": "0.14.38", + "esbuild-windows-64": "0.14.38", + "esbuild-windows-arm64": "0.14.38" + } + }, + "node_modules/esbuild-android-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.38.tgz", + "integrity": "sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-android-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.38.tgz", + "integrity": "sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.38.tgz", + "integrity": "sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-darwin-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.38.tgz", + "integrity": "sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.38.tgz", + "integrity": "sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-freebsd-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.38.tgz", + "integrity": "sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-32": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.38.tgz", + "integrity": "sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.38.tgz", + "integrity": "sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.38.tgz", + "integrity": "sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.38.tgz", + "integrity": "sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-mips64le": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.38.tgz", + "integrity": "sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-ppc64le": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.38.tgz", + "integrity": "sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-riscv64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.38.tgz", + "integrity": "sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-linux-s390x": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.38.tgz", + "integrity": "sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-netbsd-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.38.tgz", + "integrity": "sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-openbsd-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.38.tgz", + "integrity": "sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-sunos-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.38.tgz", + "integrity": "sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-32": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.38.tgz", + "integrity": "sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.38.tgz", + "integrity": "sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/esbuild-windows-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.38.tgz", + "integrity": "sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/is-core-module": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", + "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", + "dev": true, + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dev": true, + "dependencies": { + "sourcemap-codec": "^1.4.4" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "dev": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.4.tgz", + "integrity": "sha512-gbMzqQtTtDz/00jQzZ21PQzdI9PyLYqUSvD0p3naOhX4odFji0ZxYdnVwPTxmSwkmxhcFImpozceidSG+AgoPQ==", + "dev": true + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.4.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.13.tgz", + "integrity": "sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + } + ], + "dependencies": { + "nanoid": "^3.3.3", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.1.0.tgz", + "integrity": "sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.1.0.tgz", + "integrity": "sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.22.0" + }, + "peerDependencies": { + "react": "^18.1.0" + } + }, + "node_modules/react-refresh": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz", + "integrity": "sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.7.0-pre.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.7.0-pre.3.tgz", + "integrity": "sha512-6Nrovh23eq8ySnr7/CaV4f+jm9Qde6u3w9z4zglXfaVDBRRlO4lUClMOMlvrxr1U+KwqB/qNo4roGyrzJoF6Hg==", + "dependencies": { + "@remix-run/router": "1.3.0-pre.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.7.0-pre.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.7.0-pre.3.tgz", + "integrity": "sha512-f/WTO17U5jKM9KJY+0DEcCggKDn8DAWCFbTUdG6XTUMMcTYFqoWzQM6hez+rvasS1HvK8OQBOX7gQ+htnERjcA==", + "dependencies": { + "@remix-run/router": "1.3.0-pre.2", + "react-router": "6.7.0-pre.3" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resolve": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", + "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "2.72.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.72.1.tgz", + "integrity": "sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "node_modules/scheduler": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.22.0.tgz", + "integrity": "sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/typescript": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", + "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/vite": { + "version": "2.9.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-2.9.9.tgz", + "integrity": "sha512-ffaam+NgHfbEmfw/Vuh6BHKKlI/XIAhxE5QSS7gFLIngxg171mg1P3a4LSRME0z2ZU1ScxoKzphkipcYwSD5Ew==", + "dev": true, + "dependencies": { + "esbuild": "^0.14.27", + "postcss": "^8.4.13", + "resolve": "^1.22.0", + "rollup": "^2.59.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": ">=12.2.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "less": "*", + "sass": "*", + "stylus": "*" + }, + "peerDependenciesMeta": { + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + } + } + } + }, + "dependencies": { + "@ampproject/remapping": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", + "integrity": "sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.1.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@babel/code-frame": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz", + "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==", + "dev": true, + "requires": { + "@babel/highlight": "^7.16.7" + } + }, + "@babel/compat-data": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.10.tgz", + "integrity": "sha512-GZt/TCsG70Ms19gfZO1tM4CVnXsPgEPBCpJu+Qz3L0LUDsY5nZqFZglIoPC1kIYOtNBZlrnFT+klg12vFGZXrw==", + "dev": true + }, + "@babel/core": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.10.tgz", + "integrity": "sha512-liKoppandF3ZcBnIYFjfSDHZLKdLHGJRkoWtG8zQyGJBQfIYobpnVGI5+pLBNtS6psFLDzyq8+h5HiVljW9PNA==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.1.0", + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.10", + "@babel/helper-compilation-targets": "^7.17.10", + "@babel/helper-module-transforms": "^7.17.7", + "@babel/helpers": "^7.17.9", + "@babel/parser": "^7.17.10", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.10", + "@babel/types": "^7.17.10", + "convert-source-map": "^1.7.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.1", + "semver": "^6.3.0" + } + }, + "@babel/generator": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.10.tgz", + "integrity": "sha512-46MJZZo9y3o4kmhBVc7zW7i8dtR1oIK/sdO5NcfcZRhTGYi+KKJRtHNgsU6c4VUcJmUNV/LQdebD/9Dlv4K+Tg==", + "dev": true, + "requires": { + "@babel/types": "^7.17.10", + "@jridgewell/gen-mapping": "^0.1.0", + "jsesc": "^2.5.1" + } + }, + "@babel/helper-annotate-as-pure": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.16.7.tgz", + "integrity": "sha512-s6t2w/IPQVTAET1HitoowRGXooX8mCgtuP5195wD/QJPV6wYjpujCGF7JuMODVX2ZAJOf1GT6DT9MHEZvLOFSw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.17.10.tgz", + "integrity": "sha512-gh3RxjWbauw/dFiU/7whjd0qN9K6nPJMqe6+Er7rOavFh0CQUSwhAE3IcTho2rywPJFxej6TUUHDkWcYI6gGqQ==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.17.10", + "@babel/helper-validator-option": "^7.16.7", + "browserslist": "^4.20.2", + "semver": "^6.3.0" + } + }, + "@babel/helper-environment-visitor": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz", + "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-function-name": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.17.9.tgz", + "integrity": "sha512-7cRisGlVtiVqZ0MW0/yFB4atgpGLWEHUVYnb448hZK4x+vih0YO5UoS11XIYtZYqHd0dIPMdUSv8q5K4LdMnIg==", + "dev": true, + "requires": { + "@babel/template": "^7.16.7", + "@babel/types": "^7.17.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz", + "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-module-imports": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz", + "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-module-transforms": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz", + "integrity": "sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-simple-access": "^7.17.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/helper-validator-identifier": "^7.16.7", + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.3", + "@babel/types": "^7.17.0" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz", + "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==", + "dev": true + }, + "@babel/helper-simple-access": { + "version": "7.17.7", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz", + "integrity": "sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==", + "dev": true, + "requires": { + "@babel/types": "^7.17.0" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz", + "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==", + "dev": true, + "requires": { + "@babel/types": "^7.16.7" + } + }, + "@babel/helper-validator-identifier": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz", + "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz", + "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==", + "dev": true + }, + "@babel/helpers": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.9.tgz", + "integrity": "sha512-cPCt915ShDWUEzEp3+UNRktO2n6v49l5RSnG9M5pS24hA+2FAc5si+Pn1i4VVbQQ+jh+bIZhPFQOJOzbrOYY1Q==", + "dev": true, + "requires": { + "@babel/template": "^7.16.7", + "@babel/traverse": "^7.17.9", + "@babel/types": "^7.17.0" + } + }, + "@babel/highlight": { + "version": "7.17.9", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz", + "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "chalk": "^2.0.0", + "js-tokens": "^4.0.0" + } + }, + "@babel/parser": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.10.tgz", + "integrity": "sha512-n2Q6i+fnJqzOaq2VkdXxy2TCPCWQZHiCo0XqmrCvDWcZQKRyZzYi4Z0yxlBuN0w+r2ZHmre+Q087DSrw3pbJDQ==", + "dev": true + }, + "@babel/plugin-syntax-jsx": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz", + "integrity": "sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-react-jsx": { + "version": "7.17.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.17.3.tgz", + "integrity": "sha512-9tjBm4O07f7mzKSIlEmPdiE6ub7kfIe6Cd+w+oQebpATfTQMAgW+YOuWxogbKVTulA+MEO7byMeIUtQ1z+z+ZQ==", + "dev": true, + "requires": { + "@babel/helper-annotate-as-pure": "^7.16.7", + "@babel/helper-module-imports": "^7.16.7", + "@babel/helper-plugin-utils": "^7.16.7", + "@babel/plugin-syntax-jsx": "^7.16.7", + "@babel/types": "^7.17.0" + } + }, + "@babel/plugin-transform-react-jsx-development": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.16.7.tgz", + "integrity": "sha512-RMvQWvpla+xy6MlBpPlrKZCMRs2AGiHOGHY3xRwl0pEeim348dDyxeH4xBsMPbIMhujeq7ihE702eM2Ew0Wo+A==", + "dev": true, + "requires": { + "@babel/plugin-transform-react-jsx": "^7.16.7" + } + }, + "@babel/plugin-transform-react-jsx-self": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.16.7.tgz", + "integrity": "sha512-oe5VuWs7J9ilH3BCCApGoYjHoSO48vkjX2CbA5bFVhIuO2HKxA3vyF7rleA4o6/4rTDbk6r8hBW7Ul8E+UZrpA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/plugin-transform-react-jsx-source": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.16.7.tgz", + "integrity": "sha512-rONFiQz9vgbsnaMtQlZCjIRwhJvlrPET8TabIUK2hzlXw9B9s2Ieaxte1SCOOXMbWRHodbKixNf3BLcWVOQ8Bw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.16.7" + } + }, + "@babel/template": { + "version": "7.16.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", + "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/parser": "^7.16.7", + "@babel/types": "^7.16.7" + } + }, + "@babel/traverse": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.10.tgz", + "integrity": "sha512-VmbrTHQteIdUUQNTb+zE12SHS/xQVIShmBPhlNP12hD5poF2pbITW1Z4172d03HegaQWhLffdkRJYtAzp0AGcw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.16.7", + "@babel/generator": "^7.17.10", + "@babel/helper-environment-visitor": "^7.16.7", + "@babel/helper-function-name": "^7.17.9", + "@babel/helper-hoist-variables": "^7.16.7", + "@babel/helper-split-export-declaration": "^7.16.7", + "@babel/parser": "^7.17.10", + "@babel/types": "^7.17.10", + "debug": "^4.1.0", + "globals": "^11.1.0" + } + }, + "@babel/types": { + "version": "7.17.10", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.10.tgz", + "integrity": "sha512-9O26jG0mBYfGkUYCYZRnBwbVLd1UZOICEr2Em6InB6jVfsAv1GKgwXHmrSg+WFWDmeKTA6vyTZiN8tCSM5Oo3A==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.16.7", + "to-fast-properties": "^2.0.0" + } + }, + "@jridgewell/gen-mapping": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", + "integrity": "sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.0", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.7.tgz", + "integrity": "sha512-8cXDaBBHOr2pQ7j77Y6Vp5VDT2sIqWyWQ56TjEq4ih/a4iST3dItRe8Q9fp0rrIl9DoKhWQtUQz/YpOxLkXbNA==", + "dev": true + }, + "@jridgewell/set-array": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.1.tgz", + "integrity": "sha512-Ct5MqZkLGEXTVmQYbGtx9SVqD2fqwvdubdps5D3djjAkgkKwT918VNOz65pEHFaYTeWcukmJmH5SwsA9Tn2ObQ==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.13.tgz", + "integrity": "sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.13.tgz", + "integrity": "sha512-o1xbKhp9qnIAoHJSWd6KlCZfqslL4valSF81H8ImioOAxluWYWOpWkpyktY2vnt4tbrX9XYaxovq6cgowaJp2w==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "@remix-run/router": { + "version": "1.3.0-pre.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.3.0-pre.2.tgz", + "integrity": "sha512-XWpQkUMfuJy5BPA+fivgJtKPiRu1VC5M3LdrUw6Wb310liR3LUIUmY2b5xP6kpcbpIQOTMgF4NVrdwZ4NRG0lA==" + }, + "@rollup/plugin-replace": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-4.0.0.tgz", + "integrity": "sha512-+rumQFiaNac9y64OHtkHGmdjm7us9bo1PlbgQfdihQtuNxzjpaB064HbRnewUOggLQxVCCyINfStkgmBeQpv1g==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + } + }, + "@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "requires": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + } + }, + "@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true + }, + "@types/node": { + "version": "17.0.32", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.32.tgz", + "integrity": "sha512-eAIcfAvhf/BkHcf4pkLJ7ECpBAhh9kcxRBpip9cTiO+hf+aJrsxYxBeS6OXvOd9WqNAJmavXVpZvY1rBjNsXmw==", + "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": "18.0.9", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.9.tgz", + "integrity": "sha512-9bjbg1hJHUm4De19L1cHiW0Jvx3geel6Qczhjd0qY5VKVE2X5+x77YxAepuCwVh4vrgZJdgEJw48zrhRIeF4Nw==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "@types/scheduler": "*", + "csstype": "^3.0.2" + } + }, + "@types/react-dom": { + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.0.3.tgz", + "integrity": "sha512-1RRW9kst+67gveJRYPxGmVy8eVJ05O43hg77G2j5m76/RFJtMbcfAs2viQ2UNsvvDg8F7OfQZx8qQcl6ymygaQ==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@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 + }, + "@vitejs/plugin-react": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-1.3.2.tgz", + "integrity": "sha512-aurBNmMo0kz1O4qRoY+FM4epSA39y3ShWGuqfLRA/3z0oEJAdtoSfgA3aO98/PCCHAqMaduLxIxErWrVKIFzXA==", + "dev": true, + "requires": { + "@babel/core": "^7.17.10", + "@babel/plugin-transform-react-jsx": "^7.17.3", + "@babel/plugin-transform-react-jsx-development": "^7.16.7", + "@babel/plugin-transform-react-jsx-self": "^7.16.7", + "@babel/plugin-transform-react-jsx-source": "^7.16.7", + "@rollup/pluginutils": "^4.2.1", + "react-refresh": "^0.13.0", + "resolve": "^1.22.0" + }, + "dependencies": { + "@rollup/pluginutils": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", + "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", + "dev": true, + "requires": { + "estree-walker": "^2.0.1", + "picomatch": "^2.2.2" + } + }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + } + } + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "browserslist": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.3.tgz", + "integrity": "sha512-NBhymBQl1zM0Y5dQT/O+xiLP9/rzOIQdKM/eMJBAq7yBgaB6krIYLGejrwVYnSHZdqjscB1SPuAjHwxjvN6Wdg==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001332", + "electron-to-chromium": "^1.4.118", + "escalade": "^3.1.1", + "node-releases": "^2.0.3", + "picocolors": "^1.0.0" + } + }, + "caniuse-lite": { + "version": "1.0.30001339", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001339.tgz", + "integrity": "sha512-Es8PiVqCe+uXdms0Gu5xP5PF2bxLR7OBp3wUzUnuO7OHzhOfCyg3hdiGWVPVxhiuniOzng+hTc1u3fEQ0TlkSQ==", + "dev": true + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "convert-source-map": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", + "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "csstype": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz", + "integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw==", + "dev": true + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "electron-to-chromium": { + "version": "1.4.137", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.137.tgz", + "integrity": "sha512-0Rcpald12O11BUogJagX3HsCN3FE83DSqWjgXoHo5a72KUKMSfI39XBgJpgNNxS9fuGzytaFjE06kZkiVFy2qA==", + "dev": true + }, + "esbuild": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.38.tgz", + "integrity": "sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==", + "dev": true, + "requires": { + "esbuild-android-64": "0.14.38", + "esbuild-android-arm64": "0.14.38", + "esbuild-darwin-64": "0.14.38", + "esbuild-darwin-arm64": "0.14.38", + "esbuild-freebsd-64": "0.14.38", + "esbuild-freebsd-arm64": "0.14.38", + "esbuild-linux-32": "0.14.38", + "esbuild-linux-64": "0.14.38", + "esbuild-linux-arm": "0.14.38", + "esbuild-linux-arm64": "0.14.38", + "esbuild-linux-mips64le": "0.14.38", + "esbuild-linux-ppc64le": "0.14.38", + "esbuild-linux-riscv64": "0.14.38", + "esbuild-linux-s390x": "0.14.38", + "esbuild-netbsd-64": "0.14.38", + "esbuild-openbsd-64": "0.14.38", + "esbuild-sunos-64": "0.14.38", + "esbuild-windows-32": "0.14.38", + "esbuild-windows-64": "0.14.38", + "esbuild-windows-arm64": "0.14.38" + } + }, + "esbuild-android-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.38.tgz", + "integrity": "sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==", + "dev": true, + "optional": true + }, + "esbuild-android-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.38.tgz", + "integrity": "sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==", + "dev": true, + "optional": true + }, + "esbuild-darwin-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.38.tgz", + "integrity": "sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==", + "dev": true, + "optional": true + }, + "esbuild-darwin-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.38.tgz", + "integrity": "sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==", + "dev": true, + "optional": true + }, + "esbuild-freebsd-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.38.tgz", + "integrity": "sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==", + "dev": true, + "optional": true + }, + "esbuild-freebsd-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.38.tgz", + "integrity": "sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==", + "dev": true, + "optional": true + }, + "esbuild-linux-32": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.38.tgz", + "integrity": "sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==", + "dev": true, + "optional": true + }, + "esbuild-linux-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.38.tgz", + "integrity": "sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==", + "dev": true, + "optional": true + }, + "esbuild-linux-arm": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.38.tgz", + "integrity": "sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==", + "dev": true, + "optional": true + }, + "esbuild-linux-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.38.tgz", + "integrity": "sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==", + "dev": true, + "optional": true + }, + "esbuild-linux-mips64le": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.38.tgz", + "integrity": "sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==", + "dev": true, + "optional": true + }, + "esbuild-linux-ppc64le": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.38.tgz", + "integrity": "sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==", + "dev": true, + "optional": true + }, + "esbuild-linux-riscv64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.38.tgz", + "integrity": "sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==", + "dev": true, + "optional": true + }, + "esbuild-linux-s390x": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.38.tgz", + "integrity": "sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==", + "dev": true, + "optional": true + }, + "esbuild-netbsd-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.38.tgz", + "integrity": "sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==", + "dev": true, + "optional": true + }, + "esbuild-openbsd-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.38.tgz", + "integrity": "sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==", + "dev": true, + "optional": true + }, + "esbuild-sunos-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.38.tgz", + "integrity": "sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==", + "dev": true, + "optional": true + }, + "esbuild-windows-32": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.38.tgz", + "integrity": "sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==", + "dev": true, + "optional": true + }, + "esbuild-windows-64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.38.tgz", + "integrity": "sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==", + "dev": true, + "optional": true + }, + "esbuild-windows-arm64": { + "version": "0.14.38", + "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.38.tgz", + "integrity": "sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==", + "dev": true, + "optional": true + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true + }, + "fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "optional": true + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", + "dev": true + }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dev": true, + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "is-core-module": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.9.0.tgz", + "integrity": "sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==", + "dev": true, + "requires": { + "has": "^1.0.3" + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json5": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz", + "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==", + "dev": true + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "magic-string": { + "version": "0.25.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.7.tgz", + "integrity": "sha512-4CrMT5DOHTDk4HYDlzmwu4FVCcIYI8gauveasrdCu2IKIFOJ3f0v/8MDGJCDL9oD2ppz/Av1b0Nj345H9M+XIA==", + "dev": true, + "requires": { + "sourcemap-codec": "^1.4.4" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "nanoid": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", + "dev": true + }, + "node-releases": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.4.tgz", + "integrity": "sha512-gbMzqQtTtDz/00jQzZ21PQzdI9PyLYqUSvD0p3naOhX4odFji0ZxYdnVwPTxmSwkmxhcFImpozceidSG+AgoPQ==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "picomatch": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.0.tgz", + "integrity": "sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==", + "dev": true + }, + "postcss": { + "version": "8.4.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.13.tgz", + "integrity": "sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==", + "dev": true, + "requires": { + "nanoid": "^3.3.3", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + }, + "react": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-18.1.0.tgz", + "integrity": "sha512-4oL8ivCz5ZEPyclFQXaNksK3adutVS8l2xzZU0cqEFrE9Sb7fC0EFK5uEk74wIreL1DERyjvsU915j1pcT2uEQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, + "react-dom": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.1.0.tgz", + "integrity": "sha512-fU1Txz7Budmvamp7bshe4Zi32d0ll7ect+ccxNu9FlObT605GOEB8BfO4tmRJ39R5Zj831VCpvQ05QPBW5yb+w==", + "requires": { + "loose-envify": "^1.1.0", + "scheduler": "^0.22.0" + } + }, + "react-refresh": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz", + "integrity": "sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg==", + "dev": true + }, + "react-router": { + "version": "6.7.0-pre.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.7.0-pre.3.tgz", + "integrity": "sha512-6Nrovh23eq8ySnr7/CaV4f+jm9Qde6u3w9z4zglXfaVDBRRlO4lUClMOMlvrxr1U+KwqB/qNo4roGyrzJoF6Hg==", + "requires": { + "@remix-run/router": "1.3.0-pre.2" + } + }, + "react-router-dom": { + "version": "6.7.0-pre.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.7.0-pre.3.tgz", + "integrity": "sha512-f/WTO17U5jKM9KJY+0DEcCggKDn8DAWCFbTUdG6XTUMMcTYFqoWzQM6hez+rvasS1HvK8OQBOX7gQ+htnERjcA==", + "requires": { + "@remix-run/router": "1.3.0-pre.2", + "react-router": "6.7.0-pre.3" + } + }, + "resolve": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", + "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", + "dev": true, + "requires": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "rollup": { + "version": "2.72.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.72.1.tgz", + "integrity": "sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==", + "dev": true, + "requires": { + "fsevents": "~2.3.2" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "scheduler": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.22.0.tgz", + "integrity": "sha512-6QAm1BgQI88NPYymgGQLCZgvep4FyePDWFpXVK+zNSUgHwlqpJy8VEh8Et0KxTACS4VWwMousBElAZOH9nkkoQ==", + "requires": { + "loose-envify": "^1.1.0" + } + }, + "semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true + }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true + }, + "sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "typescript": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.6.4.tgz", + "integrity": "sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==", + "dev": true + }, + "vite": { + "version": "2.9.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-2.9.9.tgz", + "integrity": "sha512-ffaam+NgHfbEmfw/Vuh6BHKKlI/XIAhxE5QSS7gFLIngxg171mg1P3a4LSRME0z2ZU1ScxoKzphkipcYwSD5Ew==", + "dev": true, + "requires": { + "esbuild": "^0.14.27", + "fsevents": "~2.3.2", + "postcss": "^8.4.13", + "resolve": "^1.22.0", + "rollup": "^2.59.0" + } + } + } +} diff --git a/examples/navigation-blocking/package.json b/examples/navigation-blocking/package.json new file mode 100644 index 0000000000..b43e98e6f6 --- /dev/null +++ b/examples/navigation-blocking/package.json @@ -0,0 +1,23 @@ +{ + "name": "navigation-blocking", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "serve": "vite preview" + }, + "dependencies": { + "react": "18.1.0", + "react-dom": "18.1.0", + "react-router-dom": "^6.7.0-pre.3" + }, + "devDependencies": { + "@rollup/plugin-replace": "4.0.0", + "@types/node": "17.0.32", + "@types/react": "18.0.9", + "@types/react-dom": "18.0.3", + "@vitejs/plugin-react": "1.3.2", + "typescript": "4.6.4", + "vite": "2.9.9" + } +} diff --git a/examples/navigation-blocking/src/app.tsx b/examples/navigation-blocking/src/app.tsx new file mode 100644 index 0000000000..e9f5d19993 --- /dev/null +++ b/examples/navigation-blocking/src/app.tsx @@ -0,0 +1,141 @@ +import React from "react"; +import type { unstable_Blocker as Blocker } from "react-router-dom"; +import { + createBrowserRouter, + createRoutesFromElements, + Form, + json, + Link, + Outlet, + Route, + RouterProvider, + unstable_useBlocker as useBlocker, + useLocation, +} from "react-router-dom"; + +let router = createBrowserRouter( + createRoutesFromElements( + }> + Index} /> + One} /> + Two} /> + json({ ok: true })} + element={ + <> +

Three

+ + + } + /> + Four} /> + Five} /> + + ) +); + +if (import.meta.hot) { + import.meta.hot.dispose(() => router.dispose()); +} + +export default function App() { + return ; +} + +function Layout() { + let [historyIndex, setHistoryIndex] = React.useState( + window.history.state?.idx + ); + let location = useLocation(); + + // Expose the underlying history index in the UI for debugging + React.useEffect(() => { + setHistoryIndex(window.history.state?.idx); + }, [location]); + + // Give us meaningful document titles for popping back/forward more than 1 entry + React.useEffect(() => { + document.title = location.pathname; + }, [location]); + + return ( + <> +

Navigation Blocking Example

+ +

+ Current location (index): {location.pathname} ({historyIndex}) +

+ + + ); +} + +function ImportantForm() { + let [value, setValue] = React.useState(""); + let isBlocked = value !== ""; + let blocker = useBlocker(isBlocked); + + // Reset the blocker if the user cleans the form + React.useEffect(() => { + if (blocker.state === "blocked" && !isBlocked) { + blocker.reset(); + } + }, [blocker, isBlocked]); + + return ( + <> +

+ Is the form dirty?{" "} + {isBlocked ? ( + Yes + ) : ( + No + )} +

+ +
+ + +
+ + {blocker ? : null} + + ); +} + +function ConfirmNavigation({ blocker }: { blocker: Blocker }) { + if (blocker.state === "blocked") { + return ( + <> +

+ Blocked the last navigation to {blocker.location.pathname} +

+ + + + ); + } + + if (blocker.state === "proceeding") { + return ( +

Proceeding through blocked navigation

+ ); + } + + return

Blocker is currently unblocked

; +} diff --git a/examples/navigation-blocking/src/main.tsx b/examples/navigation-blocking/src/main.tsx new file mode 100644 index 0000000000..32a669c16c --- /dev/null +++ b/examples/navigation-blocking/src/main.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./app"; + +ReactDOM.createRoot(document.getElementById("root")).render( + + + +); diff --git a/examples/navigation-blocking/src/vite-env.d.ts b/examples/navigation-blocking/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/navigation-blocking/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/navigation-blocking/tsconfig.json b/examples/navigation-blocking/tsconfig.json new file mode 100644 index 0000000000..8bdaabfe5d --- /dev/null +++ b/examples/navigation-blocking/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "target": "ESNext", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "importsNotUsedAsValues": "error" + }, + "include": ["./src"] +} diff --git a/examples/navigation-blocking/vite.config.ts b/examples/navigation-blocking/vite.config.ts new file mode 100644 index 0000000000..b77eb48a30 --- /dev/null +++ b/examples/navigation-blocking/vite.config.ts @@ -0,0 +1,36 @@ +import * as path from "path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import rollupReplace from "@rollup/plugin-replace"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + rollupReplace({ + preventAssignment: true, + values: { + __DEV__: JSON.stringify(true), + "process.env.NODE_ENV": JSON.stringify("development"), + }, + }), + react(), + ], + resolve: process.env.USE_SOURCE + ? { + alias: { + "@remix-run/router": path.resolve( + __dirname, + "../../packages/router/index.ts" + ), + "react-router": path.resolve( + __dirname, + "../../packages/react-router/index.ts" + ), + "react-router-dom": path.resolve( + __dirname, + "../../packages/react-router-dom/index.tsx" + ), + }, + } + : {}, +}); diff --git a/package.json b/package.json index 53ba67abbc..fdb6937435 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "release": "changeset publish", "size": "filesize", "test": "jest", + "test:inspect": "node --inspect-brk ./node_modules/.bin/jest", "changeset": "changeset", "version": "changeset version", "postversion": "node scripts/postversion.mjs", @@ -68,6 +69,7 @@ "@types/semver": "^7.3.8", "@typescript-eslint/eslint-plugin": "^4.28.3", "@typescript-eslint/parser": "^4.28.3", + "abort-controller": "^3.0.0", "babel-eslint": "^10.1.0", "babel-jest": "^26.6.3", "babel-plugin-dev-expression": "^0.2.2", @@ -107,19 +109,19 @@ }, "filesize": { "packages/router/dist/router.umd.min.js": { - "none": "38 kB" + "none": "41 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "12.5 kB" + "none": "13 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { "none": "15 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "11 kB" + "none": "11.5 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "16.5 kB" + "none": "17 kB" } } } diff --git a/packages/react-router-dom-v5-compat/CHANGELOG.md b/packages/react-router-dom-v5-compat/CHANGELOG.md index 09ffe42785..e540cfb644 100644 --- a/packages/react-router-dom-v5-compat/CHANGELOG.md +++ b/packages/react-router-dom-v5-compat/CHANGELOG.md @@ -1,5 +1,13 @@ # `react-router-dom-v5-compat` +## 6.7.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@6.7.0` + - `react-router-dom@6.7.0` + ## 6.6.2 ### Patch Changes diff --git a/packages/react-router-dom-v5-compat/package.json b/packages/react-router-dom-v5-compat/package.json index c888038b5c..c552e2209c 100644 --- a/packages/react-router-dom-v5-compat/package.json +++ b/packages/react-router-dom-v5-compat/package.json @@ -1,6 +1,6 @@ { "name": "react-router-dom-v5-compat", - "version": "6.6.2", + "version": "6.7.0", "description": "Migration path to React Router v6 from v4/5", "keywords": [ "react", @@ -24,7 +24,7 @@ "types": "./dist/index.d.ts", "dependencies": { "history": "^5.3.0", - "react-router": "6.6.2" + "react-router": "6.7.0" }, "peerDependencies": { "react": ">=16.8", diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md index 014f41ee6b..a715094030 100644 --- a/packages/react-router-dom/CHANGELOG.md +++ b/packages/react-router-dom/CHANGELOG.md @@ -1,5 +1,21 @@ # `react-router-dom` +## 6.7.0 + +### Minor Changes + +- Add `unstable_useBlocker` hook for blocking navigations within the app's location origin ([#9709](https://github.com/remix-run/react-router/pull/9709)) +- Add `unstable_usePrompt` hook for blocking navigations within the app's location origin ([#9932](https://github.com/remix-run/react-router/pull/9932)) +- Add `preventScrollReset` prop to `
` ([#9886](https://github.com/remix-run/react-router/pull/9886)) + +### Patch Changes + +- Added pass-through event listener options argument to `useBeforeUnload` ([#9709](https://github.com/remix-run/react-router/pull/9709)) +- Streamline jsdom bug workaround in tests ([#9824](https://github.com/remix-run/react-router/pull/9824)) +- Updated dependencies: + - `@remix-run/router@1.3.0` + - `react-router@6.7.0` + ## 6.6.2 ### Patch Changes diff --git a/packages/react-router-dom/__tests__/data-browser-router-test.tsx b/packages/react-router-dom/__tests__/data-browser-router-test.tsx index b1c8ebace3..51c4b754d2 100644 --- a/packages/react-router-dom/__tests__/data-browser-router-test.tsx +++ b/packages/react-router-dom/__tests__/data-browser-router-test.tsx @@ -1506,14 +1506,7 @@ function testDomRouter( function Comp() { let location = useLocation(); return ( - { - // jsdom doesn't handle submitter so we add it here - // See https://github.com/jsdom/jsdom/issues/3117 - // @ts-expect-error - e.nativeEvent.submitter = e.currentTarget.querySelector("button"); - }} - > +

{location.pathname + location.search}

+ + + )} + + + ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("gets a 'proceeding' blocker after proceeding navigation starts", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + act(() => { + click(node.querySelector("[data-action='proceed']")); + }); + expect(blocker).toEqual({ + state: "proceeding", + proceed: undefined, + reset: undefined, + location: expect.any(Object), + }); + }); + + it("gets an 'unblocked' blocker after proceeding navigation completes", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + await act(async () => { + click(node.querySelector("[data-action='proceed']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("navigates after proceeding navigation completes", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + await act(async () => { + click(node.querySelector("[data-action='proceed']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toBe("About"); + }); + + it("gets an 'unblocked' blocker after resetting navigation", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + act(() => { + click(node.querySelector("[data-action='reset']")); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("stays at current location after resetting", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + await act(async () => { + click(node.querySelector("[data-action='reset']")); + // wait for '/about' loader so we catch failure if navigation proceeds + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toBe("Home"); + }); + }); + }); + + describe("on navigation", () => { + describe("blocker returns false", () => { + beforeEach(() => { + let initialEntries = ["/"]; + let initialIndex = 0; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(false); + blocker = b; + return ( +
+ + Home + + + About + + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("navigates", async () => { + await act(async () => { + click(node.querySelector("a[href='/about']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toBe("About"); + }); + + it("gets an 'unblocked' blocker after navigation starts", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("gets an 'unblocked' blocker after navigation completes", async () => { + await act(async () => { + click(node.querySelector("a[href='/about']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + }); + + describe("blocker returns true", () => { + beforeEach(() => { + let initialEntries = ["/"]; + let initialIndex = 0; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(true); + blocker = b; + return ( +
+ + Home + + + About + + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("does not navigate", async () => { + await act(async () => { + click(node.querySelector("a[href='/about']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).not.toBe("About"); + }); + + it("gets a 'blocked' blocker after navigation starts", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + expect(blocker).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + location: expect.any(Object), + }); + }); + + it("gets a 'blocked' blocker after navigation promise resolves", async () => { + await act(async () => { + click(node.querySelector("a[href='/about']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + location: expect.any(Object), + }); + }); + }); + + describe("exiting from blocked state", () => { + beforeEach(() => { + let initialEntries = ["/"]; + let initialIndex = 0; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(true); + blocker = b; + return ( +
+ + Home + + + About + + {b.state === "blocked" && ( +
+ + +
+ )} + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("gets a 'proceeding' blocker after proceeding navigation starts", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + act(() => { + click(node.querySelector("[data-action='proceed']")); + }); + expect(blocker).toEqual({ + state: "proceeding", + proceed: undefined, + reset: undefined, + location: expect.any(Object), + }); + }); + + it("gets an 'unblocked' blocker after proceeding navigation completes", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + await act(async () => { + click(node.querySelector("[data-action='proceed']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("navigates after proceeding navigation completes", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + await act(async () => { + click(node.querySelector("[data-action='proceed']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toBe("About"); + }); + + it("gets an 'unblocked' blocker after resetting navigation", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + act(() => { + click(node.querySelector("[data-action='reset']")); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("stays at current location after resetting", async () => { + act(() => { + click(node.querySelector("a[href='/about']")); + }); + await act(async () => { + click(node.querySelector("[data-action='reset']")); + // wait for '/about' loader so we catch failure if navigation proceeds + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toBe("Home"); + }); + }); + }); + + describe("on POP navigation", () => { + describe("blocker returns false", () => { + beforeEach(() => { + let initialEntries = ["/", "/about", "/contact"]; + let initialIndex = 2; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(false); + let navigate = useNavigate(); + blocker = b; + return ( +
+ + Home + + + About + + + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + { + path: "/contact", + element:

Contact

, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("navigates", async () => { + await act(async () => { + click(node.querySelector("[data-action='back']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toBe("About"); + }); + + it("gets an 'unblocked' blocker after navigation starts", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("gets an 'unblocked' blocker after navigation completes", async () => { + await act(async () => { + click(node.querySelector("[data-action='back']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + }); + + describe("blocker returns true", () => { + beforeEach(() => { + let initialEntries = ["/", "/about", "/contact"]; + let initialIndex = 2; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(true); + let navigate = useNavigate(); + blocker = b; + return ( +
+ + Home + + + About + + + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + { + path: "/contact", + element:

Contact

, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("does not navigate", async () => { + await act(async () => { + click(node.querySelector("[data-action='back']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).not.toBe("About"); + }); + + it("gets a 'blocked' blocker after navigation starts", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + expect(blocker).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + location: expect.any(Object), + }); + }); + + it("gets a 'blocked' blocker after navigation promise resolves", async () => { + await act(async () => { + click(node.querySelector("[data-action='back']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + location: expect.any(Object), + }); + }); + }); + + describe("exiting from blocked state", () => { + beforeEach(() => { + let initialEntries = ["/", "/about", "/contact"]; + let initialIndex = 2; + router = createMemoryRouter( + [ + { + element: React.createElement(() => { + let b = useBlocker(true); + let navigate = useNavigate(); + blocker = b; + return ( +
+ + Home + + + About + + + {b.state === "blocked" && ( +
+ + +
+ )} + +
+ ); + }), + children: [ + { + path: "/", + element:

Home

, + }, + { + path: "/about", + element:

About

, + loader: slowLoader, + }, + { + path: "/contact", + element:

Contact

, + }, + ], + }, + ], + { + initialEntries, + initialIndex, + } + ); + act(() => { + root = ReactDOM.createRoot(node); + root.render(); + }); + }); + + afterEach(() => { + act(() => root.unmount()); + }); + + it("gets a 'proceeding' blocker after proceeding navigation starts", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + act(() => { + click(node.querySelector("[data-action='proceed']")); + }); + expect(blocker).toEqual({ + state: "proceeding", + proceed: undefined, + reset: undefined, + location: expect.any(Object), + }); + }); + + it("gets an 'unblocked' blocker after proceeding navigation completes", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + await act(async () => { + click(node.querySelector("[data-action='proceed']")); + await sleep(LOADER_LATENCY_MS); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("navigates after proceeding navigation completes", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + await act(async () => { + click(node.querySelector("[data-action='proceed']")); + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toBe("About"); + }); + + it("gets an 'unblocked' blocker after resetting navigation", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + act(() => { + click(node.querySelector("[data-action='reset']")); + }); + expect(blocker).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("stays at current location after resetting", async () => { + act(() => { + click(node.querySelector("[data-action='back']")); + }); + await act(async () => { + click(node.querySelector("[data-action='reset']")); + // wait for '/about' loader so we catch failure if navigation proceeds + await sleep(LOADER_LATENCY_MS); + }); + let h1 = node.querySelector("h1"); + expect(h1?.textContent).toBe("Contact"); + }); + }); + }); +}); + +function sleep(n: number = 500) { + return new Promise((r) => setTimeout(r, n)); +} + +function click(target: EventTarget | null | undefined) { + target?.dispatchEvent( + new MouseEvent("click", { + view: window, + bubbles: true, + cancelable: true, + }) + ); +} diff --git a/packages/react-router-dom/dom.ts b/packages/react-router-dom/dom.ts index e6fd5aa73f..21d9b84a15 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router-dom/dom.ts @@ -138,6 +138,12 @@ export interface SubmitOptions { * hierarchy and want to instead route based on /-delimited URL segments */ relative?: RelativeRoutingType; + + /** + * In browser-based environments, prevent resetting scroll after this + * navigation when using the component + */ + preventScrollReset?: boolean; } export function getFormSubmissionInfo( diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index cf0fd14240..520905d4d7 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -18,6 +18,7 @@ import { useNavigate, useNavigation, useResolvedPath, + unstable_useBlocker as useBlocker, UNSAFE_DataRouterContext as DataRouterContext, UNSAFE_DataRouterStateContext as DataRouterStateContext, UNSAFE_NavigationContext as NavigationContext, @@ -76,6 +77,8 @@ export type { ActionFunction, ActionFunctionArgs, AwaitProps, + unstable_Blocker, + unstable_BlockerFunction, DataRouteMatch, DataRouteObject, Fetcher, @@ -142,6 +145,7 @@ export { useActionData, useAsyncError, useAsyncValue, + unstable_useBlocker, useHref, useInRouterContext, useLoaderData, @@ -593,6 +597,12 @@ export interface FormProps extends React.FormHTMLAttributes { */ relative?: RelativeRoutingType; + /** + * Prevent the scroll position from resetting to the top of the viewport on + * completion of the navigation when using the component + */ + preventScrollReset?: boolean; + /** * A function to call when the form is submitted. If you call * `event.preventDefault()` then this form will not do anything. @@ -640,6 +650,7 @@ const FormImpl = React.forwardRef( fetcherKey, routeId, relative, + preventScrollReset, ...props }, forwardedRef @@ -664,6 +675,7 @@ const FormImpl = React.forwardRef( method: submitMethod, replace, relative, + preventScrollReset, }); }; @@ -906,6 +918,7 @@ function useSubmitImpl(fetcherKey?: string, routeId?: string): SubmitFunction { let href = url.pathname + url.search; let opts = { replace: options.replace, + preventScrollReset: options.preventScrollReset, formData, formMethod: method as FormMethod, formEncType: encType as FormEncType, @@ -1000,8 +1013,9 @@ export type FetcherWithComponents = Fetcher & { Form: ReturnType; submit: ( target: SubmitTarget, - // Fetchers cannot replace because they are not navigation events - options?: Omit + // Fetchers cannot replace/preventScrollReset because they are not + // navigation events + options?: Omit ) => void; load: (href: string) => void; }; @@ -1165,7 +1179,7 @@ function useScrollRestoration({ } } - // Opt out of scroll reset if this link requested it + // Don't reset if this navigation opted out if (preventScrollReset === true) { return; } @@ -1185,15 +1199,50 @@ function useScrollRestoration({ * `React.useCallback()`. */ export function useBeforeUnload( - callback: (event: BeforeUnloadEvent) => any + callback: (event: BeforeUnloadEvent) => any, + options?: { capture?: boolean } ): void { + let { capture } = options || {}; React.useEffect(() => { - window.addEventListener("beforeunload", callback); + let opts = capture != null ? { capture } : undefined; + window.addEventListener("beforeunload", callback, opts); return () => { - window.removeEventListener("beforeunload", callback); + window.removeEventListener("beforeunload", callback, opts); }; - }, [callback]); + }, [callback, capture]); +} + +/** + * Wrapper around useBlocker to show a window.confirm prompt to users instead + * of building a custom UI with useBlocker. + * + * Warning: This has *a lot of rough edges* and behaves very differently (and + * very incorrectly in some cases) across browsers if user click addition + * back/forward navigations while the confirm is open. Use at your own risk. + */ +function usePrompt({ when, message }: { when: boolean; message: string }) { + let blocker = useBlocker(when); + + React.useEffect(() => { + if (blocker.state === "blocked" && !when) { + blocker.reset(); + } + }, [blocker, when]); + + React.useEffect(() => { + if (blocker.state === "blocked") { + let proceed = window.confirm(message); + if (proceed) { + setTimeout(blocker.proceed, 0); + } else { + blocker.reset(); + } + } + }, [blocker, message]); } + +export { usePrompt as unstable_usePrompt }; + //#endregion //////////////////////////////////////////////////////////////////////////////// diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json index fe20f0f587..cb97d88e2f 100644 --- a/packages/react-router-dom/package.json +++ b/packages/react-router-dom/package.json @@ -1,6 +1,6 @@ { "name": "react-router-dom", - "version": "6.6.2", + "version": "6.7.0", "description": "Declarative routing for React web applications", "keywords": [ "react", @@ -23,8 +23,8 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "dependencies": { - "@remix-run/router": "1.2.1", - "react-router": "6.6.2" + "@remix-run/router": "1.3.0", + "react-router": "6.7.0" }, "devDependencies": { "react": "^18.2.0", diff --git a/packages/react-router-dom/server.tsx b/packages/react-router-dom/server.tsx index a8ac8cb10c..3ace651b43 100644 --- a/packages/react-router-dom/server.tsx +++ b/packages/react-router-dom/server.tsx @@ -263,6 +263,7 @@ export function createStaticRouter( preventScrollReset: false, revalidation: "idle" as RevalidationState, fetchers: new Map(), + blockers: new Map(), }; }, get routes() { @@ -297,6 +298,12 @@ export function createStaticRouter( dispose() { throw msg("dispose"); }, + getBlocker() { + throw msg("getBlocker"); + }, + deleteBlocker() { + throw msg("deleteBlocker"); + }, _internalFetchControllers: new Map(), _internalActiveDeferreds: new Map(), }; diff --git a/packages/react-router-native/CHANGELOG.md b/packages/react-router-native/CHANGELOG.md index c998537fa3..4b27fee2c5 100644 --- a/packages/react-router-native/CHANGELOG.md +++ b/packages/react-router-native/CHANGELOG.md @@ -1,5 +1,12 @@ # `react-router-native` +## 6.7.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@6.7.0` + ## 6.6.2 ### Patch Changes diff --git a/packages/react-router-native/index.tsx b/packages/react-router-native/index.tsx index 5d66fccb83..d89e3fba19 100644 --- a/packages/react-router-native/index.tsx +++ b/packages/react-router-native/index.tsx @@ -23,6 +23,8 @@ export type { ActionFunction, ActionFunctionArgs, AwaitProps, + unstable_Blocker, + unstable_BlockerFunction, DataRouteMatch, DataRouteObject, Fetcher, @@ -89,6 +91,7 @@ export { useActionData, useAsyncError, useAsyncValue, + unstable_useBlocker, useHref, useInRouterContext, useLoaderData, diff --git a/packages/react-router-native/package.json b/packages/react-router-native/package.json index e13806dc4b..9c3c01523f 100644 --- a/packages/react-router-native/package.json +++ b/packages/react-router-native/package.json @@ -1,6 +1,6 @@ { "name": "react-router-native", - "version": "6.6.2", + "version": "6.7.0", "description": "Declarative routing for React Native applications", "keywords": [ "react", @@ -22,7 +22,7 @@ "types": "./dist/index.d.ts", "dependencies": { "@ungap/url-search-params": "^0.1.4", - "react-router": "6.6.2" + "react-router": "6.7.0" }, "devDependencies": { "react": "^18.2.0", diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 3c56e4fdf2..f7bf85d4d2 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,5 +1,18 @@ # `react-router` +## 6.7.0 + +### Minor Changes + +- Add `unstable_useBlocker` hook for blocking navigations within the app's location origin ([#9709](https://github.com/remix-run/react-router/pull/9709)) + +### Patch Changes + +- Fix `generatePath` when optional params are present ([#9764](https://github.com/remix-run/react-router/pull/9764)) +- Update `` to accept `ReactNode` as children function return result ([#9896](https://github.com/remix-run/react-router/pull/9896)) +- Updated dependencies: + - `@remix-run/router@1.3.0` + ## 6.6.2 ### Patch Changes diff --git a/packages/react-router/__tests__/generatePath-test.tsx b/packages/react-router/__tests__/generatePath-test.tsx index 105a2ed63f..1e1d3059ee 100644 --- a/packages/react-router/__tests__/generatePath-test.tsx +++ b/packages/react-router/__tests__/generatePath-test.tsx @@ -25,6 +25,7 @@ describe("generatePath", () => { expect(generatePath("*", { "*": "routing/grades" })).toBe( "routing/grades" ); + expect(generatePath("/*", {})).toBe("/"); }); }); @@ -49,6 +50,60 @@ describe("generatePath", () => { }); }); + describe("with optional params", () => { + it("adds optional dynamic params where appropriate", () => { + let path = "/:one?/:two?/:three?"; + expect(generatePath(path, { one: "uno" })).toBe("/uno"); + expect(generatePath(path, { one: "uno", two: "dos" })).toBe("/uno/dos"); + expect( + generatePath(path, { + one: "uno", + two: "dos", + three: "tres", + }) + ).toBe("/uno/dos/tres"); + expect(generatePath(path, { one: "uno", three: "tres" })).toBe( + "/uno/tres" + ); + expect(generatePath(path, { two: "dos" })).toBe("/dos"); + expect(generatePath(path, { two: "dos", three: "tres" })).toBe( + "/dos/tres" + ); + }); + + it("strips optional aspects of static segments", () => { + expect(generatePath("/one?/two?/:three?", {})).toBe("/one/two"); + expect(generatePath("/one?/two?/:three?", { three: "tres" })).toBe( + "/one/two/tres" + ); + }); + + it("handles intermixed segments", () => { + let path = "/one?/:two?/three/:four/*"; + expect(generatePath(path, { four: "cuatro" })).toBe("/one/three/cuatro"); + expect( + generatePath(path, { + two: "dos", + four: "cuatro", + }) + ).toBe("/one/dos/three/cuatro"); + expect( + generatePath(path, { + two: "dos", + four: "cuatro", + "*": "splat", + }) + ).toBe("/one/dos/three/cuatro/splat"); + expect( + generatePath(path, { + two: "dos", + four: "cuatro", + "*": "splat/and/then/some", + }) + ).toBe("/one/dos/three/cuatro/splat/and/then/some"); + }); + }); + it("throws only on on missing named parameters, but not missing splat params", () => { expect(() => generatePath(":foo")).toThrow(); expect(() => generatePath("/:foo")).toThrow(); diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index 2af02fef6c..3d55ed2e22 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -1,6 +1,8 @@ import type { ActionFunction, ActionFunctionArgs, + Blocker, + BlockerFunction, Fetcher, HydrationState, JsonFunction, @@ -82,6 +84,7 @@ import { } from "./lib/context"; import type { NavigateFunction } from "./lib/hooks"; import { + useBlocker, useHref, useInRouterContext, useLocation, @@ -114,6 +117,8 @@ export type { ActionFunction, ActionFunctionArgs, AwaitProps, + Blocker as unstable_Blocker, + BlockerFunction as unstable_BlockerFunction, DataRouteMatch, DataRouteObject, Fetcher, @@ -179,6 +184,7 @@ export { useActionData, useAsyncError, useAsyncValue, + useBlocker as unstable_useBlocker, useHref, useInRouterContext, useLoaderData, diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index d023669a1b..b76e5781e8 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -394,7 +394,7 @@ export function Routes({ } export interface AwaitResolveRenderFunction { - (data: Awaited): React.ReactElement; + (data: Awaited): React.ReactNode; } export interface AwaitProps { @@ -531,10 +531,8 @@ function ResolveAwait({ children: React.ReactNode | AwaitResolveRenderFunction; }) { let data = useAsyncValue(); - if (typeof children === "function") { - return children(data); - } - return <>{children}; + let toRender = typeof children === "function" ? children(data) : children; + return <>{toRender}; } /////////////////////////////////////////////////////////////////////////////// diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 96ff63e747..eb4bc220a8 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1,5 +1,7 @@ import * as React from "react"; import type { + Blocker, + BlockerFunction, Location, ParamParseKey, Params, @@ -650,6 +652,7 @@ export function _renderMatches( } enum DataRouterHook { + UseBlocker = "useBlocker", UseRevalidator = "useRevalidator", } @@ -818,6 +821,36 @@ export function useAsyncError(): unknown { return value?._error; } +// useBlocker() is a singleton for now since we don't have any compelling use +// cases for multi-blocker yet +let blockerKey = "blocker-singleton"; + +/** + * Allow the application to block navigations within the SPA and present the + * user a confirmation dialog to confirm the navigation. Mostly used to avoid + * using half-filled form data. This does not handle hard-reloads or + * cross-origin navigations. + */ +export function useBlocker(shouldBlock: boolean | BlockerFunction): Blocker { + let { router } = useDataRouterContext(DataRouterHook.UseBlocker); + + let blockerFunction = React.useCallback( + (args) => { + return typeof shouldBlock === "function" + ? !!shouldBlock(args) + : !!shouldBlock; + }, + [shouldBlock] + ); + + let blocker = router.getBlocker(blockerKey, blockerFunction); + + // Cleanup on unmount + React.useEffect(() => () => router.deleteBlocker(blockerKey), [router]); + + return blocker; +} + const alreadyWarned: Record = {}; function warningOnce(key: string, cond: boolean, message: string) { diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 2b573058e3..d630efa5cd 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "react-router", - "version": "6.6.2", + "version": "6.7.0", "description": "Declarative routing for React", "keywords": [ "react", @@ -23,7 +23,7 @@ "module": "./dist/index.js", "types": "./dist/index.d.ts", "dependencies": { - "@remix-run/router": "1.2.1" + "@remix-run/router": "1.3.0" }, "devDependencies": { "react": "^18.2.0" diff --git a/packages/router/CHANGELOG.md b/packages/router/CHANGELOG.md index ce2a6d35ff..dcd242a130 100644 --- a/packages/router/CHANGELOG.md +++ b/packages/router/CHANGELOG.md @@ -1,5 +1,21 @@ # `@remix-run/router` +## 1.3.0 + +### Minor Changes + +- Added support for navigation blocking APIs ([#9709](https://github.com/remix-run/react-router/pull/9709)) +- Expose deferred information from `createStaticHandler` ([#9760](https://github.com/remix-run/react-router/pull/9760)) + +### Patch Changes + +- Improved absolute redirect url detection in actions/loaders ([#9829](https://github.com/remix-run/react-router/pull/9829)) +- Fix URL creation with memory histories ([#9814](https://github.com/remix-run/react-router/pull/9814)) +- Fix `generatePath` when optional params are present ([#9764](https://github.com/remix-run/react-router/pull/9764)) +- Fix scroll reset if a submission redirects ([#9886](https://github.com/remix-run/react-router/pull/9886)) +- Fix 404 bug with same-origin absolute redirects ([#9913](https://github.com/remix-run/react-router/pull/9913)) +- Support `OPTIONS` requests in `staticHandler.queryRoute` ([#9914](https://github.com/remix-run/react-router/pull/9914)) + ## 1.2.1 ### Patch Changes diff --git a/packages/router/__tests__/TestSequences/GoBack.ts b/packages/router/__tests__/TestSequences/GoBack.ts index d7009441eb..03a6711818 100644 --- a/packages/router/__tests__/TestSequences/GoBack.ts +++ b/packages/router/__tests__/TestSequences/GoBack.ts @@ -31,6 +31,7 @@ export default async function GoBack(history: History) { }); expect(spy).toHaveBeenCalledWith({ action: "POP", + delta: expect.any(Number), location: { hash: "", key: expect.any(String), diff --git a/packages/router/__tests__/TestSequences/GoForward.ts b/packages/router/__tests__/TestSequences/GoForward.ts index 5949524b26..ccc9f09c3a 100644 --- a/packages/router/__tests__/TestSequences/GoForward.ts +++ b/packages/router/__tests__/TestSequences/GoForward.ts @@ -31,6 +31,7 @@ export default async function GoForward(history: History) { }); expect(spy).toHaveBeenCalledWith({ action: "POP", + delta: expect.any(Number), location: { hash: "", key: expect.any(String), @@ -58,6 +59,7 @@ export default async function GoForward(history: History) { }); expect(spy).toHaveBeenCalledWith({ action: "POP", + delta: expect.any(Number), location: { hash: "", key: expect.any(String), diff --git a/packages/router/__tests__/browser-test.ts b/packages/router/__tests__/browser-test.ts index a5fb3c9e9a..b699f501ae 100644 --- a/packages/router/__tests__/browser-test.ts +++ b/packages/router/__tests__/browser-test.ts @@ -1,7 +1,3 @@ -/** - * @jest-environment ./__tests__/custom-environment.js - */ - /* eslint-disable jest/expect-expect */ import { JSDOM } from "jsdom"; diff --git a/packages/router/__tests__/custom-environment.js b/packages/router/__tests__/custom-environment.js deleted file mode 100644 index b3ff337de5..0000000000 --- a/packages/router/__tests__/custom-environment.js +++ /dev/null @@ -1,19 +0,0 @@ -const Environment = require("jest-environment-jsdom"); - -/** - * A custom environment to set the TextEncoder that is required by JSDOM - * See: https://stackoverflow.com/questions/57712235/referenceerror-textencoder-is-not-defined-when-running-react-scripts-test - */ -module.exports = class CustomTestEnvironment extends Environment { - async setup() { - await super.setup(); - if (typeof this.global.TextEncoder === "undefined") { - const { TextEncoder } = require("util"); - this.global.TextEncoder = TextEncoder; - } - if (typeof this.global.TextDecoder === "undefined") { - const { TextDecoder } = require("util"); - this.global.TextDecoder = TextDecoder; - } - } -}; diff --git a/packages/router/__tests__/hash-base-test.ts b/packages/router/__tests__/hash-base-test.ts index 07abd0fd66..511df27394 100644 --- a/packages/router/__tests__/hash-base-test.ts +++ b/packages/router/__tests__/hash-base-test.ts @@ -1,7 +1,3 @@ -/** - * @jest-environment ./__tests__/custom-environment.js - */ - import { JSDOM } from "jsdom"; import type { HashHistory } from "@remix-run/router"; diff --git a/packages/router/__tests__/hash-test.ts b/packages/router/__tests__/hash-test.ts index 0fec44766f..9a2f166f82 100644 --- a/packages/router/__tests__/hash-test.ts +++ b/packages/router/__tests__/hash-test.ts @@ -1,7 +1,3 @@ -/** - * @jest-environment ./__tests__/custom-environment.js - */ - /* eslint-disable jest/expect-expect */ import { JSDOM } from "jsdom"; diff --git a/packages/router/__tests__/navigation-blocking-test.ts b/packages/router/__tests__/navigation-blocking-test.ts new file mode 100644 index 0000000000..5b010f8a1f --- /dev/null +++ b/packages/router/__tests__/navigation-blocking-test.ts @@ -0,0 +1,493 @@ +import type { Router } from "../index"; +import { createMemoryHistory, createRouter } from "../index"; + +const LOADER_LATENCY_MS = 100; +const routes = [ + { path: "/" }, + { + path: "/about", + loader: () => sleep(LOADER_LATENCY_MS), + }, + { path: "/contact" }, + { path: "/help" }, +]; + +describe("navigation blocking", () => { + let router: Router; + it("initializes an 'unblocked' blocker", () => { + router = createRouter({ + history: createMemoryHistory({ + initialEntries: ["/"], + initialIndex: 0, + }), + routes, + }); + router.initialize(); + + let fn = () => true; + router.getBlocker("KEY", fn); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + describe("on history push", () => { + let initialEntries = ["/", "/about"]; + let initialIndex = 0; + beforeEach(() => { + router = createRouter({ + history: createMemoryHistory({ + initialEntries, + initialIndex, + }), + routes, + }); + router.initialize(); + }); + + describe("blocker returns false", () => { + let fn = () => false; + it("navigates", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about"); + expect(router.state.location.pathname).toBe("/about"); + }); + + it("gets an 'unblocked' blocker after navigation starts", async () => { + router.getBlocker("KEY", fn); + router.navigate("/about"); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("gets an 'unblocked' blocker after navigation completes", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about"); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + }); + + describe("blocker returns true", () => { + let fn = () => true; + + it("does not navigate", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about"); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex] + ); + }); + + it("gets a 'blocked' blocker after navigation starts", async () => { + router.getBlocker("KEY", fn); + router.navigate("/about"); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + location: expect.any(Object), + }); + }); + + it("gets a 'blocked' blocker after navigation promise resolves", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about"); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + location: expect.any(Object), + }); + }); + }); + + describe("proceeds from blocked state", () => { + let fn = () => true; + it("gets a 'proceeding' blocker after proceeding navigation starts", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about"); + router.getBlocker("KEY", fn).proceed?.(); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "proceeding", + proceed: undefined, + reset: undefined, + location: expect.any(Object), + }); + }); + + it("gets an 'unblocked' blocker after proceeding navigation completes", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about"); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("navigates after proceeding navigation completes", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about"); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); + expect(router.state.location.pathname).toBe("/about"); + }); + }); + + describe("resets from blocked state", () => { + let fn = () => true; + it("gets an 'unblocked' blocker after resetting navigation", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about"); + router.getBlocker("KEY", fn).reset?.(); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("stays at current location after resetting", async () => { + router.getBlocker("KEY", fn); + let pathnameBeforeNavigation = router.state.location.pathname; + await router.navigate("/about"); + router.getBlocker("KEY", fn).reset?.(); + + // wait for '/about' loader so we catch failure if navigation proceeds + await sleep(LOADER_LATENCY_MS); + expect(router.state.location.pathname).toBe(pathnameBeforeNavigation); + }); + }); + }); + + describe("on history replace", () => { + let initialEntries = ["/", "/about"]; + let initialIndex = 0; + beforeEach(() => { + router = createRouter({ + history: createMemoryHistory({ + initialEntries, + initialIndex, + }), + routes, + }); + router.initialize(); + }); + + describe("blocker returns false", () => { + let fn = () => false; + it("navigates", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about", { replace: true }); + expect(router.state.location.pathname).toBe("/about"); + }); + + it("gets an 'unblocked' blocker after navigation starts", async () => { + router.getBlocker("KEY", fn); + router.navigate("/about", { replace: true }); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("gets an 'unblocked' blocker after navigation completes", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about", { replace: true }); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + }); + + describe("blocker returns true", () => { + let fn = () => true; + + it("does not navigate", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about", { replace: true }); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex] + ); + }); + + it("gets a 'blocked' blocker after navigation starts", async () => { + router.getBlocker("KEY", fn); + router.navigate("/about", { replace: true }); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + location: expect.any(Object), + }); + }); + + it("gets a 'blocked' blocker after navigation promise resolves", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about", { replace: true }); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + location: expect.any(Object), + }); + }); + }); + + describe("proceeds from blocked state", () => { + let fn = () => true; + it("gets a 'proceeding' blocker after proceeding navigation starts", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about", { replace: true }); + router.getBlocker("KEY", fn).proceed?.(); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "proceeding", + proceed: undefined, + reset: undefined, + location: expect.any(Object), + }); + }); + + it("gets an 'unblocked' blocker after proceeding navigation completes", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about", { replace: true }); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("navigates after proceeding navigation completes", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about", { replace: true }); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); + expect(router.state.location.pathname).toBe("/about"); + }); + + it("replaces the current history entry after proceeding completes", async () => { + router.getBlocker("KEY", fn); + let historyLengthBeforeNavigation = window.history.length; + await router.navigate("/about", { replace: true }); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); + expect(window.history.length).toBe(historyLengthBeforeNavigation); + }); + }); + + describe("resets from blocked state", () => { + let fn = () => true; + it("gets an 'unblocked' blocker after resetting navigation", async () => { + router.getBlocker("KEY", fn); + await router.navigate("/about", { replace: true }); + router.getBlocker("KEY", fn).reset?.(); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("stays at current location after resetting", async () => { + router.getBlocker("KEY", fn); + let pathnameBeforeNavigation = router.state.location.pathname; + await router.navigate("/about", { replace: true }); + router.getBlocker("KEY", fn).reset?.(); + + // wait for '/about' loader so we catch failure if navigation proceeds + await sleep(LOADER_LATENCY_MS); + expect(router.state.location.pathname).toBe(pathnameBeforeNavigation); + }); + }); + }); + + describe("on history pop", () => { + let initialEntries = ["/", "/about", "/contact", "/help"]; + let initialIndex = 1; + beforeEach(() => { + router = createRouter({ + history: createMemoryHistory({ + initialEntries, + initialIndex, + }), + routes, + }); + router.initialize(); + }); + + describe("blocker returns false", () => { + let fn = () => false; + it("navigates", async () => { + router.getBlocker("KEY", fn); + await router.navigate(-1); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex - 1] + ); + }); + + it("gets an 'unblocked' blocker after navigation starts", async () => { + router.getBlocker("KEY", fn); + router.navigate(-1); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("gets an 'unblocked' blocker after navigation completes", async () => { + router.getBlocker("KEY", fn); + await router.navigate(-1); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + }); + + describe("blocker returns true", () => { + let fn = () => true; + + it("does not navigate", async () => { + router.getBlocker("KEY", fn); + await router.navigate(-1); + expect(router.state.location.pathname).toBe( + initialEntries[initialIndex] + ); + }); + + it("gets a 'blocked' blocker after navigation starts", async () => { + router.getBlocker("KEY", fn); + router.navigate(-1); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + location: expect.any(Object), + }); + }); + + it("gets a 'blocked' blocker after navigation promise resolves", async () => { + router.getBlocker("KEY", fn); + await router.navigate(-1); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "blocked", + proceed: expect.any(Function), + reset: expect.any(Function), + location: expect.any(Object), + }); + }); + }); + + describe("proceeds from blocked state", () => { + let fn = () => true; + + // we want to navigate so that `/about` is the previous entry in the + // stack here since it has a loader that won't resolve immediately + let initialEntries = ["/", "/about", "/contact"]; + let initialIndex = 2; + beforeEach(() => { + router = createRouter({ + history: createMemoryHistory({ + initialEntries, + initialIndex, + }), + routes, + }); + router.initialize(); + }); + + it("gets a 'proceeding' blocker after proceeding navigation starts", async () => { + router.getBlocker("KEY", fn); + await router.navigate(-1); + router.getBlocker("KEY", fn).proceed?.(); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "proceeding", + proceed: undefined, + reset: undefined, + location: expect.any(Object), + }); + }); + + it("gets an 'unblocked' blocker after proceeding navigation completes", async () => { + router.getBlocker("KEY", fn); + await router.navigate(-1); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("navigates after proceeding navigation completes", async () => { + router.getBlocker("KEY", fn); + await router.navigate(-1); + router.getBlocker("KEY", fn).proceed?.(); + await sleep(LOADER_LATENCY_MS); + expect(router.state.location.pathname).toBe("/about"); + }); + }); + + describe("resets from blocked state", () => { + let fn = () => true; + it("gets an 'unblocked' blocker after resetting navigation", async () => { + router.getBlocker("KEY", fn); + await router.navigate(-1); + router.getBlocker("KEY", fn).reset?.(); + expect(router.getBlocker("KEY", fn)).toEqual({ + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, + }); + }); + + it("stays at current location after resetting", async () => { + router.getBlocker("KEY", fn); + let pathnameBeforeNavigation = router.state.location.pathname; + await router.navigate(-1); + router.getBlocker("KEY", fn).reset?.(); + + // wait for '/about' loader so we catch failure if navigation proceeds + await sleep(LOADER_LATENCY_MS); + expect(router.state.location.pathname).toBe(pathnameBeforeNavigation); + }); + }); + }); +}); + +function sleep(n: number = 500) { + return new Promise((r) => setTimeout(r, n)); +} diff --git a/packages/router/__tests__/router-memory-test.ts b/packages/router/__tests__/router-memory-test.ts new file mode 100644 index 0000000000..721d6f7188 --- /dev/null +++ b/packages/router/__tests__/router-memory-test.ts @@ -0,0 +1,180 @@ +/** + * @jest-environment node + */ + +import { createMemoryHistory, createRouter } from "../index"; + +// This suite of tests specifically runs in the node jest environment to catch +// issues when window is not present +describe("a memory router", () => { + it("initializes properly", async () => { + let router = createRouter({ + routes: [ + { + path: "/", + }, + ], + history: createMemoryHistory(), + }); + expect(router.state).toEqual({ + historyAction: "POP", + loaderData: {}, + actionData: null, + errors: null, + location: { + hash: "", + key: expect.any(String), + pathname: "/", + search: "", + state: null, + }, + matches: [ + { + params: {}, + pathname: "/", + pathnameBase: "/", + route: { + id: "0", + path: "/", + }, + }, + ], + initialized: true, + navigation: { + location: undefined, + state: "idle", + }, + preventScrollReset: false, + restoreScrollPosition: null, + revalidation: "idle", + fetchers: new Map(), + blockers: new Map(), + }); + router.dispose(); + }); + + it("can create Requests without window", async () => { + let loaderSpy = jest.fn(); + let router = createRouter({ + routes: [ + { + path: "/", + }, + { + path: "/a", + loader: loaderSpy, + }, + ], + history: createMemoryHistory(), + }); + + router.navigate("/a"); + expect(loaderSpy.mock.calls[0][0].request.url).toBe("http://localhost/a"); + router.dispose(); + }); + + it("can create URLs without window", async () => { + let shouldRevalidateSpy = jest.fn(); + + let router = createRouter({ + routes: [ + { + path: "/", + loader: () => "ROOT", + shouldRevalidate: shouldRevalidateSpy, + children: [ + { + index: true, + }, + { + path: "a", + }, + ], + }, + ], + history: createMemoryHistory(), + hydrationData: { loaderData: { "0": "ROOT" } }, + }); + + router.navigate("/a"); + expect(shouldRevalidateSpy.mock.calls[0][0].currentUrl.toString()).toBe( + "http://localhost/" + ); + expect(shouldRevalidateSpy.mock.calls[0][0].nextUrl.toString()).toBe( + "http://localhost/a" + ); + router.dispose(); + }); + + it("properly handles same-origin absolute URLs", async () => { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + index: true, + }, + { + path: "a", + loader: () => + new Response(null, { + status: 302, + headers: { + Location: "http://localhost/b", + }, + }), + }, + { + path: "b", + }, + ], + }, + ], + history: createMemoryHistory(), + }); + + await router.navigate("/a"); + expect(router.state.location).toMatchObject({ + hash: "", + pathname: "/b", + search: "", + }); + }); + + it("properly handles protocol-less same-origin absolute URLs", async () => { + let router = createRouter({ + routes: [ + { + path: "/", + children: [ + { + index: true, + }, + { + path: "a", + loader: () => + new Response(null, { + status: 302, + headers: { + Location: "//localhost/b", + }, + }), + }, + { + path: "b", + }, + ], + }, + ], + history: createMemoryHistory(), + }); + + await router.navigate("/a"); + expect(router.state.location).toMatchObject({ + hash: "", + pathname: "/b", + search: "", + }); + }); +}); diff --git a/packages/router/__tests__/router-test.ts b/packages/router/__tests__/router-test.ts index 39d2c8aac2..bab237948d 100644 --- a/packages/router/__tests__/router-test.ts +++ b/packages/router/__tests__/router-test.ts @@ -16,6 +16,7 @@ import { createRouter, createStaticHandler, defer, + UNSAFE_DEFERRED_SYMBOL, ErrorResponse, IDLE_FETCHER, IDLE_NAVIGATION, @@ -30,6 +31,7 @@ import type { AgnosticIndexRouteObject, AgnosticNonIndexRouteObject, AgnosticRouteObject, + DeferredData, TrackedPromise, } from "../utils"; import { @@ -157,8 +159,13 @@ function isRedirect(result: any) { ); } -interface CustomMatchers { +interface CustomMatchers { trackedPromise(data?: any, error?: any, aborted?: boolean): R; + deferredData( + done: boolean, + status?: number, + headers?: Record + ): R; } declare global { @@ -169,12 +176,40 @@ declare global { } } -// Custom matcher for asserting deferred promise results inside of `toEqual()` -// - expect.trackedPromise() => pending promise -// - expect.trackedPromise(value) => promise resolved with `value` -// - expect.trackedPromise(null, error) => promise rejected with `error` -// - expect.trackedPromise(null, null, true) => promise aborted expect.extend({ + // Custom matcher for asserting deferred promise results for static handler + // - expect(val).deferredData(false) => Unresolved promise + // - expect(val).deferredData(false) => Resolved promise + // - expect(val).deferredData(false, 201, { 'x-custom': 'yes' }) + // => Unresolved promise with status + headers + // - expect(val).deferredData(true, 201, { 'x-custom': 'yes' }) + // => Resolved promise with status + headers + deferredData(received, done, status = 200, headers = {}) { + let deferredData = received as DeferredData; + + return { + message: () => + `expected done=${String( + done + )}/status=${status}/headers=${JSON.stringify(headers)}, ` + + `instead got done=${String(deferredData.done)}/status=${ + deferredData.init!.status || 200 + }/headers=${JSON.stringify( + Object.fromEntries(new Headers(deferredData.init!.headers).entries()) + )}`, + pass: + deferredData.done === done && + (deferredData.init!.status || 200) === status && + JSON.stringify( + Object.fromEntries(new Headers(deferredData.init!.headers).entries()) + ) === JSON.stringify(headers), + }; + }, + // Custom matcher for asserting deferred promise results inside of `toEqual()` + // - expect.trackedPromise() => pending promise + // - expect.trackedPromise(value) => promise resolved with `value` + // - expect.trackedPromise(null, error) => promise rejected with `error` + // - expect.trackedPromise(null, null, true) => promise aborted trackedPromise(received, data, error, aborted = false) { let promise = received as TrackedPromise; let isTrackedPromise = @@ -922,6 +957,7 @@ describe("a router", () => { restoreScrollPosition: null, revalidation: "idle", fetchers: new Map(), + blockers: new Map(), }); }); @@ -983,6 +1019,7 @@ describe("a router", () => { restoreScrollPosition: false, revalidation: "idle", fetchers: new Map(), + blockers: new Map(), }); }); @@ -6186,6 +6223,31 @@ describe("a router", () => { } }); + it("properly handles same-origin absolute URLs", async () => { + let t = setup({ routes: REDIRECT_ROUTES }); + + let A = await t.navigate("/parent/child", { + formMethod: "post", + formData: createFormData({}), + }); + + let B = await A.actions.child.redirectReturn( + "http://localhost/parent", + undefined, + undefined, + ["parent"] + ); + await B.loaders.parent.resolve("PARENT"); + expect(t.router.state.location).toMatchObject({ + hash: "", + pathname: "/parent", + search: "", + state: { + _isRedirect: true, + }, + }); + }); + describe("redirect status code handling", () => { it("should not treat 300 as a redirect", async () => { let t = setup({ routes: REDIRECT_ROUTES }); @@ -6379,166 +6441,423 @@ describe("a router", () => { }, ]; - it("restores scroll on initial load (w/o hydrationData)", async () => { - let t = setup({ - routes: SCROLL_ROUTES, - initialEntries: ["/no-loader"], - }); + describe("scroll restoration", () => { + it("restores scroll on initial load (w/o hydrationData)", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/no-loader"], + }); - expect(t.router.state.restoreScrollPosition).toBe(null); - expect(t.router.state.preventScrollReset).toBe(false); + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(false); - // Assume initial location had a saved position - let positions = { default: 50 }; - t.router.enableScrollRestoration(positions, () => 0); - expect(t.router.state.restoreScrollPosition).toBe(50); - }); + // Assume initial location had a saved position + let positions = { default: 50 }; + t.router.enableScrollRestoration(positions, () => 0); + expect(t.router.state.restoreScrollPosition).toBe(50); + }); - it("restores scroll on initial load (w/ hydrationData)", async () => { - let t = setup({ - routes: SCROLL_ROUTES, - initialEntries: ["/"], - hydrationData: { - loaderData: { - index: "INDEX", + it("restores scroll on initial load (w/ hydrationData)", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + hydrationData: { + loaderData: { + index: "INDEX", + }, }, - }, - }); + }); - expect(t.router.state.restoreScrollPosition).toBe(false); - expect(t.router.state.preventScrollReset).toBe(false); + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); - // Assume initial location had a saved position - let positions = { default: 50 }; - t.router.enableScrollRestoration(positions, () => 0); - expect(t.router.state.restoreScrollPosition).toBe(false); - }); + // Assume initial location had a saved position + let positions = { default: 50 }; + t.router.enableScrollRestoration(positions, () => 0); + expect(t.router.state.restoreScrollPosition).toBe(false); + }); - it("restores scroll on navigations", async () => { - let t = setup({ - routes: SCROLL_ROUTES, - initialEntries: ["/"], - hydrationData: { - loaderData: { - index: "INDEX_DATA", + it("restores scroll on navigations", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + hydrationData: { + loaderData: { + index: "INDEX_DATA", + }, }, - }, - }); + }); - expect(t.router.state.restoreScrollPosition).toBe(false); - expect(t.router.state.preventScrollReset).toBe(false); + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); - let positions = {}; + let positions = {}; - // Simulate scrolling to 100 on / - let activeScrollPosition = 100; - t.router.enableScrollRestoration(positions, () => activeScrollPosition); + // Simulate scrolling to 100 on / + let activeScrollPosition = 100; + t.router.enableScrollRestoration(positions, () => activeScrollPosition); - // No restoration on first click to /tasks - let nav1 = await t.navigate("/tasks"); - await nav1.loaders.tasks.resolve("TASKS"); - expect(t.router.state.restoreScrollPosition).toBe(null); - expect(t.router.state.preventScrollReset).toBe(false); + // No restoration on first click to /tasks + let nav1 = await t.navigate("/tasks"); + await nav1.loaders.tasks.resolve("TASKS"); + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(false); - // Simulate scrolling down on /tasks - activeScrollPosition = 200; + // Simulate scrolling down on /tasks + activeScrollPosition = 200; - // Restore on pop back to / - let nav2 = await t.navigate(-1); - expect(t.router.state.restoreScrollPosition).toBe(null); - await nav2.loaders.index.resolve("INDEX"); - expect(t.router.state.restoreScrollPosition).toBe(100); - expect(t.router.state.preventScrollReset).toBe(false); + // Restore on pop back to / + let nav2 = await t.navigate(-1); + expect(t.router.state.restoreScrollPosition).toBe(null); + await nav2.loaders.index.resolve("INDEX"); + expect(t.router.state.restoreScrollPosition).toBe(100); + expect(t.router.state.preventScrollReset).toBe(false); - // Restore on pop forward to /tasks - let nav3 = await t.navigate(1); - await nav3.loaders.tasks.resolve("TASKS"); - expect(t.router.state.restoreScrollPosition).toBe(200); - expect(t.router.state.preventScrollReset).toBe(false); - }); + // Restore on pop forward to /tasks + let nav3 = await t.navigate(1); + await nav3.loaders.tasks.resolve("TASKS"); + expect(t.router.state.restoreScrollPosition).toBe(200); + expect(t.router.state.preventScrollReset).toBe(false); + }); - it("restores scroll using custom key", async () => { - let t = setup({ - routes: SCROLL_ROUTES, - initialEntries: ["/"], - hydrationData: { - loaderData: { - index: "INDEX_DATA", + it("restores scroll using custom key", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + hydrationData: { + loaderData: { + index: "INDEX_DATA", + }, }, - }, - }); + }); - expect(t.router.state.restoreScrollPosition).toBe(false); - expect(t.router.state.preventScrollReset).toBe(false); + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); - let positions = { "/tasks": 100 }; - let activeScrollPosition = 0; - t.router.enableScrollRestoration( - positions, - () => activeScrollPosition, - (l) => l.pathname - ); + let positions = { "/tasks": 100 }; + let activeScrollPosition = 0; + t.router.enableScrollRestoration( + positions, + () => activeScrollPosition, + (l) => l.pathname + ); - let nav1 = await t.navigate("/tasks"); - await nav1.loaders.tasks.resolve("TASKS"); - expect(t.router.state.restoreScrollPosition).toBe(100); - expect(t.router.state.preventScrollReset).toBe(false); - }); + let nav1 = await t.navigate("/tasks"); + await nav1.loaders.tasks.resolve("TASKS"); + expect(t.router.state.restoreScrollPosition).toBe(100); + expect(t.router.state.preventScrollReset).toBe(false); + }); - it("does not restore scroll on submissions", async () => { - let t = setup({ - routes: SCROLL_ROUTES, - initialEntries: ["/"], - hydrationData: { - loaderData: { - index: "INDEX_DATA", + it("restores scroll on GET submissions", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/tasks"], + hydrationData: { + loaderData: { + tasks: "TASKS", + }, }, - }, + }); + + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); + // We were previously on tasks at 100 + let positions = { "/tasks": 100 }; + // But we've scrolled up to 50 to submit. We'll save this overtop of + // the 100 when we start this submission navigation and then restore to + // 50 below + let activeScrollPosition = 50; + t.router.enableScrollRestoration( + positions, + () => activeScrollPosition, + (l) => l.pathname + ); + + let nav1 = await t.navigate("/tasks", { + formMethod: "get", + formData: createFormData({ key: "value" }), + }); + await nav1.loaders.tasks.resolve("TASKS2"); + expect(t.router.state.restoreScrollPosition).toBe(50); + expect(t.router.state.preventScrollReset).toBe(false); }); - expect(t.router.state.restoreScrollPosition).toBe(false); - expect(t.router.state.preventScrollReset).toBe(false); + it("restores scroll on POST submissions", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/tasks"], + hydrationData: { + loaderData: { + index: "INDEX_DATA", + }, + }, + }); - let positions = { "/tasks": 100 }; - let activeScrollPosition = 0; - t.router.enableScrollRestoration( - positions, - () => activeScrollPosition, - (l) => l.pathname - ); + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); + // We were previously on tasks at 100 + let positions = { "/tasks": 100 }; + // But we've scrolled up to 50 to submit. We'll save this overtop of + // the 100 when we start this submission navigation and then restore to + // 50 below + let activeScrollPosition = 50; + t.router.enableScrollRestoration( + positions, + () => activeScrollPosition, + (l) => l.pathname + ); - let nav1 = await t.navigate("/tasks", { - formMethod: "post", - formData: createFormData({}), + let nav1 = await t.navigate("/tasks", { + formMethod: "post", + formData: createFormData({}), + }); + const nav2 = await nav1.actions.tasks.redirectReturn("/tasks"); + await nav2.loaders.tasks.resolve("TASKS"); + expect(t.router.state.restoreScrollPosition).toBe(50); + expect(t.router.state.preventScrollReset).toBe(false); }); - await nav1.actions.tasks.resolve("ACTION"); - await nav1.loaders.tasks.resolve("TASKS"); - expect(t.router.state.restoreScrollPosition).toBe(false); - expect(t.router.state.preventScrollReset).toBe(false); }); - it("does not reset scroll", async () => { - let t = setup({ - routes: SCROLL_ROUTES, - initialEntries: ["/"], - hydrationData: { - loaderData: { - index: "INDEX_DATA", - }, - }, + describe("scroll reset", () => { + describe("default behavior", () => { + it("resets on navigations", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + hydrationData: { + loaderData: { + index: "INDEX_DATA", + }, + }, + }); + + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); + + let positions = {}; + let activeScrollPosition = 0; + t.router.enableScrollRestoration( + positions, + () => activeScrollPosition + ); + + let nav1 = await t.navigate("/tasks"); + await nav1.loaders.tasks.resolve("TASKS"); + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(false); + }); + + it("resets on navigations that redirect", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + hydrationData: { + loaderData: { + index: "INDEX_DATA", + }, + }, + }); + + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); + + let positions = {}; + let activeScrollPosition = 0; + t.router.enableScrollRestoration( + positions, + () => activeScrollPosition + ); + + let nav1 = await t.navigate("/tasks"); + let nav2 = await nav1.loaders.tasks.redirectReturn("/"); + await nav2.loaders.index.resolve("INDEX_DATA 2"); + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(false); + }); + + it("does not reset on submission navigations", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + hydrationData: { + loaderData: { + index: "INDEX_DATA", + }, + }, + }); + + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); + + let positions = {}; + let activeScrollPosition = 0; + t.router.enableScrollRestoration( + positions, + () => activeScrollPosition + ); + + let nav1 = await t.navigate("/tasks", { + formMethod: "post", + formData: createFormData({}), + }); + await nav1.actions.tasks.resolve("ACTION"); + await nav1.loaders.tasks.resolve("TASKS"); + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(true); + }); + + it("resets on submission navigations that redirect", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + hydrationData: { + loaderData: { + index: "INDEX_DATA", + }, + }, + }); + + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); + + let positions = {}; + let activeScrollPosition = 0; + t.router.enableScrollRestoration( + positions, + () => activeScrollPosition + ); + + let nav1 = await t.navigate("/tasks", { + formMethod: "post", + formData: createFormData({}), + }); + let nav2 = await nav1.actions.tasks.redirectReturn("/"); + await nav2.loaders.index.resolve("INDEX_DATA2"); + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(false); + }); }); - expect(t.router.state.restoreScrollPosition).toBe(false); - expect(t.router.state.preventScrollReset).toBe(false); + describe("user-specified flag preventScrollReset flag", () => { + it("prevents scroll reset on navigations", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + hydrationData: { + loaderData: { + index: "INDEX_DATA", + }, + }, + }); + + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); + + let positions = {}; + let activeScrollPosition = 0; + t.router.enableScrollRestoration( + positions, + () => activeScrollPosition + ); + + let nav1 = await t.navigate("/tasks", { preventScrollReset: true }); + await nav1.loaders.tasks.resolve("TASKS"); + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(true); + }); + + it("prevents scroll reset on navigations that redirect", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + hydrationData: { + loaderData: { + index: "INDEX_DATA", + }, + }, + }); + + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); + + let positions = {}; + let activeScrollPosition = 0; + t.router.enableScrollRestoration( + positions, + () => activeScrollPosition + ); - let positions = {}; - let activeScrollPosition = 0; - t.router.enableScrollRestoration(positions, () => activeScrollPosition); + let nav1 = await t.navigate("/tasks", { preventScrollReset: true }); + let nav2 = await nav1.loaders.tasks.redirectReturn("/"); + await nav2.loaders.index.resolve("INDEX_DATA 2"); + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(true); + }); + + it("prevents scroll reset on submission navigations", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + hydrationData: { + loaderData: { + index: "INDEX_DATA", + }, + }, + }); - let nav1 = await t.navigate("/tasks", { preventScrollReset: true }); - await nav1.loaders.tasks.resolve("TASKS"); - expect(t.router.state.restoreScrollPosition).toBe(null); - expect(t.router.state.preventScrollReset).toBe(true); + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); + + let positions = {}; + let activeScrollPosition = 0; + t.router.enableScrollRestoration( + positions, + () => activeScrollPosition + ); + + let nav1 = await t.navigate("/tasks", { + formMethod: "post", + formData: createFormData({}), + preventScrollReset: true, + }); + await nav1.actions.tasks.resolve("ACTION"); + await nav1.loaders.tasks.resolve("TASKS"); + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(true); + }); + + it("prevents scroll reset on submission navigations that redirect", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + hydrationData: { + loaderData: { + index: "INDEX_DATA", + }, + }, + }); + + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); + + let positions = {}; + let activeScrollPosition = 0; + t.router.enableScrollRestoration( + positions, + () => activeScrollPosition + ); + + let nav1 = await t.navigate("/tasks", { + formMethod: "post", + formData: createFormData({}), + preventScrollReset: true, + }); + let nav2 = await nav1.actions.tasks.redirectReturn("/"); + await nav2.loaders.index.resolve("INDEX_DATA2"); + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(true); + }); + }); }); }); @@ -10948,14 +11267,32 @@ describe("a router", () => { { id: "deferred", path: "deferred", - loader: () => - defer({ + loader: ({ request }) => { + if (new URL(request.url).searchParams.has("reject")) { + return defer({ + critical: "loader", + lazy: new Promise((_, r) => + setTimeout(() => r(new Error("broken!")), 10) + ), + }); + } + if (new URL(request.url).searchParams.has("status")) { + return defer( + { + critical: "loader", + lazy: new Promise((r) => setTimeout(() => r("lazy"), 10)), + }, + { status: 201, headers: { "X-Custom": "yes" } } + ); + } + return defer({ critical: "loader", lazy: new Promise((r) => setTimeout(() => r("lazy"), 10)), - }), + }); + }, action: () => defer({ - critical: "action", + critical: "critical", lazy: new Promise((r) => setTimeout(() => r("lazy"), 10)), }), }, @@ -10981,6 +11318,20 @@ describe("a router", () => { }, ]; + // Regardless of if the URL is internal or external - all absolute URL + // responses should return untouched during SSR so the browser can handle + // them + let ABSOLUTE_URLS = [ + "http://localhost/", + "https://localhost/about", + "http://remix.run/blog", + "https://remix.run/blog", + "//remix.run/blog", + "app://whatever", + "mailto:hello@remix.run", + "web+remix:whatever", + ]; + function createRequest(path: string, opts?: RequestInit) { return new Request(`http://localhost${path}`, { signal: new AbortController().signal, @@ -11112,8 +11463,7 @@ describe("a router", () => { }); }); - // Note: this is only until we wire up the remix streaming - it("should abort deferred data on load navigations (for now)", async () => { + it("should support document load navigations returning deferred", async () => { let { query } = createStaticHandler(SSR_ROUTES); let context = await query(createRequest("/parent/deferred")); expect(context).toMatchObject({ @@ -11122,19 +11472,29 @@ describe("a router", () => { parent: "PARENT LOADER", deferred: { critical: "loader", - lazy: expect.trackedPromise(null, null, true), + lazy: expect.trackedPromise(), }, }, + activeDeferreds: { + deferred: expect.deferredData(false), + }, errors: null, location: { pathname: "/parent/deferred" }, matches: [{ route: { id: "parent" } }, { route: { id: "deferred" } }], }); await new Promise((r) => setTimeout(r, 10)); - expect( - (context as StaticHandlerContext).loaderData.deferred.lazy instanceof - Promise - ).toBe(true); + + expect(context).toMatchObject({ + loaderData: { + deferred: { + lazy: expect.trackedPromise("lazy"), + }, + }, + activeDeferreds: { + deferred: expect.deferredData(true), + }, + }); }); it("should support document submit navigations", async () => { @@ -11295,15 +11655,8 @@ describe("a router", () => { expect((response as Response).headers.get("Location")).toBe("/parent"); }); - it("should handle external redirect Responses", async () => { - let urls = [ - "http://remix.run/blog", - "https://remix.run/blog", - "//remix.run/blog", - "app://whatever", - ]; - - for (let url of urls) { + it("should handle absolute redirect Responses", async () => { + for (let url of ABSOLUTE_URLS) { let handler = createStaticHandler([ { path: "/", @@ -11685,6 +12038,127 @@ describe("a router", () => { expect(arg(childStub).context.sessionId).toBe("12345"); }); + describe("deferred", () => { + let { query } = createStaticHandler(SSR_ROUTES); + + it("should return DeferredData on symbol", async () => { + let context = (await query( + createRequest("/parent/deferred") + )) as StaticHandlerContext; + expect(context).toMatchObject({ + loaderData: { + parent: "PARENT LOADER", + deferred: { + critical: "loader", + lazy: expect.trackedPromise(), + }, + }, + activeDeferreds: { + deferred: expect.deferredData(false), + }, + }); + await new Promise((r) => setTimeout(r, 10)); + expect(context).toMatchObject({ + loaderData: { + parent: "PARENT LOADER", + deferred: { + critical: "loader", + lazy: expect.trackedPromise("lazy"), + }, + }, + activeDeferreds: { + deferred: expect.deferredData(true), + }, + }); + }); + + it("should return rejected DeferredData on symbol", async () => { + let context = (await query( + createRequest("/parent/deferred?reject") + )) as StaticHandlerContext; + expect(context).toMatchObject({ + loaderData: { + parent: "PARENT LOADER", + deferred: { + critical: "loader", + lazy: expect.trackedPromise(), + }, + }, + activeDeferreds: { + deferred: expect.deferredData(false), + }, + }); + await new Promise((r) => setTimeout(r, 10)); + expect(context).toMatchObject({ + loaderData: { + parent: "PARENT LOADER", + deferred: { + critical: "loader", + lazy: expect.trackedPromise(undefined, new Error("broken!")), + }, + }, + activeDeferreds: { + deferred: expect.deferredData(true), + }, + }); + }); + + it("should return DeferredData on symbol with status + headers", async () => { + let context = (await query( + createRequest("/parent/deferred?status") + )) as StaticHandlerContext; + expect(context).toMatchObject({ + loaderData: { + parent: "PARENT LOADER", + deferred: { + critical: "loader", + lazy: expect.trackedPromise(), + }, + }, + activeDeferreds: { + deferred: expect.deferredData(false, 201, { + "x-custom": "yes", + }), + }, + }); + await new Promise((r) => setTimeout(r, 10)); + expect(context).toMatchObject({ + loaderData: { + parent: "PARENT LOADER", + deferred: { + critical: "loader", + lazy: expect.trackedPromise("lazy"), + }, + }, + activeDeferreds: { + deferred: expect.deferredData(true, 201, { + "x-custom": "yes", + }), + }, + }); + }); + + it("does not support deferred on submissions", async () => { + let context = (await query( + createSubmitRequest("/parent/deferred") + )) as StaticHandlerContext; + expect(context.actionData).toEqual(null); + expect(context.loaderData).toEqual({ + parent: null, + deferred: null, + }); + expect(context.activeDeferreds).toEqual(null); + expect(context.errors).toEqual({ + parent: new ErrorResponse( + 400, + "Bad Request", + new Error("defer() is not supported in actions"), + true + ), + }); + }); + }); + describe("statusCode", () => { it("should expose a 200 status code by default", async () => { let { query } = createStaticHandler([ @@ -12074,6 +12548,14 @@ describe("a router", () => { expect(data).toBe("PARENT LOADER"); }); + it("should support OPTIONS requests", async () => { + let { queryRoute } = createStaticHandler(SSR_ROUTES); + let data = await queryRoute( + createRequest("/parent", { method: "OPTIONS" }) + ); + expect(data).toBe("PARENT LOADER"); + }); + it("should support singular route load navigations (primitives)", async () => { let { queryRoute } = createStaticHandler(SSR_ROUTES); let data; @@ -12510,15 +12992,8 @@ describe("a router", () => { expect((response as Response).headers.get("Location")).toBe("/parent"); }); - it("should handle external redirect Responses", async () => { - let urls = [ - "http://remix.run/blog", - "https://remix.run/blog", - "//remix.run/blog", - "app://whatever", - ]; - - for (let url of urls) { + it("should handle absolute redirect Responses", async () => { + for (let url of ABSOLUTE_URLS) { let handler = createStaticHandler([ { id: "root", @@ -12661,6 +13136,80 @@ describe("a router", () => { expect(arg(actionStub).context.sessionId).toBe("12345"); }); + describe("deferred", () => { + let { queryRoute } = createStaticHandler(SSR_ROUTES); + + it("should return DeferredData on symbol", async () => { + let result = await queryRoute(createRequest("/parent/deferred")); + expect(result).toMatchObject({ + critical: "loader", + lazy: expect.trackedPromise(), + }); + expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(false); + await new Promise((r) => setTimeout(r, 10)); + expect(result).toMatchObject({ + critical: "loader", + lazy: expect.trackedPromise("lazy"), + }); + expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(true); + }); + + it("should return rejected DeferredData on symbol", async () => { + let result = await queryRoute( + createRequest("/parent/deferred?reject") + ); + expect(result).toMatchObject({ + critical: "loader", + lazy: expect.trackedPromise(), + }); + expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(false); + await new Promise((r) => setTimeout(r, 10)); + expect(result).toMatchObject({ + critical: "loader", + lazy: expect.trackedPromise(null, new Error("broken!")), + }); + expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(true); + }); + + it("should return DeferredData on symbol with status + headers", async () => { + let result = await queryRoute( + createRequest("/parent/deferred?status") + ); + expect(result).toMatchObject({ + critical: "loader", + lazy: expect.trackedPromise(), + }); + expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(false, 201, { + "x-custom": "yes", + }); + await new Promise((r) => setTimeout(r, 10)); + expect(result).toMatchObject({ + critical: "loader", + lazy: expect.trackedPromise("lazy"), + }); + expect(result[UNSAFE_DEFERRED_SYMBOL]).deferredData(true, 201, { + "x-custom": "yes", + }); + }); + + it("does not support deferred on submissions", async () => { + try { + await queryRoute(createSubmitRequest("/parent/deferred")); + expect(false).toBe(true); + } catch (e) { + // eslint-disable-next-line jest/no-conditional-expect + expect(e).toEqual( + new ErrorResponse( + 400, + "Bad Request", + new Error("defer() is not supported in actions"), + true + ) + ); + } + }); + }); + describe("Errors with Status Codes", () => { /* eslint-disable jest/no-conditional-expect */ let { queryRoute } = createStaticHandler([ @@ -12758,7 +13307,7 @@ describe("a router", () => { it("should handle unsupported methods with a 405 Response", async () => { try { - await queryRoute(createRequest("/", { method: "OPTIONS" }), { + await queryRoute(createRequest("/", { method: "TRACE" }), { routeId: "root", }); expect(false).toBe(true); @@ -12766,7 +13315,7 @@ describe("a router", () => { expect(isRouteErrorResponse(data)).toBe(true); expect(data.status).toBe(405); expect(data.error).toEqual( - new Error('Invalid request method "OPTIONS"') + new Error('Invalid request method "TRACE"') ); expect(data.internal).toBe(true); } diff --git a/packages/router/__tests__/setup.ts b/packages/router/__tests__/setup.ts index 6d9d8d4a16..2588f2abd7 100644 --- a/packages/router/__tests__/setup.ts +++ b/packages/router/__tests__/setup.ts @@ -1,4 +1,9 @@ +import { + TextEncoder as NodeTextEncoder, + TextDecoder as NodeTextDecoder, +} from "util"; import { fetch, Request, Response } from "@remix-run/web-fetch"; +import { AbortController as NodeAbortController } from "abort-controller"; if (!globalThis.fetch) { // Built-in lib.dom.d.ts expects `fetch(Request | string, ...)` but the web @@ -13,3 +18,14 @@ if (!globalThis.fetch) { // @ts-expect-error globalThis.Response = Response; } + +if (!globalThis.AbortController) { + // @ts-expect-error + globalThis.AbortController = NodeAbortController; +} + +if (!globalThis.TextEncoder || !globalThis.TextDecoder) { + globalThis.TextEncoder = NodeTextEncoder; + // @ts-expect-error + globalThis.TextDecoder = NodeTextDecoder; +} diff --git a/packages/router/history.ts b/packages/router/history.ts index e5117817b7..fee3e1191f 100644 --- a/packages/router/history.ts +++ b/packages/router/history.ts @@ -81,6 +81,11 @@ export interface Update { * The new location. */ location: Location; + + /** + * The delta between this location and the former location in the history stack + */ + delta: number; } /** @@ -125,6 +130,13 @@ export interface History { */ createHref(to: To): string; + /** + * Returns a URL for the given `to` value + * + * @param to - The destination URL + */ + createURL(to: To): URL; + /** * Encode a location the same way window.history would do (no-op for memory * history) so we ensure our PUSH/REPLACE navigations for data routers @@ -174,6 +186,7 @@ export interface History { type HistoryState = { usr: any; key?: string; + idx: number; }; const PopStateEventType = "popstate"; @@ -255,6 +268,10 @@ export function createMemoryHistory( return location; } + function createHref(to: To) { + return typeof to === "string" ? to : createPath(to); + } + let history: MemoryHistory = { get index() { return index; @@ -265,8 +282,9 @@ export function createMemoryHistory( get location() { return getCurrentLocation(); }, - createHref(to) { - return typeof to === "string" ? to : createPath(to); + createHref, + createURL(to) { + return new URL(createHref(to), "http://localhost"); }, encodeLocation(to: To) { let path = typeof to === "string" ? parsePath(to) : to; @@ -282,7 +300,7 @@ export function createMemoryHistory( index += 1; entries.splice(index, entries.length, nextLocation); if (v5Compat && listener) { - listener({ action, location: nextLocation }); + listener({ action, location: nextLocation, delta: 1 }); } }, replace(to, state) { @@ -290,14 +308,16 @@ export function createMemoryHistory( let nextLocation = createMemoryLocation(to, state); entries[index] = nextLocation; if (v5Compat && listener) { - listener({ action, location: nextLocation }); + listener({ action, location: nextLocation, delta: 0 }); } }, go(delta) { action = Action.Pop; - index = clampIndex(index + delta); + let nextIndex = clampIndex(index + delta); + let nextLocation = entries[nextIndex]; + index = nextIndex; if (listener) { - listener({ action, location: getCurrentLocation() }); + listener({ action, location: nextLocation, delta }); } }, listen(fn: Listener) { @@ -485,10 +505,11 @@ function createKey() { /** * For browser-based histories, we combine the state and key into an object */ -function getHistoryState(location: Location): HistoryState { +function getHistoryState(location: Location, index: number): HistoryState { return { usr: location.state, key: location.key, + idx: index, }; } @@ -558,24 +579,6 @@ export function parsePath(path: string): Partial { return parsedPath; } -export function createClientSideURL(location: Location | string): URL { - // window.location.origin is "null" (the literal string value) in Firefox - // under certain conditions, notably when serving from a local HTML file - // See https://bugzilla.mozilla.org/show_bug.cgi?id=878297 - let base = - typeof window !== "undefined" && - typeof window.location !== "undefined" && - window.location.origin !== "null" - ? window.location.origin - : window.location.href; - let href = typeof location === "string" ? location : createPath(location); - invariant( - base, - `No window.location.(origin|href) available to create URL for href: ${href}` - ); - return new URL(href, base); -} - export interface UrlHistory extends History {} export type UrlHistoryOptions = { @@ -594,10 +597,43 @@ function getUrlBasedHistory( let action = Action.Pop; let listener: Listener | null = null; + let index = getIndex()!; + // Index should only be null when we initialize. If not, it's because the + // user called history.pushState or history.replaceState directly, in which + // case we should log a warning as it will result in bugs. + if (index == null) { + index = 0; + globalHistory.replaceState({ ...globalHistory.state, idx: index }, ""); + } + + function getIndex(): number { + let state = globalHistory.state || { idx: null }; + return state.idx; + } + function handlePop() { - action = Action.Pop; - if (listener) { - listener({ action, location: history.location }); + let nextAction = Action.Pop; + let nextIndex = getIndex(); + + if (nextIndex != null) { + let delta = nextIndex - index; + action = nextAction; + index = nextIndex; + if (listener) { + listener({ action, location: history.location, delta }); + } + } else { + warning( + false, + // TODO: Write up a doc that explains our blocking strategy in detail + // and link to it here so people can understand better what is going on + // and how to avoid it. + `You are trying to block a POP navigation to a location that was not ` + + `created by @remix-run/router. The block will fail silently in ` + + `production, but in general you should do all navigation with the ` + + `router (instead of using window.history.pushState directly) ` + + `to avoid this situation.` + ); } } @@ -606,7 +642,8 @@ function getUrlBasedHistory( let location = createLocation(history.location, to, state); if (validateLocation) validateLocation(location, to); - let historyState = getHistoryState(location); + index = getIndex() + 1; + let historyState = getHistoryState(location, index); let url = history.createHref(location); // try...catch because iOS limits us to 100 pushState calls :/ @@ -619,7 +656,7 @@ function getUrlBasedHistory( } if (v5Compat && listener) { - listener({ action, location: history.location }); + listener({ action, location: history.location, delta: 1 }); } } @@ -628,15 +665,33 @@ function getUrlBasedHistory( let location = createLocation(history.location, to, state); if (validateLocation) validateLocation(location, to); - let historyState = getHistoryState(location); + index = getIndex(); + let historyState = getHistoryState(location, index); let url = history.createHref(location); globalHistory.replaceState(historyState, "", url); if (v5Compat && listener) { - listener({ action, location: history.location }); + listener({ action, location: history.location, delta: 0 }); } } + function createURL(to: To): URL { + // window.location.origin is "null" (the literal string value) in Firefox + // under certain conditions, notably when serving from a local HTML file + // See https://bugzilla.mozilla.org/show_bug.cgi?id=878297 + let base = + window.location.origin !== "null" + ? window.location.origin + : window.location.href; + + let href = typeof to === "string" ? to : createPath(to); + invariant( + base, + `No window.location.(origin|href) available to create URL for href: ${href}` + ); + return new URL(href, base); + } + let history: History = { get action() { return action; @@ -659,11 +714,10 @@ function getUrlBasedHistory( createHref(to) { return createHref(window, to); }, + createURL, encodeLocation(to) { // Encode a Location the same way window.location would - let url = createClientSideURL( - typeof to === "string" ? to : createPath(to) - ); + let url = createURL(to); return { pathname: url.pathname, search: url.search, diff --git a/packages/router/index.ts b/packages/router/index.ts index 23bfca95b1..21670631d3 100644 --- a/packages/router/index.ts +++ b/packages/router/index.ts @@ -1,5 +1,3 @@ -import { convertRoutesToDataRoutes, getPathContributingMatches } from "./utils"; - export type { ActionFunction, ActionFunctionArgs, @@ -58,6 +56,7 @@ export type { Path, To, } from "./history"; + export { Action, createBrowserHistory, @@ -79,6 +78,7 @@ export * from "./router"; /** @internal */ export { + DeferredData as UNSAFE_DeferredData, convertRoutesToDataRoutes as UNSAFE_convertRoutesToDataRoutes, getPathContributingMatches as UNSAFE_getPathContributingMatches, -}; +} from "./utils"; diff --git a/packages/router/package.json b/packages/router/package.json index bfb01fc5a4..dcccad7b37 100644 --- a/packages/router/package.json +++ b/packages/router/package.json @@ -1,6 +1,6 @@ { "name": "@remix-run/router", - "version": "1.2.1", + "version": "1.3.0", "description": "Nested/Data-driven/Framework-agnostic Routing", "keywords": [ "remix", diff --git a/packages/router/router.ts b/packages/router/router.ts index 6365a49e58..0924aa4f76 100644 --- a/packages/router/router.ts +++ b/packages/router/router.ts @@ -3,7 +3,6 @@ import { Action as HistoryAction, createLocation, createPath, - createClientSideURL, invariant, parsePath, } from "./history"; @@ -33,6 +32,7 @@ import { joinPaths, matchRoutes, resolveTo, + warning, } from "./utils"; //////////////////////////////////////////////////////////////////////////////// @@ -111,14 +111,14 @@ export interface Router { * Navigate forward/backward in the history stack * @param to Delta to move in the history stack */ - navigate(to: number): void; + navigate(to: number): Promise; /** * Navigate to the given path * @param to Path to navigate to * @param opts Navigation options (method, submission, etc.) */ - navigate(to: To, opts?: RouterNavigateOptions): void; + navigate(to: To, opts?: RouterNavigateOptions): Promise; /** * @internal @@ -191,6 +191,25 @@ export interface Router { */ dispose(): void; + /** + * @internal + * PRIVATE - DO NOT USE + * + * Get a navigation blocker + * @param key The identifier for the blocker + * @param fn The blocker function implementation + */ + getBlocker(key: string, fn: BlockerFunction): Blocker; + + /** + * @internal + * PRIVATE - DO NOT USE + * + * Delete a navigation blocker + * @param key The identifier for the blocker + */ + deleteBlocker(key: string): void; + /** * @internal * PRIVATE - DO NOT USE @@ -276,6 +295,11 @@ export interface RouterState { * Map of current fetchers */ fetchers: Map; + + /** + * Map of current blockers + */ + blockers: Map; } /** @@ -308,6 +332,7 @@ export interface StaticHandlerContext { statusCode: number; loaderHeaders: Record; actionHeaders: Record; + activeDeferreds: Record | null; _deepestRenderedBoundaryId?: string | null; } @@ -371,6 +396,7 @@ type LinkNavigateOptions = { type SubmissionNavigateOptions = { replace?: boolean; state?: any; + preventScrollReset?: boolean; formMethod?: FormMethod; formEncType?: FormEncType; formData: FormData; @@ -460,6 +486,35 @@ type FetcherStates = { export type Fetcher = FetcherStates[keyof FetcherStates]; +interface BlockerBlocked { + state: "blocked"; + reset(): void; + proceed(): void; + location: Location; +} + +interface BlockerUnblocked { + state: "unblocked"; + reset: undefined; + proceed: undefined; + location: undefined; +} + +interface BlockerProceeding { + state: "proceeding"; + reset: undefined; + proceed: undefined; + location: Location; +} + +export type Blocker = BlockerUnblocked | BlockerBlocked | BlockerProceeding; + +export type BlockerFunction = (args: { + currentLocation: Location; + nextLocation: Location; + historyAction: HistoryAction; +}) => boolean; + interface ShortCircuitable { /** * startNavigation does not need to complete the navigation because we @@ -561,6 +616,13 @@ export const IDLE_FETCHER: FetcherStates["Idle"] = { formData: undefined, }; +export const IDLE_BLOCKER: BlockerUnblocked = { + state: "unblocked", + proceed: undefined, + reset: undefined, + location: undefined, +}; + const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined" && @@ -636,50 +698,76 @@ export function createRouter(init: RouterInit): Router { actionData: (init.hydrationData && init.hydrationData.actionData) || null, errors: (init.hydrationData && init.hydrationData.errors) || initialErrors, fetchers: new Map(), + blockers: new Map(), }; // -- Stateful internal variables to manage navigations -- // Current navigation in progress (to be committed in completeNavigation) let pendingAction: HistoryAction = HistoryAction.Pop; + // Should the current navigation prevent the scroll reset if scroll cannot // be restored? let pendingPreventScrollReset = false; + // AbortController for the active navigation let pendingNavigationController: AbortController | null; + // We use this to avoid touching history in completeNavigation if a // revalidation is entirely uninterrupted let isUninterruptedRevalidation = false; + // Use this internal flag to force revalidation of all loaders: // - submissions (completed or interrupted) // - useRevalidate() // - X-Remix-Revalidate (from redirect) let isRevalidationRequired = false; + // Use this internal array to capture routes that require revalidation due // to a cancelled deferred on action submission let cancelledDeferredRoutes: string[] = []; + // Use this internal array to capture fetcher loads that were cancelled by an // action navigation and require revalidation let cancelledFetcherLoads: string[] = []; + // AbortControllers for any in-flight fetchers let fetchControllers = new Map(); + // Track loads based on the order in which they started let incrementingLoadId = 0; + // Track the outstanding pending navigation data load to be compared against // the globally incrementing load when a fetcher load lands after a completed // navigation let pendingNavigationLoadId = -1; + // Fetchers that triggered data reloads as a result of their actions let fetchReloadIds = new Map(); + // Fetchers that triggered redirect navigations from their actions let fetchRedirectIds = new Set(); + // Most recent href/match for fetcher.load calls for fetchers let fetchLoadMatches = new Map(); + // Store DeferredData instances for active route matches. When a // route loader returns defer() we stick one in here. Then, when a nested // promise resolves we update loaderData. If a new navigation starts we // cancel active deferreds for eliminated routes. let activeDeferreds = new Map(); + // We ony support a single active blocker at the moment since we don't have + // any compelling use cases for multi-blocker yet + let activeBlocker: string | null = null; + + // Store blocker functions in a separate Map outside of router state since + // we don't need to update UI state if they change + let blockerFunctions = new Map(); + + // Flag to ignore the next history update, so we can revert the URL change on + // a POP navigation that was blocked by the user without touching router state + let ignoreNextHistoryUpdate = false; + // Initialize the router, all side effects should be kicked off from here. // Implemented as a Fluent API for ease of: // let router = createRouter(init).initialize(); @@ -687,8 +775,48 @@ export function createRouter(init: RouterInit): Router { // If history informs us of a POP navigation, start the navigation but do not update // state. We'll update our own state once the navigation completes unlistenHistory = init.history.listen( - ({ action: historyAction, location }) => - startNavigation(historyAction, location) + ({ action: historyAction, location, delta }) => { + // Ignore this event if it was just us resetting the URL from a + // blocked POP navigation + if (ignoreNextHistoryUpdate) { + ignoreNextHistoryUpdate = false; + return; + } + + let blockerKey = shouldBlockNavigation({ + currentLocation: state.location, + nextLocation: location, + historyAction, + }); + if (blockerKey) { + // Restore the URL to match the current UI, but don't update router state + ignoreNextHistoryUpdate = true; + init.history.go(delta * -1); + + // Put the blocker into a blocked state + updateBlocker(blockerKey, { + state: "blocked", + location, + proceed() { + updateBlocker(blockerKey!, { + state: "proceeding", + proceed: undefined, + reset: undefined, + location, + }); + // Re-do the same POP navigation we just blocked + init.history.go(delta); + }, + reset() { + deleteBlocker(blockerKey!); + updateState({ blockers: new Map(router.state.blockers) }); + }, + }); + return; + } + + return startNavigation(historyAction, location); + } ); // Kick off initial data load if needed. Use Pop to avoid modifying history @@ -707,6 +835,7 @@ export function createRouter(init: RouterInit): Router { subscribers.clear(); pendingNavigationController && pendingNavigationController.abort(); state.fetchers.forEach((_, key) => deleteFetcher(key)); + state.blockers.forEach((_, key) => deleteBlocker(key)); } // Subscribe to state updates for the router @@ -771,6 +900,20 @@ export function createRouter(init: RouterInit): Router { ) : state.loaderData; + // On a successful navigation we can assume we got through all blockers + // so we can start fresh + for (let [key] of blockerFunctions) { + deleteBlocker(key); + } + + // Always respect the user flag. Otherwise don't reset on mutation + // submission navigations unless they redirect + let preventScrollReset = + pendingPreventScrollReset === true || + (state.navigation.formMethod != null && + isMutationMethod(state.navigation.formMethod) && + location.state?._isRedirect !== true); + updateState({ ...newState, // matches, errors, fetchers go through as-is actionData, @@ -780,11 +923,12 @@ export function createRouter(init: RouterInit): Router { initialized: true, navigation: IDLE_NAVIGATION, revalidation: "idle", - // Don't restore on submission navigations - restoreScrollPosition: state.navigation.formData - ? false - : getSavedScrollPosition(location, newState.matches || state.matches), - preventScrollReset: pendingPreventScrollReset, + restoreScrollPosition: getSavedScrollPosition( + location, + newState.matches || state.matches + ), + preventScrollReset, + blockers: new Map(state.blockers), }); if (isUninterruptedRevalidation) { @@ -819,16 +963,17 @@ export function createRouter(init: RouterInit): Router { let { path, submission, error } = normalizeNavigateOptions(to, opts); - let location = createLocation(state.location, path, opts && opts.state); + let currentLocation = state.location; + let nextLocation = createLocation(state.location, path, opts && opts.state); // When using navigate as a PUSH/REPLACE we aren't reading an already-encoded // URL from window.location, so we need to encode it here so the behavior // remains the same as POP and non-data-router usages. new URL() does all // the same encoding we'd get from a history.pushState/window.location read // without having to touch history - location = { - ...location, - ...init.history.encodeLocation(location), + nextLocation = { + ...nextLocation, + ...init.history.encodeLocation(nextLocation), }; let userReplace = opts && opts.replace != null ? opts.replace : undefined; @@ -856,7 +1001,35 @@ export function createRouter(init: RouterInit): Router { ? opts.preventScrollReset === true : undefined; - return await startNavigation(historyAction, location, { + let blockerKey = shouldBlockNavigation({ + currentLocation, + nextLocation, + historyAction, + }); + if (blockerKey) { + // Put the blocker into a blocked state + updateBlocker(blockerKey, { + state: "blocked", + location: nextLocation, + proceed() { + updateBlocker(blockerKey!, { + state: "proceeding", + proceed: undefined, + reset: undefined, + location: nextLocation, + }); + // Send the same navigation through + navigate(to, opts); + }, + reset() { + deleteBlocker(blockerKey!); + updateState({ blockers: new Map(state.blockers) }); + }, + }); + return; + } + + return await startNavigation(historyAction, nextLocation, { submission, // Send through the formData serialization error if we have one so we can // render at the right error boundary after we match routes @@ -957,6 +1130,7 @@ export function createRouter(init: RouterInit): Router { // Create a controller/Request for this navigation pendingNavigationController = new AbortController(); let request = createClientSideRequest( + init.history, location, pendingNavigationController.signal, opts && opts.submission @@ -1115,7 +1289,7 @@ export function createRouter(init: RouterInit): Router { } if (isDeferredResult(result)) { - throw new Error("defer() is not supported in actions"); + throw getInternalRouterError(400, { type: "defer-action" }); } return { @@ -1167,6 +1341,7 @@ export function createRouter(init: RouterInit): Router { : undefined; let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad( + init.history, state, matches, activeSubmission, @@ -1380,6 +1555,7 @@ export function createRouter(init: RouterInit): Router { // Call the action for the fetcher let abortController = new AbortController(); let fetchRequest = createClientSideRequest( + init.history, path, abortController.signal, submission @@ -1427,13 +1603,15 @@ export function createRouter(init: RouterInit): Router { } if (isDeferredResult(actionResult)) { - invariant(false, "defer() is not supported in actions"); + throw getInternalRouterError(400, { type: "defer-action" }); } // Start the data load for current matches, or the next location if we're // in the middle of a navigation let nextLocation = state.navigation.location || state.location; let revalidationRequest = createClientSideRequest( + init.history, + nextLocation, abortController.signal ); @@ -1456,6 +1634,7 @@ export function createRouter(init: RouterInit): Router { state.fetchers.set(key, loadFetcher); let [matchesToLoad, revalidatingFetchers] = getMatchesToLoad( + init.history, state, matches, submission, @@ -1599,7 +1778,11 @@ export function createRouter(init: RouterInit): Router { // Call the loader for this fetcher route match let abortController = new AbortController(); - let fetchRequest = createClientSideRequest(path, abortController.signal); + let fetchRequest = createClientSideRequest( + init.history, + path, + abortController.signal + ); fetchControllers.set(key, abortController); let result: DataResult = await callLoaderOrAction( "loader", @@ -1609,7 +1792,7 @@ export function createRouter(init: RouterInit): Router { router.basename ); - // Deferred isn't supported or fetcher loads, await everything and treat it + // Deferred isn't supported for fetcher loads, await everything and treat it // as a normal load. resolveDeferredData will return undefined if this // fetcher gets aborted, so we just leave result untouched and short circuit // below if that happens @@ -1718,8 +1901,8 @@ export function createRouter(init: RouterInit): Router { ); // Check if this an external redirect that goes to a new origin - if (typeof window?.location !== "undefined") { - let newOrigin = createClientSideURL(redirect.location).origin; + if (isBrowser && typeof window?.location !== "undefined") { + let newOrigin = init.history.createURL(redirect.location).origin; if (window.location.origin !== newOrigin) { if (replace) { window.location.replace(redirect.location); @@ -1762,6 +1945,8 @@ export function createRouter(init: RouterInit): Router { ...submission, formAction: redirect.location, }, + // Preserve this flag across redirects + preventScrollReset: pendingPreventScrollReset, }); } else { // Otherwise, we kick off a new loading navigation, preserving the @@ -1775,6 +1960,8 @@ export function createRouter(init: RouterInit): Router { formEncType: submission ? submission.formEncType : undefined, formData: submission ? submission.formData : undefined, }, + // Preserve this flag across redirects + preventScrollReset: pendingPreventScrollReset, }); } } @@ -1796,7 +1983,7 @@ export function createRouter(init: RouterInit): Router { ...fetchersToLoad.map(([, href, match, fetchMatches]) => callLoaderOrAction( "loader", - createClientSideRequest(href, request.signal), + createClientSideRequest(init.history, href, request.signal), match, fetchMatches, router.basename @@ -1916,6 +2103,84 @@ export function createRouter(init: RouterInit): Router { return yeetedKeys.length > 0; } + function getBlocker(key: string, fn: BlockerFunction) { + let blocker: Blocker = state.blockers.get(key) || IDLE_BLOCKER; + + if (blockerFunctions.get(key) !== fn) { + blockerFunctions.set(key, fn); + if (activeBlocker == null) { + // This is now the active blocker + activeBlocker = key; + } else if (key !== activeBlocker) { + warning(false, "A router only supports one blocker at a time"); + } + } + + return blocker; + } + + function deleteBlocker(key: string) { + state.blockers.delete(key); + blockerFunctions.delete(key); + if (activeBlocker === key) { + activeBlocker = null; + } + } + + // Utility function to update blockers, ensuring valid state transitions + function updateBlocker(key: string, newBlocker: Blocker) { + let blocker = state.blockers.get(key) || IDLE_BLOCKER; + + // Poor mans state machine :) + // https://mermaid.live/edit#pako:eNqVkc9OwzAMxl8l8nnjAYrEtDIOHEBIgwvKJTReGy3_lDpIqO27k6awMG0XcrLlnz87nwdonESogKXXBuE79rq75XZO3-yHds0RJVuv70YrPlUrCEe2HfrORS3rubqZfuhtpg5C9wk5tZ4VKcRUq88q9Z8RS0-48cE1iHJkL0ugbHuFLus9L6spZy8nX9MP2CNdomVaposqu3fGayT8T8-jJQwhepo_UtpgBQaDEUom04dZhAN1aJBDlUKJBxE1ceB2Smj0Mln-IBW5AFU2dwUiktt_2Qaq2dBfaKdEup85UV7Yd-dKjlnkabl2Pvr0DTkTreM + invariant( + (blocker.state === "unblocked" && newBlocker.state === "blocked") || + (blocker.state === "blocked" && newBlocker.state === "blocked") || + (blocker.state === "blocked" && newBlocker.state === "proceeding") || + (blocker.state === "blocked" && newBlocker.state === "unblocked") || + (blocker.state === "proceeding" && newBlocker.state === "unblocked"), + `Invalid blocker state transition: ${blocker.state} -> ${newBlocker.state}` + ); + + state.blockers.set(key, newBlocker); + updateState({ blockers: new Map(state.blockers) }); + } + + function shouldBlockNavigation({ + currentLocation, + nextLocation, + historyAction, + }: { + currentLocation: Location; + nextLocation: Location; + historyAction: HistoryAction; + }): string | undefined { + if (activeBlocker == null) { + return; + } + + // We only allow a single blocker at the moment. This will need to be + // updated if we enhance to support multiple blockers in the future + let blockerFunction = blockerFunctions.get(activeBlocker); + invariant( + blockerFunction, + "Could not find a function for the active blocker" + ); + let blocker = state.blockers.get(activeBlocker); + + if (blocker && blocker.state === "proceeding") { + // If the blocker is currently proceeding, we don't need to re-check + // it and can let this navigation continue + return; + } + + // At this point, we know we're unblocked/blocked so we need to check the + // user-provided blocker function + if (blockerFunction({ currentLocation, nextLocation, historyAction })) { + return activeBlocker; + } + } + function cancelActiveDeferreds( predicate?: (routeId: string) => boolean ): string[] { @@ -2015,6 +2280,8 @@ export function createRouter(init: RouterInit): Router { getFetcher, deleteFetcher, dispose, + getBlocker, + deleteBlocker, _internalFetchControllers: fetchControllers, _internalActiveDeferreds: activeDeferreds, }; @@ -2027,6 +2294,8 @@ export function createRouter(init: RouterInit): Router { //#region createStaticHandler //////////////////////////////////////////////////////////////////////////////// +export const UNSAFE_DEFERRED_SYMBOL = Symbol("deferred"); + export function createStaticHandler( routes: AgnosticRouteObject[], opts?: { @@ -2086,6 +2355,7 @@ export function createStaticHandler( statusCode: error.status, loaderHeaders: {}, actionHeaders: {}, + activeDeferreds: null, }; } else if (!matches) { let error = getInternalRouterError(404, { pathname: location.pathname }); @@ -2103,6 +2373,7 @@ export function createStaticHandler( statusCode: error.status, loaderHeaders: {}, actionHeaders: {}, + activeDeferreds: null, }; } @@ -2150,7 +2421,7 @@ export function createStaticHandler( let matches = matchRoutes(dataRoutes, location, basename); // SSR supports HEAD requests while SPA doesn't - if (!isValidMethod(method) && method !== "head") { + if (!isValidMethod(method) && method !== "head" && method !== "options") { throw getInternalRouterError(405, { method }); } else if (!matches) { throw getInternalRouterError(404, { pathname: location.pathname }); @@ -2191,8 +2462,19 @@ export function createStaticHandler( } // Pick off the right state value to return - let routeData = [result.actionData, result.loaderData].find((v) => v); - return Object.values(routeData || {})[0]; + if (result.actionData) { + return Object.values(result.actionData)[0]; + } + + if (result.loaderData) { + let data = Object.values(result.loaderData)[0]; + if (result.activeDeferreds?.[match.route.id]) { + data[UNSAFE_DEFERRED_SYMBOL] = result.activeDeferreds[match.route.id]; + } + return data; + } + + return undefined; } async function queryImpl( @@ -2305,7 +2587,14 @@ export function createStaticHandler( } if (isDeferredResult(result)) { - throw new Error("defer() is not supported in actions"); + let error = getInternalRouterError(400, { type: "defer-action" }); + if (isRouteRequest) { + throw error; + } + result = { + type: ResultType.error, + error, + }; } if (isRouteRequest) { @@ -2325,6 +2614,7 @@ export function createStaticHandler( statusCode: 200, loaderHeaders: {}, actionHeaders: {}, + activeDeferreds: null, }; } @@ -2420,6 +2710,7 @@ export function createStaticHandler( errors: pendingActionError || null, statusCode: 200, loaderHeaders: {}, + activeDeferreds: null, }; } @@ -2443,25 +2734,20 @@ export function createStaticHandler( throw new Error(`${method}() call aborted`); } - let executedLoaders = new Set(); - results.forEach((result, i) => { - executedLoaders.add(matchesToLoad[i].route.id); - // Can't do anything with these without the Remix side of things, so just - // cancel them for now - if (isDeferredResult(result)) { - result.deferredData.cancel(); - } - }); - // Process and commit output from loaders + let activeDeferreds = new Map(); let context = processRouteLoaderData( matches, matchesToLoad, results, - pendingActionError + pendingActionError, + activeDeferreds ); // Add a null for any non-loader matches for proper revalidation on the client + let executedLoaders = new Set( + matchesToLoad.map((match) => match.route.id) + ); matches.forEach((match) => { if (!executedLoaders.has(match.route.id)) { context.loaderData[match.route.id] = null; @@ -2471,6 +2757,10 @@ export function createStaticHandler( return { ...context, matches, + activeDeferreds: + activeDeferreds.size > 0 + ? Object.fromEntries(activeDeferreds.entries()) + : null, }; } @@ -2595,6 +2885,7 @@ function getLoaderMatchesUntilBoundary( } function getMatchesToLoad( + history: History, state: RouterState, matches: AgnosticDataRouteMatch[], submission: Submission | undefined, @@ -2622,6 +2913,7 @@ function getMatchesToLoad( // If this route had a pending deferred cancelled it must be revalidated cancelledDeferredRoutes.some((id) => id === match.route.id) || shouldRevalidateLoader( + history, state.location, state.matches[index], submission, @@ -2641,6 +2933,7 @@ function getMatchesToLoad( revalidatingFetchers.push([key, href, match, fetchMatches]); } else if (isRevalidationRequired) { let shouldRevalidate = shouldRevalidateLoader( + history, href, match, submission, @@ -2694,6 +2987,7 @@ function isNewRouteInstance( } function shouldRevalidateLoader( + history: History, currentLocation: string | Location, currentMatch: AgnosticDataRouteMatch, submission: Submission | undefined, @@ -2702,9 +2996,9 @@ function shouldRevalidateLoader( isRevalidationRequired: boolean, actionResult: DataResult | undefined ) { - let currentUrl = createClientSideURL(currentLocation); + let currentUrl = history.createURL(currentLocation); let currentParams = currentMatch.params; - let nextUrl = createClientSideURL(location); + let nextUrl = history.createURL(location); let nextParams = match.params; // This is the default implementation as to when we revalidate. If the route @@ -2795,8 +3089,7 @@ async function callLoaderOrAction( "Redirects returned/thrown from loaders/actions must have a Location header" ); - let isAbsolute = - /^[a-z+]+:\/\//i.test(location) || location.startsWith("//"); + let isAbsolute = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(location); // Support relative routing in internal redirects if (!isAbsolute) { @@ -2822,6 +3115,17 @@ async function callLoaderOrAction( } location = createPath(resolvedLocation); + } else if (!isStaticRequest) { + // Strip off the protocol+origin for same-origin absolute redirects. + // If this is a static reques, we can let it go back to the browser + // as-is + let currentUrl = new URL(request.url); + let url = location.startsWith("//") + ? new URL(currentUrl.protocol + location) + : new URL(location); + if (url.origin === currentUrl.origin) { + location = url.pathname + url.search + url.hash; + } } // Don't process redirects in the router during static requests requests. @@ -2893,11 +3197,12 @@ async function callLoaderOrAction( // client-side navigations and fetches. During SSR we will always have a // Request instance from the static handler (query/queryRoute) function createClientSideRequest( + history: History, location: string | Location, signal: AbortSignal, submission?: Submission ): Request { - let url = createClientSideURL(stripHashFromPath(location)).toString(); + let url = history.createURL(stripHashFromPath(location)).toString(); let init: RequestInit = { signal }; if (submission && isMutationMethod(submission.formMethod)) { @@ -2933,7 +3238,7 @@ function processRouteLoaderData( matchesToLoad: AgnosticDataRouteMatch[], results: DataResult[], pendingError: RouteData | undefined, - activeDeferreds?: Map + activeDeferreds: Map ): { loaderData: RouterState["loaderData"]; errors: RouterState["errors"] | null; @@ -2988,12 +3293,14 @@ function processRouteLoaderData( if (result.headers) { loaderHeaders[id] = result.headers; } - } else if (isDeferredResult(result)) { - activeDeferreds && activeDeferreds.set(id, result.deferredData); - loaderData[id] = result.deferredData.data; - // TODO: Add statusCode/headers once we wire up streaming in Remix } else { - loaderData[id] = result.data; + if (isDeferredResult(result)) { + activeDeferreds.set(id, result.deferredData); + loaderData[id] = result.deferredData.data; + } else { + loaderData[id] = result.data; + } + // Error status codes always override success status codes, but if all // loaders are successful we take the deepest status code. if ( @@ -3068,11 +3375,11 @@ function processLoaderData( } else if (isRedirectResult(result)) { // Should never get here, redirects should get processed above, but we // keep this to type narrow to a success result in the else - throw new Error("Unhandled fetcher revalidation redirect"); + invariant(false, "Unhandled fetcher revalidation redirect"); } else if (isDeferredResult(result)) { // Should never get here, deferred data should be awaited for fetchers // in resolveDeferredResults - throw new Error("Unhandled fetcher deferred data"); + invariant(false, "Unhandled fetcher deferred data"); } else { let doneFetcher: FetcherStates["Idle"] = { state: "idle", @@ -3163,10 +3470,12 @@ function getInternalRouterError( pathname, routeId, method, + type, }: { pathname?: string; routeId?: string; method?: string; + type?: "defer-action"; } = {} ) { let statusText = "Unknown Server Error"; @@ -3179,6 +3488,8 @@ function getInternalRouterError( `You made a ${method} request to "${pathname}" but ` + `did not provide a \`loader\` for route "${routeId}", ` + `so there is no way to handle the request.`; + } else if (type === "defer-action") { + errorMessage = "defer() is not supported in actions"; } else { errorMessage = "Cannot submit binary form data using GET"; } diff --git a/packages/router/utils.ts b/packages/router/utils.ts index 84c8b2e900..1a01048c3a 100644 --- a/packages/router/utils.ts +++ b/packages/router/utils.ts @@ -31,6 +31,8 @@ export interface SuccessResult { export interface DeferredResult { type: ResultType.deferred; deferredData: DeferredData; + statusCode?: number; + headers?: Headers; } /** @@ -198,7 +200,9 @@ type _PathParam = ? _PathParam | _PathParam : // find params after `:` Path extends `:${infer Param}` - ? Param + ? Param extends `${infer Optional}?` + ? Optional + : Param : // otherwise, there aren't any params present never; @@ -614,7 +618,7 @@ function matchRouteBranch< export function generatePath( originalPath: Path, params: { - [key in PathParam]: string; + [key in PathParam]: string | null; } = {} as any ): string { let path = originalPath; @@ -629,27 +633,49 @@ export function generatePath( path = path.replace(/\*$/, "/*") as Path; } - return path - .replace(/^:(\w+)/g, (_, key: PathParam) => { - invariant(params[key] != null, `Missing ":${key}" param`); - return params[key]!; - }) - .replace(/\/:(\w+)/g, (_, key: PathParam) => { - invariant(params[key] != null, `Missing ":${key}" param`); - return `/${params[key]!}`; - }) - .replace(/(\/?)\*/, (_, prefix, __, str) => { - const star = "*" as PathParam; - - if (params[star] == null) { - // If no splat was provided, trim the trailing slash _unless_ it's - // the entire path - return str === "/*" ? "/" : ""; - } - - // Apply the splat - return `${prefix}${params[star]}`; - }); + return ( + path + .replace( + /^:(\w+)(\??)/g, + (_, key: PathParam, optional: string | undefined) => { + let param = params[key]; + if (optional === "?") { + return param == null ? "" : param; + } + if (param == null) { + invariant(false, `Missing ":${key}" param`); + } + return param; + } + ) + .replace( + /\/:(\w+)(\??)/g, + (_, key: PathParam, optional: string | undefined) => { + let param = params[key]; + if (optional === "?") { + return param == null ? "" : `/${param}`; + } + if (param == null) { + invariant(false, `Missing ":${key}" param`); + } + return `/${param}`; + } + ) + // Remove any optional markers from optional static segments + .replace(/\?/g, "") + .replace(/(\/?)\*/, (_, prefix, __, str) => { + const star = "*" as PathParam; + + if (params[star] == null) { + // If no splat was provided, trim the trailing slash _unless_ it's + // the entire path + return str === "/*" ? "/" : ""; + } + + // Apply the splat + return `${prefix}${params[star]}`; + }) + ); } /** @@ -874,7 +900,7 @@ export function warning(cond: any, message: string): void { if (typeof console !== "undefined") console.warn(message); try { - // Welcome to debugging React Router! + // Welcome to debugging @remix-run/router! // // This error is thrown as a convenience so you can more easily // find the source for a warning that appears in the console by @@ -1131,14 +1157,17 @@ export interface TrackedPromise extends Promise { export class AbortedDeferredError extends Error {} export class DeferredData { - private pendingKeys: Set = new Set(); + private pendingKeysSet: Set = new Set(); private controller: AbortController; private abortPromise: Promise; private unlistenAbortSignal: () => void; - private subscriber?: (aborted: boolean) => void = undefined; + private subscribers: Set<(aborted: boolean, settledKey?: string) => void> = + new Set(); data: Record; + init?: ResponseInit; + deferredKeys: string[] = []; - constructor(data: Record) { + constructor(data: Record, responseInit?: ResponseInit) { invariant( data && typeof data === "object" && !Array.isArray(data), "defer() only accepts plain objects" @@ -1162,17 +1191,20 @@ export class DeferredData { }), {} ); + + this.init = responseInit; } private trackPromise( - key: string | number, + key: string, value: Promise | unknown ): TrackedPromise | unknown { if (!(value instanceof Promise)) { return value; } - this.pendingKeys.add(key); + this.deferredKeys.push(key); + this.pendingKeysSet.add(key); // We store a little wrapper promise that will be extended with // _data/_error props upon resolve/reject @@ -1191,7 +1223,7 @@ export class DeferredData { private onSettle( promise: TrackedPromise, - key: string | number, + key: string, error: unknown, data?: unknown ): unknown { @@ -1204,34 +1236,37 @@ export class DeferredData { return Promise.reject(error); } - this.pendingKeys.delete(key); + this.pendingKeysSet.delete(key); if (this.done) { // Nothing left to abort! this.unlistenAbortSignal(); } - const subscriber = this.subscriber; if (error) { Object.defineProperty(promise, "_error", { get: () => error }); - subscriber && subscriber(false); + this.emit(false, key); return Promise.reject(error); } Object.defineProperty(promise, "_data", { get: () => data }); - subscriber && subscriber(false); + this.emit(false, key); return data; } - subscribe(fn: (aborted: boolean) => void) { - this.subscriber = fn; + private emit(aborted: boolean, settledKey?: string) { + this.subscribers.forEach((subscriber) => subscriber(aborted, settledKey)); + } + + subscribe(fn: (aborted: boolean, settledKey?: string) => void) { + this.subscribers.add(fn); + return () => this.subscribers.delete(fn); } cancel() { this.controller.abort(); - this.pendingKeys.forEach((v, k) => this.pendingKeys.delete(k)); - let subscriber = this.subscriber; - subscriber && subscriber(true); + this.pendingKeysSet.forEach((v, k) => this.pendingKeysSet.delete(k)); + this.emit(true); } async resolveData(signal: AbortSignal) { @@ -1252,7 +1287,7 @@ export class DeferredData { } get done() { - return this.pendingKeys.size === 0; + return this.pendingKeysSet.size === 0; } get unwrappedData() { @@ -1269,6 +1304,10 @@ export class DeferredData { {} ); } + + get pendingKeys() { + return Array.from(this.pendingKeysSet); + } } function isTrackedPromise(value: any): value is TrackedPromise { @@ -1288,9 +1327,16 @@ function unwrapTrackedPromise(value: any) { return value._data; } -export function defer(data: Record) { - return new DeferredData(data); -} +export type DeferFunction = ( + data: Record, + init?: number | ResponseInit +) => DeferredData; + +export const defer: DeferFunction = (data, init = {}) => { + let responseInit = typeof init === "number" ? { status: init } : init; + + return new DeferredData(data, responseInit); +}; export type RedirectFunction = ( url: string, diff --git a/rollup.config.js b/rollup.config.js index d4f780327f..ba258c604f 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,9 +2,15 @@ const fs = require("fs"); const path = require("path"); module.exports = function rollup(options) { - return fs - .readdirSync("packages") + return [ + "router", + "react-router", + "react-router-dom", + "react-router-dom-v5-compat", + "react-router-native", + ] .flatMap((dir) => { + // if (dir !== "router") return null; let configPath = path.join("packages", dir, "rollup.config.js"); try { fs.readFileSync(configPath);