├── .autorc ├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── something-else.md └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── DOCUMENTATION_V1.md ├── LICENSE ├── README.md ├── UPGRADE_V1_V2.md ├── package.json ├── packages └── storybook-addon-remix-react-router │ ├── manager.js │ ├── package.json │ ├── src │ ├── constants.ts │ ├── features │ │ ├── decorator │ │ │ ├── components │ │ │ │ ├── ReactRouterDecorator.tsx │ │ │ │ ├── RouterLogger.tsx │ │ │ │ └── StoryRouter.tsx │ │ │ ├── contexts │ │ │ │ ├── DeepRouteMatchesContext.tsx │ │ │ │ └── StoryContext.tsx │ │ │ ├── hooks │ │ │ │ ├── useActionDecorator.ts │ │ │ │ ├── useCurrentUrl.ts │ │ │ │ ├── useDataEventBuilder.ts │ │ │ │ ├── useDeepRouteMatches.tsx │ │ │ │ ├── useLoaderDecorator.ts │ │ │ │ ├── useNavigationEventBuilder.ts │ │ │ │ ├── useRouteContextMatches.ts │ │ │ │ ├── useRouteObjectsDecorator.ts │ │ │ │ └── useStory.tsx │ │ │ ├── types.ts │ │ │ ├── utils │ │ │ │ ├── castParametersV2.ts │ │ │ │ ├── castRouterRoute.tsx │ │ │ │ ├── getFormDataSummary.spec.ts │ │ │ │ ├── getFormDataSummary.ts │ │ │ │ ├── getHumanReadableBody.ts │ │ │ │ ├── injectStory.ts │ │ │ │ ├── isValidReactNode.ts │ │ │ │ ├── normalizeHistory.spec.ts │ │ │ │ ├── normalizeHistory.ts │ │ │ │ ├── normalizeRouting.tsx │ │ │ │ ├── optionalStoryArg.ts │ │ │ │ ├── routesHelpers │ │ │ │ │ ├── reactRouterNestedAncestors.ts │ │ │ │ │ ├── reactRouterNestedOutlets.ts │ │ │ │ │ ├── reactRouterOutlet.ts │ │ │ │ │ ├── reactRouterOutlets.ts │ │ │ │ │ ├── reactRouterParameters.spec.tsx │ │ │ │ │ └── reactRouterParameters.tsx │ │ │ │ ├── searchParamsToRecord.spec.ts │ │ │ │ └── searchParamsToRecord.ts │ │ │ └── withRouter.tsx │ │ └── panel │ │ │ ├── components │ │ │ ├── InformationBanner.tsx │ │ │ ├── InspectorContainer.tsx │ │ │ ├── Panel.tsx │ │ │ ├── PanelContent.tsx │ │ │ ├── PanelTitle.tsx │ │ │ ├── RouterEventDisplayWrapper.tsx │ │ │ └── ThemedInspector.tsx │ │ │ ├── hooks │ │ │ └── useAddonVersions.ts │ │ │ └── types.ts │ ├── fixes.d.ts │ ├── index.ts │ ├── internals.ts │ ├── manager.tsx │ ├── setupTests.ts │ ├── types │ │ └── type-utils.spec.ts │ └── utils │ │ ├── misc.spec.ts │ │ ├── misc.ts │ │ ├── test-utils.ts │ │ └── type-utils.ts │ ├── tsconfig.json │ ├── tsup.config.ts │ ├── vite.config.ts │ └── vitest.config.js ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tests └── reactRouterV7 │ ├── .storybook │ ├── main.js │ └── preview.js │ ├── package.json │ ├── public │ └── mockServiceWorker.js │ ├── setupTests.ts │ ├── stories │ ├── AdvancedRouting.stories.tsx │ ├── Basics.stories.tsx │ ├── DataRouter │ │ ├── Action.stories.tsx │ │ ├── Complex.stories.tsx │ │ ├── Lazy.stories.tsx │ │ ├── Loader.stories.tsx │ │ └── lazy.ts │ ├── DescendantRoutes.stories.tsx │ ├── RemixRunReact │ │ └── Imports.stories.tsx │ └── v2Stories.spec.tsx │ ├── suites │ ├── RouterLogger.spec.tsx │ ├── injectStory.spec.tsx │ └── isValidReactNode.spec.tsx │ ├── test-utils.ts │ ├── tsconfig.json │ ├── utils.ts │ ├── vite.config.ts │ └── vitest.config.ts ├── vitest.config.ts └── vitest.workspace.ts /.autorc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["npm", "conventional-commits"] 3 | } -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | 'plugin:react-hooks/recommended', 12 | ], 13 | overrides: [ 14 | { 15 | env: { 16 | node: true, 17 | }, 18 | files: ['.eslintrc.{js,cjs}'], 19 | parserOptions: { 20 | sourceType: 'script', 21 | }, 22 | }, 23 | ], 24 | parser: '@typescript-eslint/parser', 25 | parserOptions: { 26 | ecmaVersion: 'latest', 27 | sourceType: 'module', 28 | }, 29 | plugins: ['react-refresh'], 30 | rules: { 31 | 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 32 | '@typescript-eslint/no-unused-vars': [ 33 | 'warn', 34 | { 35 | vars: 'local', 36 | args: 'none', 37 | caughtErrors: 'none', 38 | ignoreRestSiblings: false, 39 | }, 40 | ], 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐞 Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | What is supposed to happen ? What is happening instead ? 11 | 12 | **To Reproduce** 13 | Describe all the steps required to reproduce the issue. 14 | 15 | To help us resolve your issue more quickly, please fork the [https://stackblitz.com/edit/storybook-addon-remix-react-router](stackblitz project) to reproduce your issue. 16 | 17 | **Additional context** 18 | Add any other context about the problem here. 19 | 20 | **Environment** 21 | Share the output of the following command : 22 | 23 | ```bash 24 | npx sb info 25 | ``` 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/something-else.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: '⭐️Something else' 3 | about: Anything else you want to share with us ? 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | <-- Are you sure this should not be in Discussions ? --> 10 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: pnpm/action-setup@v4 12 | with: 13 | version: 9.15.0 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version-file: '.nvmrc' 17 | cache: 'pnpm' 18 | cache-dependency-path: pnpm-lock.yaml 19 | 20 | - name: Install dependencies 21 | run: pnpm i 22 | 23 | - name: Build packages 24 | run: pnpm --filter "storybook-addon-remix-react-router" run build 25 | 26 | - name: Vitest cache 27 | uses: actions/cache@v4 28 | with: 29 | path: ./node_modules/.vite/vitest/results.json 30 | key: vitest-cache-${{ github.ref_name }} 31 | restore-keys: | 32 | vitest-cache-${{ github.ref_name }} 33 | 34 | - name: Run tests 35 | run: pnpm vitest --run 36 | 37 | - name: Create Release 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | run: pnpm --filter "storybook-addon-remix-react-router" run release 42 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci') && !startsWith(github.event.head_commit.message, 'docs')" 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: pnpm/action-setup@v4 12 | with: 13 | version: 9.15.0 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version-file: '.nvmrc' 17 | cache: 'pnpm' 18 | cache-dependency-path: pnpm-lock.yaml 19 | 20 | - name: Install dependencies 21 | run: pnpm i 22 | 23 | - name: Build packages 24 | run: pnpm --filter "storybook-addon-remix-react-router" run build 25 | 26 | - name: Vitest cache 27 | uses: actions/cache@v4 28 | with: 29 | path: ./node_modules/.vite/vitest/results.json 30 | key: vitest-cache-${{ github.ref_name }} 31 | restore-keys: | 32 | vitest-cache-${{ github.ref_name }} 33 | 34 | - name: Run tests 35 | run: pnpm vitest --run -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | package/ 3 | node_modules/ 4 | storybook-static/ 5 | coverage/ 6 | build-storybook.log 7 | .DS_Store 8 | .env 9 | .idea 10 | 11 | *storybook.log 12 | tests-report 13 | 14 | packages/storybook-addon-remix-react-router/README.md -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "embeddedLanguageFormatting": "auto", 6 | "htmlWhitespaceSensitivity": "css", 7 | "insertPragma": false, 8 | "jsxSingleQuote": false, 9 | "printWidth": 120, 10 | "proseWrap": "preserve", 11 | "quoteProps": "consistent", 12 | "requirePragma": false, 13 | "semi": true, 14 | "singleAttributePerLine": false, 15 | "singleQuote": true, 16 | "tabWidth": 2, 17 | "trailingComma": "es5", 18 | "useTabs": false, 19 | "vueIndentScriptAndStyle": false 20 | } 21 | -------------------------------------------------------------------------------- /DOCUMENTATION_V1.md: -------------------------------------------------------------------------------- 1 | ## V1 Documentation - Legacy 2 | 3 | Only supports for Storybook 7 4 | 5 | ## Getting Started 6 | 7 | Install the package 8 | 9 | ``` 10 | yarn add -D storybook-addon-remix-react-router 11 | ``` 12 | 13 | Add it to your storybook configuration: 14 | 15 | ```js 16 | // .storybook/main.ts 17 | module.exports = { 18 | addons: ['storybook-addon-remix-react-router'], 19 | }; 20 | ``` 21 | 22 | ## How to use it as a component decorator 23 | 24 | To add the router to all the stories of a component, simply add it to the `decorators` array. 25 | 26 | Note that the `parameters.reactRouter` property is optional, by default the router will render the component at `/`. 27 | 28 | ```tsx 29 | import { withRouter } from 'storybook-addon-remix-react-router'; 30 | 31 | export default { 32 | title: 'User Profile', 33 | component: UserProfile, 34 | decorators: [withRouter], 35 | parameters: { 36 | reactRouter: { 37 | routePath: '/users/:userId', 38 | routeParams: { userId: '42' }, 39 | }, 40 | }, 41 | }; 42 | 43 | export const Example = () => ; 44 | ``` 45 | 46 | ## Usage at the story level 47 | 48 | If you want to change the router config just for one story you can do the following : 49 | 50 | ```tsx 51 | import { withRouter } from 'storybook-addon-remix-react-router'; 52 | 53 | export default { 54 | title: 'User Profile', 55 | component: UserProfile, 56 | decorators: [withRouter], 57 | }; 58 | 59 | export const Example = () => ; 60 | Example.story = { 61 | parameters: { 62 | reactRouter: { 63 | routePath: '/users/:userId', 64 | routeParams: { userId: '42' }, 65 | routeHandle: 'Profile', 66 | searchParams: { tab: 'activityLog' }, 67 | routeState: { fromPage: 'homePage' }, 68 | }, 69 | }, 70 | }; 71 | ``` 72 | 73 | ## Define a global default 74 | 75 | If you want you can wrap all your stories inside a router by adding the decorator in your `preview.js` file. 76 | 77 | ```ts 78 | // preview.js 79 | 80 | export const decorators = [withRouter]; 81 | 82 | // you can also define global defaults parameters 83 | export const parameters = { 84 | reactRouter: { 85 | // ... 86 | }, 87 | }; 88 | ``` 89 | 90 | ## Data Router 91 | 92 | If you use the data routers of `react-router 6.4+`, such as ``, you can use the following properties : 93 | 94 | ```js 95 | export const Example = () => ; 96 | Example.story = { 97 | parameters: { 98 | reactRouter: { 99 | routePath: '/articles', 100 | loader: fetchArticlesFunction, 101 | action: articlesActionFunction, 102 | errorElement: , 103 | }, 104 | }, 105 | }; 106 | ``` 107 | 108 | ## Outlet 109 | 110 | If your component renders an outlet, you can set the `outlet` property : 111 | 112 | ```js 113 | export const Example = () => ; 114 | Example.story = { 115 | parameters: { 116 | reactRouter: { 117 | routePath: '/articles', 118 | outlet: { 119 | element:
, 120 | handle: 'Article', 121 | path: ':articleId', 122 | loader: yourLoaderFunction, 123 | action: yourActionFunction, 124 | errorElement: , 125 | }, 126 | // Or simply 127 | outlet: , 128 | }, 129 | }, 130 | }; 131 | ``` 132 | 133 | ## Descendant Routes 134 | 135 | `` can be nested to handle layouts & outlets. 136 | But components can also render a `` component with its set of ``, leading to a deep nesting called `Descendant Routes`. 137 | In this case, in order for the whole component tree to render in your story with matching params, you will need to set the `browserPath` property : 138 | 139 | ```js 140 | export default { 141 | title: 'Descendant Routes', 142 | component: SettingsPage, // this component renders a with several with path like `billing` or `privacy` 143 | decorators: [withRouter], 144 | }; 145 | 146 | Default.story = { 147 | parameters: { 148 | reactRouter: { 149 | browserPath: '/billing', 150 | }, 151 | }, 152 | }; 153 | 154 | // If you want to render at a specific path, like `/settings`, React Router requires that you add a trailing wildcard 155 | SpecificPath.story = { 156 | parameters: { 157 | reactRouter: { 158 | routePath: '/settings/*', 159 | browserPath: '/settings/billing', 160 | }, 161 | }, 162 | }; 163 | ``` 164 | 165 | ## Dedicated panel 166 | 167 | Navigation events, loader and actions are logged, for you to better understand the lifecycle of your components. 168 | 169 | ![Addon Panel](https://user-images.githubusercontent.com/94478/224843029-b37ff60d-10f8-4198-bbc3-f26e2775437f.png) 170 | 171 | ## Available Parameters 172 | 173 | Every parameter is optional. In most cases they follow the same type used by Route Router itself, sometimes they offer a sugar syntax. 174 | 175 | | Parameter | Type | Description | 176 | | ---------------- | ------------------------------------------------------------------- | ------------------------------------------------------------- | 177 | | routePath | `string` | i.e: `/users/:userId` | 178 | | routeParams | `Record` | i.e: `{ userId: "777" }` | 179 | | routeState | `any` | Available through `useLocation()` | 180 | | routeHandle | `any` | Available through `useMatches()` | 181 | | searchParams | `string[][] \| Record \| string \| URLSearchParams` | Location query string `useSearchParams()` | 182 | | outlet | `React.ReactNode \| OutletProps` | Outlet rendered by the route. See type `OutletProps` below. | 183 | | browserPath | `string` | Useful when you have [descendant routes](#descendant-routes). | 184 | | loader | `LoaderFunction` | | 185 | | action | `ActionFunction` | | 186 | | errorElement | `React.ReactNode \| null` | | 187 | | hydrationData | `HydrationState` | | 188 | | shouldRevalidate | `ShouldRevalidateFunction` | | 189 | | routeId | `string` | Available through `useMatches()` | 190 | 191 | ```ts 192 | type OutletProps = { 193 | element: React.ReactNode; 194 | path?: string; 195 | handle?: unknown; 196 | loader?: LoaderFunction; 197 | action?: ActionFunction; 198 | errorElement?: React.ReactNode | null; 199 | }; 200 | ``` 201 | 202 | ## Compatibility 203 | 204 | ✅ Storybook 7.0 205 | 206 | ✅ React 16 207 | ✅ React 17 208 | ✅ React 18 209 | 210 | If you face an issue with any version, open an issue. 211 | 212 | ## Contribution 213 | 214 | Contributions are welcome. 215 | 216 | Before writing any code, file an issue to showcase the bug or the use case for the feature you want to see in this addon. 217 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023-Present Jonathan MASSUCHETTI . 2 | 3 | 4 | Apache License 5 | Version 2.0, January 2004 6 | http://www.apache.org/licenses/ 7 | 8 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 9 | 10 | 1. Definitions. 11 | 12 | "License" shall mean the terms and conditions for use, reproduction, 13 | and distribution as defined by Sections 1 through 9 of this document. 14 | 15 | "Licensor" shall mean the copyright owner or entity authorized by 16 | the copyright owner that is granting the License. 17 | 18 | "Legal Entity" shall mean the union of the acting entity and all 19 | other entities that control, are controlled by, or are under common 20 | control with that entity. For the purposes of this definition, 21 | "control" means (i) the power, direct or indirect, to cause the 22 | direction or management of such entity, whether by contract or 23 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 24 | outstanding shares, or (iii) beneficial ownership of such entity. 25 | 26 | "You" (or "Your") shall mean an individual or Legal Entity 27 | exercising permissions granted by this License. 28 | 29 | "Source" form shall mean the preferred form for making modifications, 30 | including but not limited to software source code, documentation 31 | source, and configuration files. 32 | 33 | "Object" form shall mean any form resulting from mechanical 34 | transformation or translation of a Source form, including but 35 | not limited to compiled object code, generated documentation, 36 | and conversions to other media types. 37 | 38 | "Work" shall mean the work of authorship, whether in Source or 39 | Object form, made available under the License, as indicated by a 40 | copyright notice that is included in or attached to the work 41 | (an example is provided in the Appendix below). 42 | 43 | "Derivative Works" shall mean any work, whether in Source or Object 44 | form, that is based on (or derived from) the Work and for which the 45 | editorial revisions, annotations, elaborations, or other modifications 46 | represent, as a whole, an original work of authorship. For the purposes 47 | of this License, Derivative Works shall not include works that remain 48 | separable from, or merely link (or bind by name) to the interfaces of, 49 | the Work and Derivative Works thereof. 50 | 51 | "Contribution" shall mean any work of authorship, including 52 | the original version of the Work and any modifications or additions 53 | to that Work or Derivative Works thereof, that is intentionally 54 | submitted to Licensor for inclusion in the Work by the copyright owner 55 | or by an individual or Legal Entity authorized to submit on behalf of 56 | the copyright owner. For the purposes of this definition, "submitted" 57 | means any form of electronic, verbal, or written communication sent 58 | to the Licensor or its representatives, including but not limited to 59 | communication on electronic mailing lists, source code control systems, 60 | and issue tracking systems that are managed by, or on behalf of, the 61 | Licensor for the purpose of discussing and improving the Work, but 62 | excluding communication that is conspicuously marked or otherwise 63 | designated in writing by the copyright owner as "Not a Contribution." 64 | 65 | "Contributor" shall mean Licensor and any individual or Legal Entity 66 | on behalf of whom a Contribution has been received by Licensor and 67 | subsequently incorporated within the Work. 68 | 69 | 2. Grant of Copyright License. Subject to the terms and conditions of 70 | this License, each Contributor hereby grants to You a perpetual, 71 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 72 | copyright license to reproduce, prepare Derivative Works of, 73 | publicly display, publicly perform, sublicense, and distribute the 74 | Work and such Derivative Works in Source or Object form. 75 | 76 | 3. Grant of Patent License. Subject to the terms and conditions of 77 | this License, each Contributor hereby grants to You a perpetual, 78 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 79 | (except as stated in this section) patent license to make, have made, 80 | use, offer to sell, sell, import, and otherwise transfer the Work, 81 | where such license applies only to those patent claims licensable 82 | by such Contributor that are necessarily infringed by their 83 | Contribution(s) alone or by combination of their Contribution(s) 84 | with the Work to which such Contribution(s) was submitted. If You 85 | institute patent litigation against any entity (including a 86 | cross-claim or counterclaim in a lawsuit) alleging that the Work 87 | or a Contribution incorporated within the Work constitutes direct 88 | or contributory patent infringement, then any patent licenses 89 | granted to You under this License for that Work shall terminate 90 | as of the date such litigation is filed. 91 | 92 | 4. Redistribution. You may reproduce and distribute copies of the 93 | Work or Derivative Works thereof in any medium, with or without 94 | modifications, and in Source or Object form, provided that You 95 | meet the following conditions: 96 | 97 | (a) You must give any other recipients of the Work or 98 | Derivative Works a copy of this License; and 99 | 100 | (b) You must cause any modified files to carry prominent notices 101 | stating that You changed the files; and 102 | 103 | (c) You must retain, in the Source form of any Derivative Works 104 | that You distribute, all copyright, patent, trademark, and 105 | attribution notices from the Source form of the Work, 106 | excluding those notices that do not pertain to any part of 107 | the Derivative Works; and 108 | 109 | (d) If the Work includes a "NOTICE" text file as part of its 110 | distribution, then any Derivative Works that You distribute must 111 | include a readable copy of the attribution notices contained 112 | within such NOTICE file, excluding those notices that do not 113 | pertain to any part of the Derivative Works, in at least one 114 | of the following places: within a NOTICE text file distributed 115 | as part of the Derivative Works; within the Source form or 116 | documentation, if provided along with the Derivative Works; or, 117 | within a display generated by the Derivative Works, if and 118 | wherever such third-party notices normally appear. The contents 119 | of the NOTICE file are for informational purposes only and 120 | do not modify the License. You may add Your own attribution 121 | notices within Derivative Works that You distribute, alongside 122 | or as an addendum to the NOTICE text from the Work, provided 123 | that such additional attribution notices cannot be construed 124 | as modifying the License. 125 | 126 | You may add Your own copyright statement to Your modifications and 127 | may provide additional or different license terms and conditions 128 | for use, reproduction, or distribution of Your modifications, or 129 | for any such Derivative Works as a whole, provided Your use, 130 | reproduction, and distribution of the Work otherwise complies with 131 | the conditions stated in this License. 132 | 133 | 5. Submission of Contributions. Unless You explicitly state otherwise, 134 | any Contribution intentionally submitted for inclusion in the Work 135 | by You to the Licensor shall be under the terms and conditions of 136 | this License, without any additional terms or conditions. 137 | Notwithstanding the above, nothing herein shall supersede or modify 138 | the terms of any separate license agreement you may have executed 139 | with Licensor regarding such Contributions. 140 | 141 | 6. Trademarks. This License does not grant permission to use the trade 142 | names, trademarks, service marks, or product names of the Licensor, 143 | except as required for reasonable and customary use in describing the 144 | origin of the Work and reproducing the content of the NOTICE file. 145 | 146 | 7. Disclaimer of Warranty. Unless required by applicable law or 147 | agreed to in writing, Licensor provides the Work (and each 148 | Contributor provides its Contributions) on an "AS IS" BASIS, 149 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 150 | implied, including, without limitation, any warranties or conditions 151 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 152 | PARTICULAR PURPOSE. You are solely responsible for determining the 153 | appropriateness of using or redistributing the Work and assume any 154 | risks associated with Your exercise of permissions under this License. 155 | 156 | 8. Limitation of Liability. In no event and under no legal theory, 157 | whether in tort (including negligence), contract, or otherwise, 158 | unless required by applicable law (such as deliberate and grossly 159 | negligent acts) or agreed to in writing, shall any Contributor be 160 | liable to You for damages, including any direct, indirect, special, 161 | incidental, or consequential damages of any character arising as a 162 | result of this License or out of the use or inability to use the 163 | Work (including but not limited to damages for loss of goodwill, 164 | work stoppage, computer failure or malfunction, or any and all 165 | other commercial damages or losses), even if such Contributor 166 | has been advised of the possibility of such damages. 167 | 168 | 9. Accepting Warranty or Additional Liability. While redistributing 169 | the Work or Derivative Works thereof, You may choose to offer, 170 | and charge a fee for, acceptance of support, warranty, indemnity, 171 | or other liability obligations and/or rights consistent with this 172 | License. However, in accepting such obligations, You may act only 173 | on Your own behalf and on Your sole responsibility, not on behalf 174 | of any other Contributor, and only if You agree to indemnify, 175 | defend, and hold each Contributor harmless for any liability 176 | incurred by, or claims asserted against, such Contributor by reason 177 | of your accepting any such warranty or additional liability. 178 | 179 | END OF TERMS AND CONDITIONS 180 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Storybook Addon Remix React Router 2 | 3 | [![Storybook](https://raw.githubusercontent.com/storybookjs/brand/master/badge/badge-storybook.svg?sanitize=true)](https://storybook.js.org) 4 | [![npm](https://img.shields.io/npm/v/storybook-addon-remix-react-router?color=blue)](https://www.npmjs.com/package/storybook-addon-remix-react-router) 5 | [![Release](https://github.com/JesusTheHun/storybook-addon-remix-react-router/actions/workflows/release.yml/badge.svg)](https://github.com/JesusTheHun/storybook-addon-remix-react-router/actions/workflows/release.yml) 6 | ![npm](https://img.shields.io/npm/dm/storybook-addon-remix-react-router) 7 | 8 | > Use Remix React Router in your stories. 9 | 10 | Starting with `react-router@7`, the package `react-router-dom` is deprecated by Remix. 11 | If you still use this package, use the `v3` of this addon. 12 | If you have dropped it in favor of `react-router`, use the `v4` or `v5` depending on your Storybook version. 13 | Check the compatibility table at the bottom. 14 | 15 | ## Recent changes 16 | 17 | ✅ Support for Storybook 9 with `storybook-addon-remix-react-router@5`. 18 | ✅ Support for React Router v7 with `storybook-addon-remix-react-router@4`. 19 | ✅ Support for Storybook 8 with `storybook-addon-remix-react-router@3`. 20 | 21 | ## Getting Started 22 | 23 | Install the package 24 | 25 | ``` 26 | npm i -D storybook-addon-remix-react-router 27 | ``` 28 | 29 | Add it to your storybook configuration: 30 | 31 | ```js 32 | // .storybook/main.ts 33 | 34 | export default { 35 | addons: ['storybook-addon-remix-react-router'], 36 | } satisfies StorybookConfig; 37 | ``` 38 | 39 | ## Decorate all stories of a component 40 | 41 | To add the router to all the stories of a component, simply add it to the `decorators` array. 42 | 43 | Note that `parameters.reactRouter` is optional, by default the router will render the component at `/`. 44 | 45 | ```tsx 46 | import { withRouter, reactRouterParameters } from 'storybook-addon-remix-react-router'; 47 | 48 | export default { 49 | title: 'User Profile', 50 | render: () => , 51 | decorators: [withRouter], 52 | parameters: { 53 | reactRouter: reactRouterParameters({ 54 | location: { 55 | pathParams: { userId: '42' }, 56 | }, 57 | routing: { path: '/users/:userId' }, 58 | }), 59 | }, 60 | }; 61 | ``` 62 | 63 | ## Decorate a specific story 64 | 65 | To change the config for a single story, you can do the following : 66 | 67 | ```tsx 68 | import { withRouter, reactRouterParameters } from 'storybook-addon-remix-react-router'; 69 | 70 | export default { 71 | title: 'User Profile', 72 | render: () => , 73 | decorators: [withRouter], 74 | }; 75 | 76 | export const FromHomePage = { 77 | parameters: { 78 | reactRouter: reactRouterParameters({ 79 | location: { 80 | pathParams: { userId: '42' }, 81 | searchParams: { tab: 'activityLog' }, 82 | state: { fromPage: 'homePage' }, 83 | }, 84 | routing: { 85 | path: '/users/:userId', 86 | handle: 'Profile', 87 | }, 88 | }), 89 | }, 90 | }; 91 | ``` 92 | 93 | ## Decorate all stories, globally 94 | 95 | To wrap all your project's stories inside a router by adding the decorator in your `preview.js` file. 96 | 97 | ```ts 98 | // .storybook/preview.js 99 | 100 | export default { 101 | decorators: [withRouter], 102 | parameters: { 103 | reactRouter: reactRouterParameters({ ... }), 104 | } 105 | } satisfies Preview; 106 | ``` 107 | 108 | ## Location 109 | 110 | To specify anything related to the browser location, use the `location` property. 111 | 112 | ```tsx 113 | type LocationParameters = { 114 | path?: string | ((inferredPath: string, pathParams: Record) => string | undefined); 115 | pathParams?: PathParams; 116 | searchParams?: ConstructorParameters[0]; 117 | hash?: string; 118 | state?: unknown; 119 | }; 120 | ``` 121 | 122 | ### Inferred path 123 | 124 | If `location.path` is not provided, the browser pathname will be generated using the joined `path`s from the `routing` property and the `pathParams`. 125 | 126 | ### Path as a function 127 | 128 | You can provide a function to `path`. 129 | It will receive the joined `path`s from the routing property and the `pathParams` as parameters. 130 | If the function returns a `string`, is will be used _as is_. It's up to you to call `generatePath` from `react-router` if you need to. 131 | If the function returns `undefined`, it will fallback to the default behavior, just like if you didn't provide any value for `location.path`. 132 | 133 | ## Routing 134 | 135 | You can set `routing` to anything accepted by `createBrowserRouter`. 136 | To make your life easier, `storybook-addon-remix-react-router` comes with some routing helpers : 137 | 138 | ```tsx 139 | export const MyStory = { 140 | parameters: { 141 | reactRouter: reactRouterParameters({ 142 | routing: reactRouterOutlet(), 143 | }), 144 | }, 145 | }; 146 | ``` 147 | 148 | ### Routing Helpers 149 | 150 | The following helpers are available out of the box : 151 | 152 | ```ts 153 | reactRouterOutlet(); // Render a single outlet 154 | reactRouterOutlets(); // Render multiple outlets 155 | reactRouterNestedOutlets(); // Render multiple outlets nested one into another 156 | reactRouterNestedAncestors(); // Render the story as an outlet of nested outlets 157 | ``` 158 | 159 | You can also create your own helper and use the exported type `RoutingHelper` to assist you : 160 | 161 | ```ts 162 | import { RoutingHelper } from 'storybook-addon-remix-react-router'; 163 | 164 | const myCustomHelper: RoutingHelper = () => { 165 | // Routing creation logic 166 | }; 167 | ``` 168 | 169 | `RouterRoute` is basically the native `RouteObject` from `react-router`; augmented with `{ useStoryElement?: boolean }`. 170 | If you want to accept a JSX and turn it into a `RouterRoute`, you can use the exported function `castRouterRoute`. 171 | 172 | ### Use the story as the route element 173 | 174 | Just set `{ useStoryElement: true }` in the routing config object. 175 | 176 | ## Dedicated panel 177 | 178 | Navigation events, loader and actions are logged, for you to better understand the lifecycle of your components. 179 | 180 | ![Addon Panel](https://user-images.githubusercontent.com/94478/224843029-b37ff60d-10f8-4198-bbc3-f26e2775437f.png) 181 | 182 | ## Compatibility 183 | 184 | This package aims to support `Storybook > 7` and `React > 16`. 185 | Here is a compatibility table : 186 | 187 | | Addon | React | Storybook | React Router | 188 | |-------|-----------------------|-----------|------------------| 189 | | 5.x | >= 16.8.0 | 9.x | 7.x | 190 | | 4.x | >= 16.8.0 | 8.x | 7.x | 191 | | 3.x | >= 16.8.0 | 8.x | 6.x 1 | 192 | | 2.x | >= 16.8.0 < 19.0.0 | 7.x | 6.x | 193 | | 1.x | >= 16.8.0 < 19.0.0 | 7.x | 6.x | 194 | 195 | 1 You can actually use react-router v7 if you import from `react-router-dom` and not `react-router`. 196 | 197 | If you have an issue with any version, open an issue. 198 | 199 | ## Contribution 200 | 201 | Contributions are welcome. 202 | 203 | Before writing any code, file an issue to showcase the bug or the use case for the feature you want to see in this addon. 204 | 205 | ## License 206 | 207 | This package is released under the Apache 2.0 license. 208 | -------------------------------------------------------------------------------- /UPGRADE_V1_V2.md: -------------------------------------------------------------------------------- 1 | # Upgrade from v1 to v2 2 | 3 | The `v2` makes a clear distinction between routing declaration and the browser location. 4 | 5 | Here is a simplified view of the two APIs : 6 | 7 | ```tsx 8 | // v1 9 | type ReactRouterAddonStoryParameters = { 10 | routeId: string; 11 | routePath: string; 12 | routeParams: {}; 13 | routeState: any; 14 | routeHandle: any; 15 | searchParams: {}; 16 | outlet: React.ReactNode | OutletProps; 17 | browserPath: string; 18 | loader: LoaderFunction; 19 | action: ActionFunction; 20 | errorElement: React.ReactNode; 21 | hydrationData: HydrationState; 22 | shouldRevalidate: ShouldRevalidateFunction; 23 | }; 24 | 25 | // v2 26 | type ReactRouterAddonStoryParameters = { 27 | hydrationData: HydrationState; 28 | location: { 29 | path: string | Function; 30 | pathParams: {}; 31 | searchParams: {}; 32 | hash: string; 33 | state: any; 34 | }; 35 | routing: string | RouteObject | RouteObject[]; // <= You can now use react-router native configuration 36 | }; 37 | ``` 38 | 39 | Before 40 | 41 | ```tsx 42 | export const UserProfile = { 43 | render: , 44 | parameters: { 45 | reactRouter: { 46 | routePath: '/users/:userId', 47 | routeParams: { userId: '42' }, 48 | }, 49 | }, 50 | }; 51 | ``` 52 | 53 | New version, verbose 54 | 55 | ```tsx 56 | export const UserProfile = { 57 | render: , 58 | parameters: { 59 | // Note the helper function 👇 that provide auto-completion and type safety 60 | reactRouter: reactRouterParameters({ 61 | location: { 62 | path: '/users/:userId', 63 | pathParams: { userId: 42 }, 64 | }, 65 | routing: [ 66 | { 67 | path: '/users/:userId', 68 | }, 69 | ], 70 | }), 71 | }, 72 | }; 73 | ``` 74 | 75 | To limit the verbosity, you can do two things : 76 | 77 | 1. `routing` : if you only want to set the path of the story you can use a `string`. Also, if you have a single route, you can pass an object instead of an array of object. 78 | 2. `location` : you can omit `location.path` if the path you want is the joined `path`s defined in your `routing`. 79 | 80 | New version, using shorthands 81 | 82 | ```tsx 83 | export const UserProfile = { 84 | render: , 85 | parameters: { 86 | // Note the helper function 👇 that provide auto-completion and type safety 87 | reactRouter: reactRouterParameters({ 88 | location: { 89 | pathParams: { userId: 42 }, 90 | }, 91 | routing: '/users/:userId', 92 | }), 93 | }, 94 | }; 95 | ``` 96 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@storybook-addon-remix-react-router/repo", 3 | "private": true, 4 | "version": "5.0.0", 5 | "description": "Use Remix React Router in your stories", 6 | "type": "module", 7 | "packageManager": "pnpm@9.15.0", 8 | "scripts": { 9 | "preinstall": "npx only-allow pnpm", 10 | "test": "vitest --run" 11 | }, 12 | "devDependencies": { 13 | "@auto-it/conventional-commits": "^11.3.0", 14 | "@typescript-eslint/eslint-plugin": "^5.61.0", 15 | "@typescript-eslint/parser": "^5.61.0", 16 | "@vitest/ui": "^3.1.3", 17 | "auto": "^11.3.0", 18 | "boxen": "^5.0.1", 19 | "dedent": "^0.7.0", 20 | "eslint": "^8.44.0", 21 | "eslint-config-prettier": "^8.8.0", 22 | "eslint-plugin-prettier": "^4.2.1", 23 | "eslint-plugin-react-hooks": "^4.6.0", 24 | "eslint-plugin-react-refresh": "^0.4.1", 25 | "lint-staged": "^13.2.3", 26 | "prettier": "2.8.8", 27 | "typescript": "5.3.2", 28 | "vite": "^6.0.3", 29 | "vitest": "^3.1.3" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/manager.js: -------------------------------------------------------------------------------- 1 | export * from './dist/manager.js'; 2 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storybook-addon-remix-react-router", 3 | "version": "5.0.0", 4 | "description": "Use Remix React Router in your stories. (Formerly storybook-addon-react-router-v6)", 5 | "keywords": [ 6 | "storybook", 7 | "storybook-addons", 8 | "remix", 9 | "react", 10 | "router", 11 | "react-router", 12 | "remix-react-router" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/JesusTheHun/storybook-addon-remix-react-router.git" 17 | }, 18 | "author": "Jonathan MASSUCHETTI ", 19 | "license": "Apache-2.0", 20 | "packageManager": "pnpm@9.15.0", 21 | "exports": { 22 | ".": { 23 | "types": "./dist/index.d.ts", 24 | "require": "./dist/index.cjs", 25 | "import": "./dist/index.js" 26 | }, 27 | "./preview": { 28 | "types": "./dist/preview.d.ts", 29 | "import": "./dist/preview.js", 30 | "require": "./dist/preview.js" 31 | }, 32 | "./preset": "./dist/preset.cjs", 33 | "./manager": "./dist/manager.js", 34 | "./package.json": "./package.json", 35 | "./internals": { 36 | "types": "./dist/internals.d.ts", 37 | "import": "./dist/internals.js", 38 | "require": "./dist/internals.js" 39 | } 40 | }, 41 | "type": "module", 42 | "main": "../../dist/index.js", 43 | "module": "dist/index.mjs", 44 | "types": "dist/index.d.ts", 45 | "files": [ 46 | "dist/**/*", 47 | "../../README.md", 48 | "*.js", 49 | "*.d.ts" 50 | ], 51 | "scripts": { 52 | "preinstall": "npx only-allow pnpm", 53 | "clean": "rimraf ./dist", 54 | "prebuild": "pnpm run clean", 55 | "build": "tsup", 56 | "build:watch": "tsup --watch", 57 | "test": "vitest --run", 58 | "prepack": "pnpm run build && cp ../../README.md .", 59 | "prerelease": "pnpm run build && cp ../../README.md .", 60 | "release": "auto shipit", 61 | "prettier:check": "prettier --check .", 62 | "prettier:write": "prettier --write ." 63 | }, 64 | "dependencies": { 65 | "@mjackson/form-data-parser": "^0.4.0", 66 | "compare-versions": "^6.0.0", 67 | "react-inspector": "6.0.2" 68 | }, 69 | "peerDependencies": { 70 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 71 | "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 72 | "react-router": "^7.0.2", 73 | "storybook": "^9.0.0" 74 | }, 75 | "devDependencies": { 76 | "@remix-run/router": "^1.3.3", 77 | "@remix-run/web-fetch": "^4.3.2", 78 | "@storybook/react-vite": "9.0.0-rc.1", 79 | "@types/node": "^18.15.0", 80 | "@types/react": "^18.3.14", 81 | "@types/react-inspector": "^4.0.2", 82 | "@vitejs/plugin-react": "^4.4.1", 83 | "chromatic": "^6.17.4", 84 | "concurrently": "^6.2.0", 85 | "dedent": "^0.7.0", 86 | "expect-type": "^0.16.0", 87 | "jsdom": "^25.0.1", 88 | "prop-types": "^15.8.1", 89 | "react": "^18.0.1", 90 | "react-dom": "^18.0.1", 91 | "react-router": "^7.0.2", 92 | "rimraf": "^6.0.1", 93 | "storybook": "9.0.0-rc.1", 94 | "tsup": "^8.3.5", 95 | "typescript": "5.3.2", 96 | "utility-types": "^3.10.0", 97 | "vite": "^6.0.3", 98 | "vitest": "^2.1.8" 99 | }, 100 | "lint-staged": { 101 | "**/*": "prettier --write --ignore-unknown" 102 | }, 103 | "peerDependenciesMeta": { 104 | "react": { 105 | "optional": true 106 | }, 107 | "react-dom": { 108 | "optional": true 109 | } 110 | }, 111 | "publishConfig": { 112 | "access": "public" 113 | }, 114 | "storybook": { 115 | "displayName": "Remix React Router (formerly React Router v6)", 116 | "supportedFrameworks": [ 117 | "react" 118 | ], 119 | "icon": "https://user-images.githubusercontent.com/94478/167677696-c05c668e-6290-4ced-8b6b-c2a40211f8e7.jpg" 120 | }, 121 | "bugs": { 122 | "url": "https://github.com/JesusTheHun/storybook-addon-remix-react-router/issues" 123 | }, 124 | "homepage": "https://github.com/JesusTheHun/storybook-addon-remix-react-router#readme" 125 | } 126 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ADDON_ID = 'storybook/react-router-v6'; 2 | export const PANEL_ID = `${ADDON_ID}/panel`; 3 | export const PARAM_KEY = `reactRouter`; 4 | 5 | export const EVENTS = { 6 | CLEAR: `${ADDON_ID}/clear`, 7 | NAVIGATION: `${ADDON_ID}/navigation`, 8 | STORY_LOADED: `${ADDON_ID}/story-loaded`, 9 | ROUTE_MATCHES: `${ADDON_ID}/route-matches`, 10 | ACTION_INVOKED: `${ADDON_ID}/action_invoked`, 11 | ACTION_SETTLED: `${ADDON_ID}/action_settled`, 12 | LOADER_INVOKED: `${ADDON_ID}/loader_invoked`, 13 | LOADER_SETTLED: `${ADDON_ID}/loader_settled`, 14 | } as const; 15 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/components/ReactRouterDecorator.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DeepRouteMatchesContext } from '../contexts/DeepRouteMatchesContext'; 3 | import { StoryContext } from '../contexts/StoryContext'; 4 | import { useRouteContextMatches } from '../hooks/useRouteContextMatches'; 5 | import { LocationParameters, NavigationHistoryEntry, RouterParameters } from '../types'; 6 | import { StoryRouter } from './StoryRouter'; 7 | 8 | export type ReactRouterDecoratorProps = { 9 | renderStory: (context: unknown) => React.ReactElement; 10 | storyContext: { args: Record }; 11 | addonParameters: ReactRouterAddonStoryParameters; 12 | }; 13 | 14 | export type ReactRouterAddonStoryParameters = 15 | | (RouterParameters & { 16 | location?: LocationParameters; 17 | navigationHistory?: never; 18 | }) 19 | | (RouterParameters & { 20 | location?: never; 21 | navigationHistory: [NavigationHistoryEntry, ...NavigationHistoryEntry[]]; 22 | }); 23 | 24 | export const ReactRouterDecorator: React.FC = ({ 25 | renderStory, 26 | storyContext, 27 | addonParameters, 28 | }) => { 29 | const deepRouteMatches = useRouteContextMatches(); 30 | 31 | return ( 32 | 33 | 34 | 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/components/RouterLogger.tsx: -------------------------------------------------------------------------------- 1 | import { addons } from 'storybook/preview-api'; 2 | import React, { useRef } from 'react'; 3 | import { useLocation, RouteMatch } from 'react-router'; 4 | 5 | import { EVENTS } from '../../../constants'; 6 | import { useDeepRouteMatches } from '../hooks/useDeepRouteMatches'; 7 | import { useNavigationEventBuilder } from '../hooks/useNavigationEventBuilder'; 8 | import { useStory } from '../hooks/useStory'; 9 | 10 | export function RouterLogger() { 11 | const { renderStory, storyContext } = useStory(); 12 | const channel = addons.getChannel(); 13 | const location = useLocation(); 14 | const matches = useDeepRouteMatches(); 15 | 16 | const buildEventData = useNavigationEventBuilder(); 17 | 18 | const storyLoadedEmittedLocationKeyRef = useRef(); 19 | const lastNavigationEventLocationKeyRef = useRef(); 20 | const lastRouteMatchesRef = useRef(); 21 | 22 | const storyLoaded = storyLoadedEmittedLocationKeyRef.current !== undefined; 23 | const shouldEmitNavigationEvents = storyLoaded && location.key !== storyLoadedEmittedLocationKeyRef.current; 24 | 25 | if (shouldEmitNavigationEvents && lastNavigationEventLocationKeyRef.current !== location.key) { 26 | channel.emit(EVENTS.NAVIGATION, buildEventData(EVENTS.NAVIGATION)); 27 | lastNavigationEventLocationKeyRef.current = location.key; 28 | } 29 | 30 | if (shouldEmitNavigationEvents && matches.length > 0 && matches !== lastRouteMatchesRef.current) { 31 | channel.emit(EVENTS.ROUTE_MATCHES, buildEventData(EVENTS.ROUTE_MATCHES)); 32 | } 33 | 34 | if (!storyLoaded && matches.length > 0) { 35 | channel.emit(EVENTS.STORY_LOADED, buildEventData(EVENTS.STORY_LOADED)); 36 | storyLoadedEmittedLocationKeyRef.current = location.key; 37 | lastRouteMatchesRef.current = matches; 38 | } 39 | 40 | lastRouteMatchesRef.current = matches; 41 | 42 | return <>{renderStory(storyContext)}; 43 | } 44 | 45 | RouterLogger.displayName = 'RouterLogger'; 46 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/components/StoryRouter.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { createMemoryRouter, RouterProvider, RouteObject } from 'react-router'; 3 | import { useRouteObjectsDecorator } from '../hooks/useRouteObjectsDecorator'; 4 | import { useStory } from '../hooks/useStory'; 5 | 6 | import { injectStory } from '../utils/injectStory'; 7 | import { normalizeHistory } from '../utils/normalizeHistory'; 8 | import { normalizeRouting } from '../utils/normalizeRouting'; 9 | import { RouterLogger } from './RouterLogger'; 10 | 11 | export function StoryRouter() { 12 | const { addonParameters = {} } = useStory(); 13 | const { hydrationData, routing, navigationHistory, location, future, fallback } = addonParameters; 14 | 15 | const decorateRouteObjects = useRouteObjectsDecorator(); 16 | 17 | const memoryRouter = useMemo(() => { 18 | const normalizedRoutes = normalizeRouting(routing); 19 | const decoratedRoutes = decorateRouteObjects(normalizedRoutes); 20 | const injectedRoutes = injectStory(decoratedRoutes, ); 21 | 22 | const { initialEntries, initialIndex } = normalizeHistory({ navigationHistory, location, routes: injectedRoutes }); 23 | 24 | const resolvedOptions: Parameters[1] = { 25 | initialEntries, 26 | initialIndex, 27 | hydrationData, 28 | }; 29 | 30 | if (future) { 31 | resolvedOptions.future = future; 32 | } 33 | 34 | return createMemoryRouter(injectedRoutes as RouteObject[], resolvedOptions); 35 | }, [decorateRouteObjects, hydrationData, location, navigationHistory, routing, future]); 36 | 37 | const expandProps: Record = {}; 38 | const fallbackElement = fallback ?? ; 39 | 40 | if (future) { 41 | expandProps.future = future; 42 | } 43 | 44 | if (future?.v7_partialHydration === true) { 45 | expandProps.HydrateFallback = fallbackElement; 46 | } 47 | 48 | if (future?.v7_partialHydration === false) { 49 | expandProps.fallbackElement = fallbackElement; 50 | } 51 | 52 | return ; 53 | } 54 | 55 | function Fallback() { 56 | return

Performing initial data load

; 57 | } 58 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/contexts/DeepRouteMatchesContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { RouteMatch } from 'react-router'; 4 | 5 | export const DeepRouteMatchesContext: React.Context = React.createContext([]); 6 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/contexts/StoryContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ReactRouterAddonStoryParameters } from '../components/ReactRouterDecorator'; 3 | 4 | export const StoryContext = React.createContext< 5 | | { 6 | renderStory: (context: unknown) => React.ReactElement; 7 | storyContext: { args: Record }; 8 | addonParameters: ReactRouterAddonStoryParameters; 9 | } 10 | | undefined 11 | >(undefined); 12 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/hooks/useActionDecorator.ts: -------------------------------------------------------------------------------- 1 | import { ActionFunctionArgs } from '@remix-run/router/utils'; 2 | import { addons } from 'storybook/preview-api'; 3 | import { useCallback } from 'react'; 4 | import { ActionFunction } from 'react-router'; 5 | import { EVENTS } from '../../../constants'; 6 | import { useDataEventBuilder } from './useDataEventBuilder'; 7 | 8 | export function useActionDecorator() { 9 | const channel = addons.getChannel(); 10 | const createEventData = useDataEventBuilder(); 11 | 12 | return useCallback( 13 | (action: ActionFunction) => 14 | async function (actionArgs: ActionFunctionArgs) { 15 | if (action === undefined) return; 16 | 17 | channel.emit(EVENTS.ACTION_INVOKED, await createEventData(EVENTS.ACTION_INVOKED, actionArgs)); 18 | const actionResult = await action(actionArgs); 19 | channel.emit(EVENTS.ACTION_SETTLED, await createEventData(EVENTS.ACTION_SETTLED, actionResult)); 20 | 21 | return actionResult; 22 | }, 23 | [channel, createEventData] 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/hooks/useCurrentUrl.ts: -------------------------------------------------------------------------------- 1 | import { useLocation } from 'react-router'; 2 | 3 | export const useCurrentUrl = () => { 4 | const location = useLocation(); 5 | 6 | let url = location.pathname; 7 | 8 | if (location.search.length > 0) { 9 | url += `?${location.search}`; 10 | } 11 | 12 | if (location.hash.length > 0) { 13 | url += `#${location.hash}`; 14 | } 15 | 16 | return url; 17 | }; 18 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/hooks/useDataEventBuilder.ts: -------------------------------------------------------------------------------- 1 | import type { RouterDataEvent, DataEventArgs, RouterDataEventName } from '../../panel/types'; 2 | import { useCallback, useRef } from 'react'; 3 | import { EVENTS } from '../../../constants'; 4 | 5 | import { getHumanReadableBody } from '../utils/getHumanReadableBody'; 6 | 7 | export const useDataEventBuilder = () => { 8 | const eventCount = useRef(0); 9 | 10 | return useCallback( 11 | async ( 12 | eventName: RouterDataEventName, 13 | eventArgs?: DataEventArgs[keyof DataEventArgs] 14 | ): Promise => { 15 | eventCount.current++; 16 | const key = `${eventName}_${eventCount.current}`; 17 | 18 | switch (eventName) { 19 | case EVENTS.ACTION_INVOKED: { 20 | const { request, params, context } = eventArgs as DataEventArgs[typeof eventName]; 21 | const requestData = { 22 | url: request.url, 23 | method: request.method, 24 | body: await getHumanReadableBody(request), 25 | }; 26 | 27 | const data = { params, request: requestData, context }; 28 | return { key, type: eventName, data }; 29 | } 30 | 31 | case EVENTS.ACTION_SETTLED: { 32 | return { key, type: eventName, data: eventArgs }; 33 | } 34 | 35 | case EVENTS.LOADER_INVOKED: { 36 | const { request, params, context } = eventArgs as DataEventArgs[typeof eventName]; 37 | 38 | const requestData = { 39 | url: request.url, 40 | method: request.method, 41 | body: await getHumanReadableBody(request), 42 | }; 43 | 44 | const data = { params, request: requestData, context }; 45 | return { key, type: eventName, data }; 46 | } 47 | 48 | case EVENTS.LOADER_SETTLED: { 49 | return { key, type: eventName, data: eventArgs }; 50 | } 51 | } 52 | }, 53 | [] 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/hooks/useDeepRouteMatches.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { RouteMatch } from 'react-router'; 3 | import { DeepRouteMatchesContext } from '../contexts/DeepRouteMatchesContext'; 4 | 5 | export const useDeepRouteMatches = (): RouteMatch[] => { 6 | return React.useContext(DeepRouteMatchesContext); 7 | }; 8 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/hooks/useLoaderDecorator.ts: -------------------------------------------------------------------------------- 1 | import { addons } from 'storybook/preview-api'; 2 | import { useCallback } from 'react'; 3 | import { LoaderFunction, LoaderFunctionArgs } from 'react-router'; 4 | import { EVENTS } from '../../../constants'; 5 | import { useDataEventBuilder } from './useDataEventBuilder'; 6 | 7 | export function useLoaderDecorator() { 8 | const channel = addons.getChannel(); 9 | const createEventData = useDataEventBuilder(); 10 | 11 | return useCallback( 12 | (loader: LoaderFunction) => 13 | async function (loaderArgs: LoaderFunctionArgs) { 14 | if (loader === undefined) return; 15 | 16 | channel.emit(EVENTS.LOADER_INVOKED, await createEventData(EVENTS.LOADER_INVOKED, loaderArgs)); 17 | const loaderResult = await loader(loaderArgs); 18 | channel.emit(EVENTS.LOADER_SETTLED, await createEventData(EVENTS.LOADER_SETTLED, loaderResult)); 19 | 20 | return loaderResult; 21 | }, 22 | [channel, createEventData] 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/hooks/useNavigationEventBuilder.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { useLocation, useNavigationType, useParams, useSearchParams } from 'react-router'; 3 | import { ValuesType } from 'utility-types'; 4 | import { EVENTS } from '../../../constants'; 5 | import type { RouterNavigationEvent, RouterNavigationEventName, RouteMatchesData } from '../../panel/types'; 6 | 7 | import { searchParamsToRecord } from '../utils/searchParamsToRecord'; 8 | import { useCurrentUrl } from './useCurrentUrl'; 9 | import { useDeepRouteMatches } from './useDeepRouteMatches'; 10 | 11 | export const useNavigationEventBuilder = () => { 12 | const eventCount = useRef(0); 13 | const location = useLocation(); 14 | const params = useParams(); 15 | const [search] = useSearchParams(); 16 | const navigationType = useNavigationType(); 17 | const matches = useDeepRouteMatches(); 18 | 19 | const searchParams = searchParamsToRecord(search); 20 | const currentUrl = useCurrentUrl(); 21 | 22 | const matchesData: RouteMatchesData = matches.map((routeMatch) => { 23 | const match: ValuesType = { 24 | path: routeMatch.route.path, 25 | }; 26 | 27 | if (Object.keys(routeMatch.params).length > 0) { 28 | match.params = routeMatch.params; 29 | } 30 | 31 | return match; 32 | }); 33 | 34 | const locationData = { 35 | url: currentUrl, 36 | path: location.pathname, 37 | routeParams: params, 38 | searchParams, 39 | hash: location.hash, 40 | routeState: location.state, 41 | routeMatches: matchesData, 42 | }; 43 | 44 | return (eventName: RouterNavigationEventName): RouterNavigationEvent => { 45 | eventCount.current++; 46 | const key = `${eventName}_${eventCount.current}`; 47 | 48 | switch (eventName) { 49 | case EVENTS.STORY_LOADED: { 50 | return { key, type: eventName, data: locationData }; 51 | } 52 | 53 | case EVENTS.NAVIGATION: { 54 | return { key, type: eventName, data: { ...locationData, navigationType } }; 55 | } 56 | 57 | case EVENTS.ROUTE_MATCHES: { 58 | return { key, type: eventName, data: { matches: matchesData } }; 59 | } 60 | } 61 | }; 62 | }; 63 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/hooks/useRouteContextMatches.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { UNSAFE_RouteContext, RouteMatch } from 'react-router'; 3 | 4 | type Ctx = { 5 | _currentValue?: { matches: RouteMatch[] }; 6 | }; 7 | 8 | export function useRouteContextMatches(): RouteMatch[] { 9 | const [deepRouteMatches, setDeepRouteMatches] = useState([]); 10 | 11 | const RouteContext = UNSAFE_RouteContext as unknown as { Provider: { _context: Ctx } }; 12 | 13 | RouteContext.Provider._context = new Proxy(RouteContext.Provider._context ?? {}, { 14 | set(target: Ctx, p: keyof Ctx, v: Ctx[keyof Ctx]) { 15 | if (p === '_currentValue') { 16 | if (v !== undefined) { 17 | setDeepRouteMatches((currentMatches) => { 18 | if (v.matches.length > currentMatches.length) { 19 | return v.matches; 20 | } 21 | return currentMatches; 22 | }); 23 | } 24 | } 25 | 26 | return Reflect.set(target, p, v); 27 | }, 28 | }); 29 | 30 | return deepRouteMatches; 31 | } 32 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/hooks/useRouteObjectsDecorator.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { RouterRoute } from '../types'; 3 | import { useActionDecorator } from './useActionDecorator'; 4 | import { useLoaderDecorator } from './useLoaderDecorator'; 5 | 6 | type RouteObjectDecorator = (routeDefinitions: T) => T; 7 | 8 | export function useRouteObjectsDecorator(): RouteObjectDecorator { 9 | const decorateAction = useActionDecorator(); 10 | const decorateLoader = useLoaderDecorator(); 11 | 12 | const decorateRouteObjects = useCallback( 13 | (routeDefinitions: T): T => { 14 | return routeDefinitions.map((routeDefinition) => { 15 | // eslint-disable-next-line prefer-const 16 | let { action, loader, children, lazy } = routeDefinition; 17 | const augmentedRouteDefinition = { ...routeDefinition }; 18 | 19 | if (lazy) { 20 | augmentedRouteDefinition.lazy = async function () { 21 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 22 | const lazyResult = await lazy!(); 23 | const augmentedLazyResult = { ...lazyResult }; 24 | if (typeof lazyResult.action === 'function') augmentedLazyResult.action = decorateAction(lazyResult.action); 25 | if (typeof lazyResult.loader === 'function') augmentedLazyResult.loader = decorateLoader(lazyResult.loader); 26 | 27 | return augmentedLazyResult; 28 | }; 29 | } 30 | 31 | if (typeof action === 'function') { 32 | augmentedRouteDefinition.action = decorateAction(action); 33 | } 34 | 35 | if (typeof loader === 'function') { 36 | augmentedRouteDefinition.loader = decorateLoader(loader); 37 | } 38 | 39 | if (children) { 40 | augmentedRouteDefinition.children = decorateRouteObjects(children); 41 | } 42 | 43 | return augmentedRouteDefinition; 44 | }) as T; 45 | }, 46 | [decorateAction, decorateLoader] 47 | ); 48 | 49 | return decorateRouteObjects; 50 | } 51 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/hooks/useStory.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { StoryContext } from '../contexts/StoryContext'; 3 | 4 | export const useStory = () => { 5 | const contextValue = React.useContext(StoryContext); 6 | 7 | if (contextValue === undefined) { 8 | throw new Error('useStory should be used inside '); 9 | } 10 | 11 | return contextValue; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/types.ts: -------------------------------------------------------------------------------- 1 | import type { HydrationState, FutureConfig } from '@remix-run/router'; 2 | 3 | import { createMemoryRouter } from 'react-router'; 4 | 5 | import React from 'react'; 6 | import { LazyRouteFunction, RouteObject } from 'react-router'; 7 | import { Overwrite, PromiseType } from 'utility-types'; 8 | import { Merge } from '../../utils/type-utils'; 9 | 10 | type FutureConfigRouter = Exclude[1], undefined>['future']; 11 | 12 | export type RouterParameters = { 13 | hydrationData?: HydrationState; 14 | routing?: string | RouterRoute | [RouterRoute, ...RouterRoute[]]; 15 | future?: Partial; 16 | fallback?: React.JSX.Element; 17 | }; 18 | 19 | export type LocationParameters = Record> = { 20 | path?: string | ((inferredPath: string, pathParams: PathParams) => string | undefined); 21 | pathParams?: PathParams; 22 | searchParams?: ConstructorParameters[0]; 23 | hash?: string; 24 | state?: unknown; 25 | }; 26 | 27 | export type NavigationHistoryEntry = LocationParameters & { 28 | isInitialLocation?: boolean; 29 | }; 30 | 31 | export type RouterRoute = Overwrite & StoryRouteIdentifier; 32 | 33 | export type RouteDefinition = React.ReactElement | RouteDefinitionObject; 34 | export type NonIndexRouteDefinition = React.ReactElement | NonIndexRouteDefinitionObject; 35 | 36 | export type RouteDefinitionObject = Merge & StoryRouteIdentifier>; 37 | export type NonIndexRouteDefinitionObject = RouteDefinitionObject & { index?: false }; 38 | 39 | export type StoryRouteIdentifier = { useStoryElement?: boolean }; 40 | 41 | type Params = Record extends RouteParamsFromPath 42 | ? { path?: Path } 43 | : { path: Path; params: RouteParamsFromPath }; 44 | 45 | type PushRouteParam< 46 | Segment extends string | undefined, 47 | RouteParams extends Record | unknown 48 | > = Segment extends `:${infer ParamName}` ? { [key in ParamName | keyof RouteParams]: string } : RouteParams; 49 | 50 | export type RouteParamsFromPath = 51 | Path extends `${infer CurrentSegment}/${infer RemainingPath}` 52 | ? PushRouteParam> 53 | : PushRouteParam; 54 | 55 | type LazyReturnType = T extends { 56 | lazy?: infer Lazy extends LazyRouteFunction; 57 | } 58 | ? PromiseType> 59 | : never; 60 | 61 | export type RoutingHelper = (...args: never[]) => [RouterRoute, ...RouterRoute[]]; 62 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/castParametersV2.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | import { hasOwnProperty } from '../../../utils/misc'; 4 | import { ReactRouterAddonStoryParameters } from '../components/ReactRouterDecorator'; 5 | import { LocationParameters, RouterRoute } from '../types'; 6 | import { castRouterRoute } from './castRouterRoute'; 7 | 8 | export function castParametersV2(parameters: Record = {}): ReactRouterAddonStoryParameters { 9 | const exclusiveV2properties = ['location', 'navigationHistory', 'routing', 'future']; 10 | const isV2 = Object.keys(parameters ?? {}).some((prop) => exclusiveV2properties.includes(prop)); 11 | 12 | if (isV2) return parameters; 13 | 14 | const v2params = { 15 | routing: {} as RouterRoute, 16 | location: {} as LocationParameters, 17 | hydrationData: undefined, 18 | } satisfies ReactRouterAddonStoryParameters; 19 | 20 | // The order is important 21 | if (hasOwnProperty(parameters, 'routePath')) { 22 | v2params.location.path = parameters.routePath as any; 23 | v2params.routing.path = parameters.routePath as any; 24 | } 25 | if (hasOwnProperty(parameters, 'routeParams')) v2params.location.pathParams = parameters.routeParams as any; 26 | if (hasOwnProperty(parameters, 'routeState')) v2params.location.state = parameters.routeState as any; 27 | if (hasOwnProperty(parameters, 'routeHandle')) v2params.routing.handle = parameters.routeHandle as any; 28 | if (hasOwnProperty(parameters, 'searchParams')) v2params.location.searchParams = parameters.searchParams as any; 29 | if (hasOwnProperty(parameters, 'browserPath')) v2params.location.path = parameters.browserPath as any; 30 | if (hasOwnProperty(parameters, 'loader')) v2params.routing.loader = parameters.loader as any; 31 | if (hasOwnProperty(parameters, 'action')) v2params.routing.action = parameters.action as any; 32 | if (hasOwnProperty(parameters, 'errorElement')) v2params.routing.errorElement = parameters.errorElement as any; 33 | if (hasOwnProperty(parameters, 'hydrationData')) v2params.hydrationData = parameters.hydrationData as any; 34 | if (hasOwnProperty(parameters, 'shouldRevalidate')) 35 | v2params.routing.shouldRevalidate = parameters.shouldRevalidate as any; 36 | if (hasOwnProperty(parameters, 'routeId')) v2params.routing.id = parameters.routeId as any; 37 | 38 | if (hasOwnProperty(parameters, 'outlet')) { 39 | const outlet = castRouterRoute(parameters.outlet as any); 40 | outlet.path ??= ''; 41 | v2params.routing.children = [outlet]; 42 | } 43 | 44 | v2params.routing.useStoryElement = true; 45 | 46 | return v2params; 47 | } 48 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/castRouterRoute.tsx: -------------------------------------------------------------------------------- 1 | import { RouteDefinition, RouterRoute } from '../types'; 2 | import { isValidReactNode } from './isValidReactNode'; 3 | 4 | export function castRouterRoute(definition: RouteDefinition): RouterRoute { 5 | if (isValidReactNode(definition)) { 6 | return { element: definition }; 7 | } 8 | 9 | return definition; 10 | } 11 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/getFormDataSummary.spec.ts: -------------------------------------------------------------------------------- 1 | import { FormData } from '@remix-run/web-fetch'; 2 | import { describe, expect, it } from 'vitest'; 3 | import { getFormDataSummary } from './getFormDataSummary'; 4 | 5 | describe('getFormDataSummary', () => { 6 | it('should return a record with an entry for each form data string value', () => { 7 | const formData = new FormData(); 8 | formData.append('foo', 'bar'); 9 | formData.append('fuu', 'baz'); 10 | 11 | const summary = getFormDataSummary(formData); 12 | 13 | expect(summary).toEqual({ 14 | foo: 'bar', 15 | fuu: 'baz', 16 | }); 17 | }); 18 | 19 | it('should return an object describing the file', () => { 20 | const formData = new FormData(); 21 | formData.append('myFile', new File(['somecontent'], 'myFile.txt', { type: 'text/plain' })); 22 | 23 | const summary = getFormDataSummary(formData); 24 | 25 | expect(summary).toEqual({ 26 | myFile: { 27 | filename: 'myFile.txt', 28 | filetype: 'text/plain', 29 | filesize: 11, 30 | }, 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/getFormDataSummary.ts: -------------------------------------------------------------------------------- 1 | export type FileSummary = { filename: string; filesize: number; filetype: string }; 2 | 3 | export function getFormDataSummary(formData: FormData): Record { 4 | const data: Record = {}; 5 | 6 | formData.forEach((value, key) => { 7 | if (value instanceof File) { 8 | data[key] = { 9 | filename: value.name, 10 | filesize: value.size, 11 | filetype: value.type, 12 | }; 13 | return; 14 | } 15 | 16 | data[key] = value; 17 | }); 18 | 19 | return data; 20 | } 21 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/getHumanReadableBody.ts: -------------------------------------------------------------------------------- 1 | import { FileSummary, getFormDataSummary } from './getFormDataSummary'; 2 | 3 | export async function getHumanReadableBody(request: Request) { 4 | const requestClone = request.clone(); 5 | const contentTypeHeader = requestClone.headers.get('content-type') || ''; 6 | 7 | let humanReadableBody: string | Record | undefined = undefined; 8 | 9 | switch (true) { 10 | case contentTypeHeader.startsWith('text'): 11 | humanReadableBody = await requestClone.text(); 12 | break; 13 | case contentTypeHeader.startsWith('application/json'): 14 | humanReadableBody = await requestClone.json(); 15 | break; 16 | case contentTypeHeader.startsWith('multipart/form-data'): 17 | case contentTypeHeader.startsWith('application/x-www-form-urlencoded'): { 18 | humanReadableBody = getFormDataSummary(await requestClone.formData()); 19 | break; 20 | } 21 | } 22 | 23 | return humanReadableBody; 24 | } 25 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/injectStory.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { RouterRoute } from '../types'; 3 | 4 | export function injectStory(routes: RouterRoute[], StoryElement: React.ReactElement): RouterRoute[] { 5 | // Single route, no children 6 | if (routes.length === 1 && (routes[0].children === undefined || routes[0].children.length === 0)) { 7 | return [{ ...routes[0], element: StoryElement }]; 8 | } 9 | 10 | const storyRouteIndex = routes.findIndex((route) => route.useStoryElement); 11 | 12 | if (storyRouteIndex !== -1) { 13 | return routes.map((route) => { 14 | if (!route.useStoryElement) return route; 15 | 16 | return { 17 | ...route, 18 | element: StoryElement, 19 | }; 20 | }); 21 | } 22 | 23 | return routes.map((route) => { 24 | if (!route.children) return route; 25 | 26 | return { 27 | ...route, 28 | children: injectStory(route.children, StoryElement), 29 | }; 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/isValidReactNode.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { hasOwnProperty } from '../../../utils/misc'; 3 | 4 | export function isValidReactNode(e: unknown): e is React.ReactNode { 5 | if (React.isValidElement(e)) return true; 6 | 7 | switch (true) { 8 | case React.isValidElement(e): 9 | case typeof e === 'string': 10 | case typeof e === 'number': 11 | case typeof e === 'boolean': 12 | case e === null: 13 | case e === undefined: 14 | case e instanceof Object && hasOwnProperty(e, Symbol.iterator): 15 | return true; 16 | } 17 | 18 | return false; 19 | } 20 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/normalizeHistory.spec.ts: -------------------------------------------------------------------------------- 1 | import { RouteObject } from 'react-router'; 2 | import { describe, it, expect } from 'vitest'; 3 | import { appendPathSegment, inferLocationPathFromRoutes } from './normalizeHistory'; 4 | 5 | describe('normalizeHistory', () => { 6 | describe('inferLocationPathFromRoutes', () => { 7 | it('should return "/" if no routes is given', () => { 8 | const result = inferLocationPathFromRoutes(); 9 | expect(result).toEqual('/'); 10 | }); 11 | 12 | it('should return the root path if a single route is given', () => { 13 | const routes = [{ path: '/parent/child' }]; 14 | const result = inferLocationPathFromRoutes(routes); 15 | expect(result).toEqual('/parent/child'); 16 | }); 17 | 18 | it('should return the root path if no default route is found', () => { 19 | const routes = [{ path: '/parent1' }, { path: '/parent2' }]; 20 | const result = inferLocationPathFromRoutes(routes); 21 | expect(result).toEqual('/'); 22 | }); 23 | 24 | it('should return the parent path if there is an index route', () => { 25 | const routes: RouteObject[] = [ 26 | { 27 | path: '/parent', 28 | children: [{ index: true }, { path: '/child' }], 29 | }, 30 | ]; 31 | const result = inferLocationPathFromRoutes(routes); 32 | expect(result).toEqual('/parent'); 33 | }); 34 | 35 | it('should return the joined path if each route has a single child', () => { 36 | const routes = [{ path: '/parent', children: [{ path: '/child' }] }]; 37 | const result = inferLocationPathFromRoutes(routes); 38 | expect(result).toEqual('/parent/child'); 39 | }); 40 | }); 41 | 42 | describe('appendPathSegment', () => { 43 | it('should return "/" if both the basePath and the segmentPath are empty', () => { 44 | expect(appendPathSegment('', '')).toEqual('/'); 45 | }); 46 | 47 | it('should return the basePath if there is no path to append', () => { 48 | expect(appendPathSegment('/', '')).toEqual('/'); 49 | expect(appendPathSegment('/test', '')).toEqual('/test'); 50 | }); 51 | 52 | it('should insert a slash before the pathSegment if missing', () => { 53 | expect(appendPathSegment('/test', 'path')).toEqual('/test/path'); 54 | expect(appendPathSegment('/test', '/path')).toEqual('/test/path'); 55 | }); 56 | 57 | it('should remove the slash after the basePath if present', () => { 58 | expect(appendPathSegment('/test/', 'path')).toEqual('/test/path'); 59 | expect(appendPathSegment('/test/', '/path')).toEqual('/test/path'); 60 | }); 61 | 62 | it('should add a heading slash to the basePath if missing', () => { 63 | expect(appendPathSegment('test', 'path')).toEqual('/test/path'); 64 | expect(appendPathSegment('test', '/path')).toEqual('/test/path'); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/normalizeHistory.ts: -------------------------------------------------------------------------------- 1 | import { generatePath, InitialEntry } from '@remix-run/router'; 2 | import { ReactRouterAddonStoryParameters } from '../components/ReactRouterDecorator'; 3 | import { RouterRoute } from '../types'; 4 | 5 | export type NormalizedHistoryParameters = Pick & { 6 | routes: RouterRoute[]; 7 | }; 8 | 9 | export function normalizeHistory({ navigationHistory, location, routes }: NormalizedHistoryParameters) { 10 | if (navigationHistory !== undefined) { 11 | const initialEntries: InitialEntry[] = []; 12 | let initialIndex; 13 | const compactNavigationHistory = Object.values(navigationHistory); 14 | 15 | for (let i = 0; i < compactNavigationHistory.length; i++) { 16 | const { path: userPath, pathParams, searchParams, hash, state, isInitialLocation } = compactNavigationHistory[i]; 17 | if (isInitialLocation) initialIndex = i; 18 | 19 | const inferredPath = inferLocationPathFromRoutes(routes); 20 | const computedPath = typeof userPath === 'function' ? userPath(inferredPath, pathParams ?? {}) : userPath; 21 | const path = computedPath ?? inferredPath; 22 | 23 | initialEntries.push({ 24 | pathname: generatePath(path ?? '/', pathParams), 25 | search: new URLSearchParams(searchParams).toString(), 26 | hash, 27 | state, 28 | }); 29 | } 30 | 31 | initialIndex ??= initialEntries.length - 1; 32 | 33 | return { 34 | initialEntries, 35 | initialIndex, 36 | }; 37 | } 38 | 39 | const { path: userPath, pathParams, searchParams, hash, state } = location ?? {}; 40 | 41 | const inferredPath = inferLocationPathFromRoutes(routes); 42 | const computedPath = typeof userPath === 'function' ? userPath(inferredPath, pathParams ?? {}) : userPath; 43 | const path = computedPath ?? inferredPath; 44 | 45 | const initialIndex = 0; 46 | const initialEntries: InitialEntry[] = [ 47 | { 48 | pathname: generatePath(path, pathParams), 49 | search: new URLSearchParams(searchParams).toString(), 50 | hash, 51 | state, 52 | }, 53 | ]; 54 | 55 | return { 56 | initialEntries, 57 | initialIndex, 58 | }; 59 | } 60 | 61 | export function inferLocationPathFromRoutes(routes: RouterRoute[] = [], basePath = '/'): string { 62 | if (routes.length !== 1) return basePath; 63 | 64 | const obviousRoute = routes[0]; 65 | const pathToObviousRoute = appendPathSegment(basePath, obviousRoute.path); 66 | 67 | if (obviousRoute.children === undefined || obviousRoute.children.length === 0) return pathToObviousRoute; 68 | 69 | return inferLocationPathFromRoutes(obviousRoute.children, pathToObviousRoute); 70 | } 71 | 72 | export function appendPathSegment(basePath: string, pathSegment = '') { 73 | const blankValues = ['', '/']; 74 | const basePathParts = basePath.split('/').filter((s) => !blankValues.includes(s)); 75 | const pathSegmentParts = pathSegment.split('/').filter((s) => !blankValues.includes(s)); 76 | 77 | const parts = [...basePathParts, ...pathSegmentParts]; 78 | 79 | return '/' + parts.join('/'); 80 | } 81 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/normalizeRouting.tsx: -------------------------------------------------------------------------------- 1 | import { castArray } from '../../../utils/misc'; 2 | import { ReactRouterAddonStoryParameters } from '../components/ReactRouterDecorator'; 3 | import { RouterRoute } from '../types'; 4 | 5 | export function normalizeRouting(routing: ReactRouterAddonStoryParameters['routing']): [RouterRoute, ...RouterRoute[]] { 6 | if (routing === undefined) { 7 | return [{ path: '/' }]; 8 | } 9 | 10 | if (typeof routing === 'string') { 11 | return [{ path: routing }]; 12 | } 13 | 14 | routing = castArray(routing); 15 | 16 | if (routing.length === 1) { 17 | routing[0].path ??= '/'; 18 | } 19 | 20 | return routing; 21 | } 22 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/optionalStoryArg.ts: -------------------------------------------------------------------------------- 1 | import { NonIndexRouteDefinitionObject, RouterRoute } from '../types'; 2 | import { castRouterRoute } from './castRouterRoute'; 3 | 4 | export type StoryRouteDefinition = string | Omit; 5 | 6 | export function optionalStoryArg(args: [RestType] | [StoryRouteDefinition, RestType]) { 7 | let story: RouterRoute = {}; 8 | let rest: unknown = []; 9 | 10 | if (args.length === 1) { 11 | story = {}; 12 | rest = args[0] as RestType; 13 | } 14 | 15 | if (args.length === 2) { 16 | story = typeof args[0] === 'string' ? { path: args[0] } : castRouterRoute(args[0]); 17 | rest = args[1] as RestType; 18 | } 19 | 20 | return [story, rest] as [RouterRoute, RestType]; 21 | } 22 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/routesHelpers/reactRouterNestedAncestors.ts: -------------------------------------------------------------------------------- 1 | import { castArray, hasOwnProperty, invariant } from '../../../../utils/misc'; 2 | import { 3 | NonIndexRouteDefinition, 4 | NonIndexRouteDefinitionObject, 5 | RouteDefinitionObject, 6 | RouterRoute, 7 | } from '../../types'; 8 | import { castRouterRoute } from '../castRouterRoute'; 9 | 10 | /** 11 | * Render the story as the outlet of an ancestor. 12 | * You can specify multiple ancestors to create a deep nesting. 13 | * Outlets are nested in a visual/JSX order : the first element of the array will be the root, the last will be 14 | * the direct parent of the story 15 | */ 16 | export function reactRouterNestedAncestors( 17 | ancestors: NonIndexRouteDefinition | NonIndexRouteDefinition[] 18 | ): [RouterRoute]; 19 | export function reactRouterNestedAncestors( 20 | story: Omit, 21 | ancestors: NonIndexRouteDefinition | NonIndexRouteDefinition[] 22 | ): [RouterRoute]; 23 | export function reactRouterNestedAncestors( 24 | ...args: 25 | | [NonIndexRouteDefinition | NonIndexRouteDefinition[]] 26 | | [Omit, NonIndexRouteDefinition | NonIndexRouteDefinition[]] 27 | ): [RouterRoute] { 28 | const story = (args.length === 1 ? {} : args[0]) as RouteDefinitionObject; 29 | const ancestors = castArray(args.length === 1 ? args[0] : args[1]); 30 | 31 | invariant( 32 | !hasOwnProperty(story, 'element'), 33 | 'The story definition cannot contain the `element` property because the story element will be used' 34 | ); 35 | 36 | const ancestorsRoot: RouterRoute = { path: '/' }; 37 | let lastAncestor = ancestorsRoot; 38 | 39 | for (let i = 0; i < ancestors.length; i++) { 40 | const ancestor = ancestors[i]; 41 | const ancestorDefinitionObjet = castRouterRoute(ancestor) as NonIndexRouteDefinitionObject; 42 | ancestorDefinitionObjet.path ??= ''; 43 | lastAncestor.children = [ancestorDefinitionObjet]; 44 | lastAncestor = ancestorDefinitionObjet; 45 | } 46 | 47 | lastAncestor.children = [ 48 | { 49 | ...story, 50 | index: true, 51 | useStoryElement: true, 52 | }, 53 | ]; 54 | 55 | return [ancestorsRoot]; 56 | } 57 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/routesHelpers/reactRouterNestedOutlets.ts: -------------------------------------------------------------------------------- 1 | import { RouteObject } from 'react-router'; 2 | import { hasOwnProperty, invariant } from '../../../../utils/misc'; 3 | import { NonIndexRouteDefinition, NonIndexRouteDefinitionObject, RouteDefinition, RouterRoute } from '../../types'; 4 | import { castRouterRoute } from '../castRouterRoute'; 5 | import { optionalStoryArg, StoryRouteDefinition } from '../optionalStoryArg'; 6 | 7 | /** 8 | * Render the story with multiple outlets nested one into the previous. 9 | * Use this function when your story component renders an outlet that itself can have outlet and so forth. 10 | * Outlets are nested in a visual/JSX order : the first element of the array will be the root, the last will be 11 | * the direct parent of the story 12 | * @see withOutlet 13 | * @see withOutlets 14 | */ 15 | export function reactRouterNestedOutlets(outlets: [...RouteDefinition[], NonIndexRouteDefinition]): [RouterRoute]; 16 | export function reactRouterNestedOutlets( 17 | story: StoryRouteDefinition, 18 | outlets: [...RouteDefinition[], NonIndexRouteDefinition] 19 | ): [RouterRoute]; 20 | export function reactRouterNestedOutlets( 21 | ...args: [RouteDefinition[]] | [StoryRouteDefinition, RouteDefinition[]] 22 | ): [RouterRoute] { 23 | const [story, outlets] = optionalStoryArg(args); 24 | 25 | invariant( 26 | !hasOwnProperty(story, 'element'), 27 | 'The story definition cannot contain the `element` property because the story element will be used' 28 | ); 29 | 30 | const outletsRoot: RouteObject = {}; 31 | let lastOutlet = outletsRoot; 32 | 33 | outlets.forEach((outlet) => { 34 | const outletDefinitionObjet = castRouterRoute(outlet) as NonIndexRouteDefinitionObject; 35 | outletDefinitionObjet.path ??= ''; 36 | lastOutlet.children = [outletDefinitionObjet]; 37 | lastOutlet = outletDefinitionObjet; 38 | }, outletsRoot); 39 | 40 | return [ 41 | { 42 | ...story, 43 | useStoryElement: true, 44 | children: [outletsRoot], 45 | }, 46 | ]; 47 | } 48 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/routesHelpers/reactRouterOutlet.ts: -------------------------------------------------------------------------------- 1 | import { hasOwnProperty, invariant } from '../../../../utils/misc'; 2 | import { RouteDefinition, RouterRoute } from '../../types'; 3 | import { castRouterRoute } from '../castRouterRoute'; 4 | import { optionalStoryArg, StoryRouteDefinition } from '../optionalStoryArg'; 5 | 6 | /** 7 | * Render the story with a single outlet 8 | * @see withOutlets 9 | * @see withNestedOutlets 10 | */ 11 | export function reactRouterOutlet(outlet: RouteDefinition): [RouterRoute]; 12 | export function reactRouterOutlet(story: StoryRouteDefinition, outlet: RouteDefinition): [RouterRoute]; 13 | export function reactRouterOutlet(...args: [RouteDefinition] | [StoryRouteDefinition, RouteDefinition]): [RouterRoute] { 14 | const [story, outlet] = optionalStoryArg(args); 15 | 16 | invariant( 17 | !hasOwnProperty(story, 'element'), 18 | 'The story definition cannot contain the `element` property because the story element will be used' 19 | ); 20 | 21 | const outletDefinitionObject = castRouterRoute(outlet); 22 | outletDefinitionObject.index = true; 23 | 24 | return [ 25 | { 26 | ...story, 27 | useStoryElement: true, 28 | children: [outletDefinitionObject], 29 | }, 30 | ]; 31 | } 32 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/routesHelpers/reactRouterOutlets.ts: -------------------------------------------------------------------------------- 1 | import { hasOwnProperty, invariant } from '../../../../utils/misc'; 2 | import { NonIndexRouteDefinitionObject, RouteDefinitionObject, RouterRoute } from '../../types'; 3 | import { optionalStoryArg } from '../optionalStoryArg'; 4 | 5 | /** 6 | * Render the story with multiple possible outlets. 7 | * Use this function when your story component can navigate and you want a different outlet depending on the path. 8 | * @see withOutlet 9 | * @see withNestedOutlets 10 | */ 11 | export function reactRouterOutlets(outlets: RouteDefinitionObject[]): [RouterRoute]; 12 | export function reactRouterOutlets( 13 | story: string | Omit, 14 | outlets: RouteDefinitionObject[] 15 | ): [RouterRoute]; 16 | export function reactRouterOutlets( 17 | ...args: 18 | | [RouteDefinitionObject[]] 19 | | [string | Omit, RouteDefinitionObject[]] 20 | ): [RouterRoute] { 21 | const [story, outlets] = optionalStoryArg(args); 22 | 23 | invariant( 24 | !hasOwnProperty(story, 'element'), 25 | 'The story definition cannot contain the `element` property because the story element will be used' 26 | ); 27 | 28 | return [ 29 | { 30 | ...story, 31 | useStoryElement: true, 32 | children: outlets, 33 | }, 34 | ]; 35 | } 36 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/routesHelpers/reactRouterParameters.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, expect, test } from 'vitest'; 3 | import { reactRouterOutlet } from './reactRouterOutlet'; 4 | import { reactRouterParameters } from './reactRouterParameters'; 5 | 6 | describe('reactRouterParameters', () => { 7 | function MyComponent() { 8 | return null; 9 | } 10 | 11 | test.skip('should look nice', () => { 12 | reactRouterParameters({ 13 | routing: reactRouterOutlet(), 14 | location: { hash: 'title1' }, 15 | }); 16 | }); 17 | 18 | test('it should return the given parameter', () => { 19 | const parameters = { routing: { path: '/users' } }; 20 | expect(reactRouterParameters(parameters)).toBe(parameters); 21 | }); 22 | 23 | test.skip('a typescript error should show up if the api v1 is used', () => { 24 | reactRouterParameters({ 25 | // @ts-expect-error test 26 | routePath: 'apiV1', 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/routesHelpers/reactRouterParameters.tsx: -------------------------------------------------------------------------------- 1 | import { ReactRouterAddonStoryParameters } from '../../components/ReactRouterDecorator'; 2 | 3 | export function reactRouterParameters(params: ReactRouterAddonStoryParameters) { 4 | return params; 5 | } 6 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/searchParamsToRecord.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { searchParamsToRecord } from './searchParamsToRecord'; 3 | 4 | describe('searchParamsToRecord', () => { 5 | it('should have a property for each search param', () => { 6 | const searchParams = new URLSearchParams({ 7 | foo: 'bar', 8 | yaz: 'yoz', 9 | }); 10 | 11 | expect(searchParamsToRecord(searchParams)).toEqual({ 12 | foo: 'bar', 13 | yaz: 'yoz', 14 | }); 15 | }); 16 | 17 | it('should merge values of the same param into an array', () => { 18 | const searchParamsTwoValues = new URLSearchParams({ 19 | foo: 'bar', 20 | yaz: 'yoz', 21 | }); 22 | searchParamsTwoValues.append('foo', 'baz'); 23 | 24 | expect(searchParamsToRecord(searchParamsTwoValues)).toEqual({ 25 | foo: ['bar', 'baz'], 26 | yaz: 'yoz', 27 | }); 28 | 29 | const searchParamsThreeValues = new URLSearchParams({ 30 | foo: 'bar', 31 | yaz: 'yoz', 32 | }); 33 | searchParamsThreeValues.append('foo', 'baz'); 34 | searchParamsThreeValues.append('foo', 'buz'); 35 | 36 | expect(searchParamsToRecord(searchParamsThreeValues)).toEqual({ 37 | foo: ['bar', 'baz', 'buz'], 38 | yaz: 'yoz', 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/utils/searchParamsToRecord.ts: -------------------------------------------------------------------------------- 1 | export function searchParamsToRecord(searchParams: URLSearchParams) { 2 | const result: Record = {}; 3 | 4 | searchParams.forEach((value, key) => { 5 | const currentValue = result[key]; 6 | if (typeof currentValue === 'string') { 7 | result[key] = [currentValue, value]; 8 | return; 9 | } 10 | 11 | if (Array.isArray(currentValue)) { 12 | result[key] = [...currentValue, value]; 13 | return; 14 | } 15 | 16 | result[key] = value; 17 | }); 18 | 19 | return result; 20 | } 21 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/decorator/withRouter.tsx: -------------------------------------------------------------------------------- 1 | import { makeDecorator } from 'storybook/preview-api'; 2 | import React from 'react'; 3 | import { PARAM_KEY } from '../../constants'; 4 | import { ReactRouterDecorator, ReactRouterDecoratorProps } from './components/ReactRouterDecorator'; 5 | import { castParametersV2 } from './utils/castParametersV2'; 6 | 7 | export const withRouter: () => any = makeDecorator({ 8 | name: 'withRouter', 9 | parameterName: PARAM_KEY, 10 | wrapper: (getStory, context, { parameters }) => { 11 | const addonParameters = castParametersV2(parameters); 12 | 13 | return ( 14 | 19 | ); 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/panel/components/InformationBanner.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from 'storybook/theming'; 2 | 3 | export const InformationBanner = styled.p` 4 | background: #ffebba; 5 | padding: 5px; 6 | margin-top: 0; 7 | `; 8 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/panel/components/InspectorContainer.tsx: -------------------------------------------------------------------------------- 1 | import { styled, StyledComponent } from 'storybook/theming'; 2 | import { ReactNode } from 'react'; 3 | 4 | export const InspectorContainer: StyledComponent<{ children: ReactNode }> = styled.div({ 5 | flex: 1, 6 | padding: '0 0 0 5px', 7 | }); 8 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/panel/components/Panel.tsx: -------------------------------------------------------------------------------- 1 | import { AddonPanel } from 'storybook/internal/components'; 2 | import { STORY_CHANGED } from 'storybook/internal/core-events'; 3 | import { API, useChannel } from 'storybook/manager-api'; 4 | import React, { useState } from 'react'; 5 | import { EVENTS } from '../../../constants'; 6 | import { useAddonVersions } from '../hooks/useAddonVersions'; 7 | import { RouterEvent } from '../types'; 8 | import { InformationBanner } from './InformationBanner'; 9 | import { PanelContent } from './PanelContent'; 10 | 11 | interface PanelProps { 12 | active: boolean; 13 | api: API; 14 | } 15 | 16 | export const Panel: React.FC = (props) => { 17 | const [navigationEvents, setNavigationEvents] = useState([]); 18 | const { latestAddonVersion, addonUpdateAvailable } = useAddonVersions(); 19 | 20 | const pushEvent = (event: RouterEvent) => { 21 | setNavigationEvents((prev) => [...prev, event]); 22 | }; 23 | 24 | useChannel({ 25 | [EVENTS.ROUTE_MATCHES]: pushEvent, 26 | [EVENTS.NAVIGATION]: pushEvent, 27 | [EVENTS.STORY_LOADED]: pushEvent, 28 | [EVENTS.ACTION_INVOKED]: pushEvent, 29 | [EVENTS.ACTION_SETTLED]: pushEvent, 30 | [EVENTS.LOADER_INVOKED]: pushEvent, 31 | [EVENTS.LOADER_SETTLED]: pushEvent, 32 | [STORY_CHANGED]: () => setNavigationEvents([]), 33 | }); 34 | 35 | const clear = () => { 36 | props.api.emit(EVENTS.CLEAR); 37 | setNavigationEvents([]); 38 | }; 39 | 40 | return ( 41 | 42 | <> 43 | {addonUpdateAvailable && ( 44 | 45 | Version {latestAddonVersion} is now available !{' '} 46 | 49 | Changelog 50 | 51 | . 52 | 53 | )} 54 | 55 | 56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/panel/components/PanelContent.tsx: -------------------------------------------------------------------------------- 1 | import { ActionBar, ScrollArea } from 'storybook/internal/components'; 2 | import { styled, StyledComponent } from 'storybook/theming'; 3 | import React, { Fragment, PropsWithChildren, ReactNode } from 'react'; 4 | import { EVENTS } from '../../../constants'; 5 | import { FCC } from '../../../fixes'; 6 | import { RouterEvent, RouterEvents } from '../types'; 7 | import { InspectorContainer } from './InspectorContainer'; 8 | import { RouterEventDisplayWrapper } from './RouterEventDisplayWrapper'; 9 | import { ThemedInspector } from './ThemedInspector'; 10 | 11 | export type PanelContentProps = { 12 | routerEvents: Array; 13 | onClear: () => void; 14 | }; 15 | 16 | export type ScrollAreaProps = PropsWithChildren<{ 17 | horizontal?: boolean; 18 | vertical?: boolean; 19 | className?: string; 20 | title: string; 21 | }>; 22 | const PatchedScrollArea = ScrollArea as FCC; 23 | 24 | export const PanelContent: FCC = ({ routerEvents, onClear }) => { 25 | return ( 26 | 27 | 28 | {routerEvents.map((event, i) => { 29 | return ( 30 | 31 | 32 | 47 | 48 | 49 | ); 50 | })} 51 | 52 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | export const humanReadableEventNames: Record = { 59 | [EVENTS.NAVIGATION]: 'Navigate', 60 | [EVENTS.STORY_LOADED]: 'Story rendered', 61 | [EVENTS.ROUTE_MATCHES]: 'New route matches', 62 | [EVENTS.ACTION_INVOKED]: 'Action invoked', 63 | [EVENTS.ACTION_SETTLED]: 'Action settled', 64 | [EVENTS.LOADER_INVOKED]: 'Loader invoked', 65 | [EVENTS.LOADER_SETTLED]: 'Loader settled', 66 | }; 67 | 68 | export const Wrapper: StyledComponent<{ children: ReactNode } & ScrollAreaProps> = styled( 69 | ({ children, title }: ScrollAreaProps) => ( 70 | 71 | {children} 72 | 73 | ) 74 | )({ 75 | margin: 0, 76 | padding: '10px 5px 20px', 77 | }); 78 | Wrapper.displayName = 'Wrapper'; 79 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/panel/components/PanelTitle.tsx: -------------------------------------------------------------------------------- 1 | import { STORY_CHANGED } from 'storybook/internal/core-events'; 2 | import { ManagerContext } from 'storybook/manager-api'; 3 | import React, { useContext, useEffect, useState } from 'react'; 4 | import { EVENTS } from '../../../constants'; 5 | import { useAddonVersions } from '../hooks/useAddonVersions'; 6 | 7 | export function PanelTitle() { 8 | const { api } = useContext(ManagerContext); 9 | const { addonUpdateAvailable } = useAddonVersions(); 10 | 11 | const [badgeCount, setBadgeCount] = useState(0); 12 | const incrementBadgeCount = () => setBadgeCount((previous) => previous + 1); 13 | const clearBadgeCount = () => setBadgeCount(0); 14 | 15 | useEffect(() => { 16 | api.on(STORY_CHANGED, clearBadgeCount); 17 | api.on(EVENTS.ROUTE_MATCHES, incrementBadgeCount); 18 | api.on(EVENTS.NAVIGATION, incrementBadgeCount); 19 | api.on(EVENTS.ACTION_INVOKED, incrementBadgeCount); 20 | api.on(EVENTS.ACTION_SETTLED, incrementBadgeCount); 21 | api.on(EVENTS.LOADER_INVOKED, incrementBadgeCount); 22 | api.on(EVENTS.LOADER_SETTLED, incrementBadgeCount); 23 | api.on(EVENTS.CLEAR, clearBadgeCount); 24 | 25 | return () => { 26 | api.off(STORY_CHANGED, clearBadgeCount); 27 | api.off(EVENTS.ROUTE_MATCHES, incrementBadgeCount); 28 | api.off(EVENTS.NAVIGATION, incrementBadgeCount); 29 | api.off(EVENTS.ACTION_INVOKED, incrementBadgeCount); 30 | api.off(EVENTS.ACTION_SETTLED, incrementBadgeCount); 31 | api.off(EVENTS.LOADER_INVOKED, incrementBadgeCount); 32 | api.off(EVENTS.LOADER_SETTLED, incrementBadgeCount); 33 | api.off(EVENTS.CLEAR, clearBadgeCount); 34 | }; 35 | }, [api]); 36 | 37 | const suffixes: string[] = []; 38 | 39 | if (addonUpdateAvailable) suffixes.push('⚡️'); 40 | if (badgeCount) suffixes.push(`(${badgeCount})`); 41 | 42 | return <>React Router {suffixes.join(' ')}; 43 | } 44 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/panel/components/RouterEventDisplayWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { styled, StyledComponent } from 'storybook/theming'; 2 | import { ReactNode } from 'react'; 3 | 4 | export const RouterEventDisplayWrapper: StyledComponent<{ children: ReactNode }> = styled.div({ 5 | display: 'flex', 6 | padding: 0, 7 | borderLeft: '5px solid transparent', 8 | borderBottom: '1px solid transparent', 9 | transition: 'all 0.1s', 10 | alignItems: 'flex-start', 11 | whiteSpace: 'pre', 12 | }); 13 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/panel/components/ThemedInspector.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Theme } from 'storybook/theming'; 3 | import { withTheme } from 'storybook/theming'; 4 | import { ObjectInspector } from 'react-inspector'; 5 | 6 | interface InspectorProps { 7 | theme: Theme; 8 | sortObjectKeys?: boolean; 9 | showNonenumerable?: boolean; 10 | name: any; 11 | data: any; 12 | depth?: number; 13 | expandPaths?: string | string[]; 14 | } 15 | 16 | export const ThemedInspector = withTheme(({ theme, ...props }: InspectorProps) => ( 17 | 18 | )); 19 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/panel/hooks/useAddonVersions.ts: -------------------------------------------------------------------------------- 1 | import { compareVersions } from 'compare-versions'; 2 | import { useEffect, useState } from 'react'; 3 | 4 | export function useAddonVersions() { 5 | const [currentVersion, setCurrentVersion] = useState(); 6 | const [latestVersion, setLatestVersion] = useState(); 7 | 8 | useEffect(() => { 9 | const abortController = new AbortController(); 10 | fetch(`https://registry.npmjs.org/storybook-addon-remix-react-router/latest`, { signal: abortController.signal }) 11 | .then((b) => b.json()) 12 | .then((json) => setLatestVersion(json.version)) 13 | // eslint-disable-next-line @typescript-eslint/no-empty-function 14 | .catch(() => {}); 15 | 16 | return () => abortController.abort(); 17 | }, []); 18 | 19 | useEffect(() => { 20 | getAddonVersion().then((v) => setCurrentVersion(v)); 21 | }, []); 22 | 23 | const newVersionAvailable = 24 | !latestVersion || !currentVersion ? undefined : compareVersions(latestVersion, currentVersion) === 1; 25 | 26 | return { 27 | currentAddonVersion: currentVersion, 28 | latestAddonVersion: latestVersion, 29 | addonUpdateAvailable: newVersionAvailable, 30 | }; 31 | } 32 | 33 | async function getAddonVersion() { 34 | try { 35 | const packageJson = await import('../../../../package.json', { 36 | with: { 37 | type: 'json', 38 | }, 39 | }); 40 | 41 | return packageJson.version; 42 | } catch (error) { 43 | const packageJson = await import('../../../../package.json', { 44 | assert: { 45 | type: 'json', 46 | }, 47 | }); 48 | 49 | return packageJson.version; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/features/panel/types.ts: -------------------------------------------------------------------------------- 1 | import type { RouteMatch, NavigationType, LoaderFunctionArgs, ActionFunctionArgs } from 'react-router'; 2 | import { useParams } from 'react-router'; 3 | 4 | import { PromiseType } from 'utility-types'; 5 | import { EVENTS } from '../../constants'; 6 | import { getHumanReadableBody } from '../decorator/utils/getHumanReadableBody'; 7 | 8 | export type AddonEvents = typeof EVENTS; 9 | 10 | /////////////////// 11 | // Router Events // 12 | /////////////////// 13 | export type RouterEvents = Omit; 14 | 15 | export type RouterNavigationEventKey = 'NAVIGATION' | 'STORY_LOADED' | 'ROUTE_MATCHES'; 16 | export type RouterDataEventKey = 'ACTION_INVOKED' | 'ACTION_SETTLED' | 'LOADER_INVOKED' | 'LOADER_SETTLED'; 17 | 18 | export type RouterNavigationEventName = RouterEvents[RouterNavigationEventKey]; 19 | export type RouterDataEventName = RouterEvents[RouterDataEventKey]; 20 | 21 | export type RouterNavigationEvents = Pick; 22 | export type RouterDataEvents = Pick; 23 | 24 | export type RouterEventData = RouterLocationEventData & RouterDataEventData; 25 | 26 | export type RouterEvent = { 27 | [Key in keyof RouterEvents]: { 28 | key: string; 29 | type: RouterEvents[Key]; 30 | data: RouterEventData[RouterEvents[Key]]; 31 | }; 32 | }[keyof RouterEvents]; 33 | 34 | export type RouterNavigationEvent = Extract; 35 | export type RouterDataEvent = Extract; 36 | 37 | export type RouteMatchesData = Array<{ path: RouteMatch['route']['path']; params?: RouteMatch['params'] }>; 38 | 39 | export type RouterStoryLoadedEventData = { 40 | url: string; 41 | path: string; 42 | hash: string; 43 | routeParams: ReturnType; 44 | routeState: unknown; 45 | searchParams: Record; 46 | routeMatches: RouteMatchesData; 47 | }; 48 | 49 | export type RouterNavigationEventData = { 50 | url: string; 51 | navigationType: NavigationType; 52 | path: string; 53 | hash: string; 54 | routeParams: ReturnType; 55 | searchParams: Record; 56 | routeState: unknown; 57 | routeMatches: RouteMatchesData; 58 | }; 59 | 60 | export type RouterRouteMatchesEventData = { 61 | matches: RouteMatchesData; 62 | }; 63 | 64 | export type DataEventArgs = { 65 | [EVENTS.ACTION_INVOKED]: ActionFunctionArgs; 66 | [EVENTS.ACTION_SETTLED]: unknown; 67 | [EVENTS.LOADER_INVOKED]: LoaderFunctionArgs; 68 | [EVENTS.LOADER_SETTLED]: unknown; 69 | }; 70 | 71 | export type RequestSummary = { 72 | url: ActionFunctionArgs['request']['url']; 73 | method: ActionFunctionArgs['request']['method']; 74 | body: PromiseType>; 75 | }; 76 | 77 | export type RouterDataEventData = { 78 | [EVENTS.ACTION_INVOKED]: Pick & { 79 | request: RequestSummary; 80 | }; 81 | [EVENTS.ACTION_SETTLED]: DataEventArgs[RouterDataEvents['ACTION_SETTLED']]; 82 | [EVENTS.LOADER_INVOKED]: Pick & { 83 | request: RequestSummary; 84 | }; 85 | [EVENTS.LOADER_SETTLED]: DataEventArgs[RouterDataEvents['LOADER_SETTLED']]; 86 | }; 87 | 88 | export type RouterLocationEventData = { 89 | [EVENTS.NAVIGATION]: RouterNavigationEventData; 90 | [EVENTS.STORY_LOADED]: RouterStoryLoadedEventData; 91 | [EVENTS.ROUTE_MATCHES]: RouterRouteMatchesEventData; 92 | }; 93 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/fixes.d.ts: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from 'react'; 2 | 3 | export type FCC = React.FC>; 4 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/index.ts: -------------------------------------------------------------------------------- 1 | import { withRouter } from './features/decorator/withRouter'; 2 | import { reactRouterParameters } from './features/decorator/utils/routesHelpers/reactRouterParameters'; 3 | import { reactRouterOutlet } from './features/decorator/utils/routesHelpers/reactRouterOutlet'; 4 | import { reactRouterOutlets } from './features/decorator/utils/routesHelpers/reactRouterOutlets'; 5 | import { reactRouterNestedOutlets } from './features/decorator/utils/routesHelpers/reactRouterNestedOutlets'; 6 | import { reactRouterNestedAncestors } from './features/decorator/utils/routesHelpers/reactRouterNestedAncestors'; 7 | import { castRouterRoute } from './features/decorator/utils/castRouterRoute'; 8 | 9 | import type { ReactRouterAddonStoryParameters } from './features/decorator/components/ReactRouterDecorator'; 10 | import type { 11 | RouteDefinition, 12 | NonIndexRouteDefinition, 13 | NonIndexRouteDefinitionObject, 14 | RouteDefinitionObject, 15 | RouterRoute, 16 | RoutingHelper, 17 | } from './features/decorator/types'; 18 | 19 | export { 20 | withRouter, 21 | reactRouterParameters, 22 | reactRouterOutlet, 23 | reactRouterOutlets, 24 | reactRouterNestedOutlets, 25 | reactRouterNestedAncestors, 26 | castRouterRoute, 27 | }; 28 | 29 | export type { 30 | RouterRoute, 31 | ReactRouterAddonStoryParameters, 32 | RouteDefinition, 33 | NonIndexRouteDefinition, 34 | NonIndexRouteDefinitionObject, 35 | RouteDefinitionObject, 36 | RoutingHelper, 37 | }; 38 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/internals.ts: -------------------------------------------------------------------------------- 1 | export { EVENTS } from './constants'; 2 | 3 | export { injectStory } from './features/decorator/utils/injectStory'; 4 | export { isValidReactNode } from './features/decorator/utils/isValidReactNode'; 5 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/manager.tsx: -------------------------------------------------------------------------------- 1 | import { addons, types } from 'storybook/manager-api'; 2 | import React from 'react'; 3 | 4 | import { ADDON_ID, PANEL_ID, PARAM_KEY } from './constants'; 5 | import { Panel } from './features/panel/components/Panel'; 6 | import { PanelTitle } from './features/panel/components/PanelTitle'; 7 | 8 | addons.register(ADDON_ID, (api) => { 9 | addons.add(PANEL_ID, { 10 | type: types.PANEL, 11 | paramKey: PARAM_KEY, 12 | title: , 13 | match: ({ viewMode }) => viewMode === 'story', 14 | render: ({ active }) => , 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import { vi, beforeEach } from 'vitest'; 2 | import '@testing-library/jest-dom'; 3 | 4 | import { fetch, Request, Response } from '@remix-run/web-fetch'; 5 | import { mockLocalStorage } from './utils/test-utils'; 6 | 7 | beforeEach(() => { 8 | mockLocalStorage(); 9 | vi.stubGlobal('fetch', fetch); 10 | vi.stubGlobal('Request', Request); 11 | vi.stubGlobal('Response', Response); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/types/type-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest'; 2 | import { expectTypeOf } from 'expect-type'; 3 | import { RouteParamsFromPath } from '../features/decorator/types'; 4 | 5 | describe('Types - Utils', () => { 6 | describe('RouteParamsFromPath', () => { 7 | test('returns an empty object when no param is expected', () => { 8 | expectTypeOf>().toEqualTypeOf(); 9 | expectTypeOf>().toEqualTypeOf(); 10 | }); 11 | 12 | test('returns an object with the expected route params', () => { 13 | expectTypeOf>().toEqualTypeOf<{ userId: string }>(); 14 | expectTypeOf>().toEqualTypeOf<{ dashboard: string }>(); 15 | expectTypeOf>().toEqualTypeOf<{ 16 | userId: string; 17 | tabId: string; 18 | }>(); 19 | expectTypeOf>().toEqualTypeOf<{ userId: string }>(); 20 | expectTypeOf>().toEqualTypeOf<{ 21 | userId: string; 22 | tabId: string; 23 | }>(); 24 | 25 | expectTypeOf>().toEqualTypeOf<{ userId: string }>(); 26 | expectTypeOf>().toEqualTypeOf<{ userId: string }>(); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/utils/misc.spec.ts: -------------------------------------------------------------------------------- 1 | import { expectTypeOf } from 'expect-type'; 2 | import { describe, expect, it } from 'vitest'; 3 | import { castArray } from './misc'; 4 | 5 | describe('Utils - Misc', () => { 6 | describe('castArray', () => { 7 | it('should cast a non-array `undefined` to an array containing a single value, `undefined`', () => { 8 | expect(castArray(undefined)).toEqual([undefined]); 9 | }); 10 | 11 | it('should return an empty array if called with no argument', () => { 12 | expect(castArray()).toEqual([]); 13 | }); 14 | 15 | it('should cast a non-array value to an array containing only the value', () => { 16 | expect(castArray('a')).toEqual(['a']); 17 | }); 18 | 19 | it('should return the same object ref is the value is already an array', () => { 20 | const arr = ['a', 'b']; 21 | expect(castArray(arr)).toBe(arr); 22 | }); 23 | 24 | it('should preserve the type of a non-array value', () => { 25 | expectTypeOf(castArray('a')).toEqualTypeOf<[string]>(); 26 | expectTypeOf(castArray('a' as const)).toEqualTypeOf<['a']>(); 27 | }); 28 | 29 | it('should preserve the type of the element of an array value', () => { 30 | expectTypeOf(castArray(['a', 'b'])).toEqualTypeOf(); 31 | expectTypeOf(castArray([777, 'a', 'b'])).toEqualTypeOf>(); 32 | expectTypeOf(castArray(['a', 'b'] as const)).toEqualTypeOf(); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import { AssertKey, ToArray } from './type-utils'; 2 | 3 | export type Deferred = { 4 | promise: Promise; 5 | resolve: (value: T | PromiseLike) => void; 6 | reject: (reason?: unknown) => void; 7 | }; 8 | 9 | export function defer(): Deferred { 10 | const deferred: Partial> = {}; 11 | 12 | deferred.promise = new Promise((resolve, reject) => { 13 | deferred.resolve = resolve; 14 | deferred.reject = reject; 15 | }); 16 | 17 | return deferred as Deferred; 18 | } 19 | 20 | export function hasOwnProperty(obj: T, prop: K): obj is AssertKey { 21 | return Object.prototype.hasOwnProperty.call(obj, prop); 22 | } 23 | 24 | export function invariant(value: boolean, message?: string): asserts value; 25 | export function invariant(value: T | null | undefined, message?: string): asserts value is T; 26 | export function invariant(value: any, message?: string) { 27 | if (value === false || value === null || typeof value === 'undefined') { 28 | console.warn('Test invariant failed:', message); 29 | throw new Error(message); 30 | } 31 | } 32 | 33 | export function castArray(): []; 34 | export function castArray(value: T): ToArray; 35 | export function castArray(value?: T) { 36 | if (arguments.length === 0) { 37 | return []; 38 | } 39 | 40 | return (Array.isArray(value) ? value : [value]) as ToArray; 41 | } 42 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/utils/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | type Vi = typeof vi; 4 | 5 | export class LocalStorage { 6 | store!: Record; 7 | 8 | constructor(vi: Vi) { 9 | Object.defineProperty(this, 'store', { 10 | enumerable: false, 11 | writable: true, 12 | value: {}, 13 | }); 14 | Object.defineProperty(this, 'getItem', { 15 | enumerable: false, 16 | value: vi.fn((key: string) => (this.store[key] !== undefined ? this.store[key] : null)), 17 | }); 18 | Object.defineProperty(this, 'setItem', { 19 | enumerable: false, 20 | // not mentioned in the spec, but we must always coerce to a string 21 | value: vi.fn((key: string, val = '') => { 22 | this.store[key] = val + ''; 23 | }), 24 | }); 25 | Object.defineProperty(this, 'removeItem', { 26 | enumerable: false, 27 | value: vi.fn((key: string) => { 28 | delete this.store[key]; 29 | }), 30 | }); 31 | Object.defineProperty(this, 'clear', { 32 | enumerable: false, 33 | value: vi.fn(() => { 34 | Object.keys(this.store).map((key: string) => delete this.store[key]); 35 | }), 36 | }); 37 | Object.defineProperty(this, 'toString', { 38 | enumerable: false, 39 | value: vi.fn(() => { 40 | return '[object Storage]'; 41 | }), 42 | }); 43 | Object.defineProperty(this, 'key', { 44 | enumerable: false, 45 | value: vi.fn((idx) => Object.keys(this.store)[idx] || null), 46 | }); 47 | } // end constructor 48 | 49 | get length() { 50 | return Object.keys(this.store).length; 51 | } 52 | // for backwards compatibility 53 | get __STORE__() { 54 | return this.store; 55 | } 56 | } 57 | 58 | export function mockLocalStorage(): void { 59 | if (!(window.localStorage instanceof LocalStorage)) { 60 | vi.stubGlobal('localStorage', new LocalStorage(vi)); 61 | vi.stubGlobal('sessionStorage', new LocalStorage(vi)); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/src/utils/type-utils.ts: -------------------------------------------------------------------------------- 1 | export type RequireOne = Exclude< 2 | { 3 | [K in keyof T]: K extends Key ? Omit & Required> : never; 4 | }[keyof T], 5 | undefined 6 | >; 7 | 8 | export type If = T extends true ? Then : Else; 9 | export type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void 10 | ? I 11 | : never; 12 | export type IsUnion = [T] extends [UnionToIntersection] ? false : true; 13 | 14 | export type Merge = { 15 | [K in keyof T]: Deep extends true ? (T extends object ? Merge : T[K]) : T[K]; 16 | // eslint-disable-next-line @typescript-eslint/ban-types 17 | } & {}; 18 | 19 | export type ToArray = T extends ReadonlyArray ? T : [T]; 20 | 21 | export type AssertKey = IsUnion extends true 22 | ? Extract extends Extract 23 | ? T & Record 24 | : T 25 | : T & Record; 26 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": ".", 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "incremental": false, 9 | "isolatedModules": true, 10 | "resolveJsonModule": true, 11 | "jsx": "react", 12 | "lib": ["es2020", "dom", "dom.iterable"], 13 | "module": "esnext", 14 | "moduleResolution": "bundler", 15 | "noImplicitAny": true, 16 | "noUncheckedIndexedAccess": false, 17 | "rootDir": "./src", 18 | "skipLibCheck": true, 19 | "target": "esnext" 20 | }, 21 | "include": ["./src/**/*"] 22 | } 23 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig((options) => ({ 4 | entry: ['src/index.ts', 'src/manager.tsx', 'src/internals.ts'], 5 | splitting: false, 6 | minify: !options.watch, 7 | format: ['cjs', 'esm'], 8 | dts: { 9 | resolve: true, 10 | }, 11 | treeshake: true, 12 | sourcemap: true, 13 | clean: true, 14 | platform: 'browser', 15 | esbuildOptions(options) { 16 | options.conditions = ['module']; 17 | options.external = ['./package.json']; 18 | }, 19 | })); 20 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | }); 7 | -------------------------------------------------------------------------------- /packages/storybook-addon-remix-react-router/vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineProject } from 'vitest/config'; 2 | 3 | export default defineProject({ 4 | test: { 5 | globals: true, 6 | environment: 'jsdom', 7 | restoreMocks: true, 8 | unstubEnvs: true, 9 | unstubGlobals: true, 10 | setupFiles: ['./src/setupTests.ts'], 11 | include: ['./src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 12 | threads: true, 13 | testTimeout: 20000, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "packages/*" 3 | - "tests/*" -------------------------------------------------------------------------------- /tests/reactRouterV7/.storybook/main.js: -------------------------------------------------------------------------------- 1 | /** @type { import('@storybook/react-vite').StorybookConfig } */ 2 | const config = { 3 | stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 4 | addons: ['storybook-addon-remix-react-router'], 5 | framework: { 6 | name: '@storybook/react-vite', 7 | options: {}, 8 | }, 9 | }; 10 | export default config; 11 | -------------------------------------------------------------------------------- /tests/reactRouterV7/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { initialize, mswLoader } from 'msw-storybook-addon'; 2 | 3 | // initialize(); 4 | 5 | /** @type { import('@storybook/react').Preview } */ 6 | const preview = { 7 | parameters: { 8 | controls: { 9 | matchers: { 10 | color: /(background|color)$/i, 11 | date: /Date$/i, 12 | }, 13 | }, 14 | }, 15 | // loaders: [mswLoader], 16 | }; 17 | 18 | export default preview; 19 | -------------------------------------------------------------------------------- /tests/reactRouterV7/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-reactrouterv7", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "type": "module", 6 | "scripts": { 7 | "preinstall": "npx only-allow pnpm", 8 | "test": "vitest --run", 9 | "storybook": "storybook dev -p 6006", 10 | "build-storybook": "storybook build" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "description": "", 15 | "devDependencies": { 16 | "@remix-run/router": "^1.3.3", 17 | "@remix-run/web-fetch": "^4.3.2", 18 | "@storybook/react-vite": "9.0.0-rc.1", 19 | "@testing-library/dom": "^10.4.0", 20 | "@testing-library/jest-dom": "^6.6.3", 21 | "@testing-library/react": "^16.1.0", 22 | "@testing-library/user-event": "^14.5.2", 23 | "@types/react": "^18.3.14", 24 | "@vitejs/plugin-react": "^4.4.1", 25 | "happy-dom": "^15.11.7", 26 | "jsdom": "^25.0.1", 27 | "msw": "^2.6.8", 28 | "msw-storybook-addon": "^2.0.4", 29 | "prop-types": "^15.8.1", 30 | "storybook": "9.0.0-rc.1", 31 | "storybook-addon-remix-react-router": "workspace:*", 32 | "typescript": "^5.7.2", 33 | "vite": "^6.0.3", 34 | "vitest": "^3.1.3" 35 | }, 36 | "dependencies": { 37 | "@remix-run/react": "^2.13.0", 38 | "react": "^18.3.1", 39 | "react-dom": "^18.3.1", 40 | "react-router": "^7.0.2" 41 | }, 42 | "msw": { 43 | "workerDirectory": [ 44 | "public" 45 | ] 46 | } 47 | } -------------------------------------------------------------------------------- /tests/reactRouterV7/public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker. 6 | * @see https://github.com/mswjs/msw 7 | * - Please do NOT modify this file. 8 | * - Please do NOT serve this file on production. 9 | */ 10 | 11 | const PACKAGE_VERSION = '2.6.8' 12 | const INTEGRITY_CHECKSUM = '00729d72e3b82faf54ca8b9621dbb96f' 13 | const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') 14 | const activeClientIds = new Set() 15 | 16 | self.addEventListener('install', function () { 17 | self.skipWaiting() 18 | }) 19 | 20 | self.addEventListener('activate', function (event) { 21 | event.waitUntil(self.clients.claim()) 22 | }) 23 | 24 | self.addEventListener('message', async function (event) { 25 | const clientId = event.source.id 26 | 27 | if (!clientId || !self.clients) { 28 | return 29 | } 30 | 31 | const client = await self.clients.get(clientId) 32 | 33 | if (!client) { 34 | return 35 | } 36 | 37 | const allClients = await self.clients.matchAll({ 38 | type: 'window', 39 | }) 40 | 41 | switch (event.data) { 42 | case 'KEEPALIVE_REQUEST': { 43 | sendToClient(client, { 44 | type: 'KEEPALIVE_RESPONSE', 45 | }) 46 | break 47 | } 48 | 49 | case 'INTEGRITY_CHECK_REQUEST': { 50 | sendToClient(client, { 51 | type: 'INTEGRITY_CHECK_RESPONSE', 52 | payload: { 53 | packageVersion: PACKAGE_VERSION, 54 | checksum: INTEGRITY_CHECKSUM, 55 | }, 56 | }) 57 | break 58 | } 59 | 60 | case 'MOCK_ACTIVATE': { 61 | activeClientIds.add(clientId) 62 | 63 | sendToClient(client, { 64 | type: 'MOCKING_ENABLED', 65 | payload: { 66 | client: { 67 | id: client.id, 68 | frameType: client.frameType, 69 | }, 70 | }, 71 | }) 72 | break 73 | } 74 | 75 | case 'MOCK_DEACTIVATE': { 76 | activeClientIds.delete(clientId) 77 | break 78 | } 79 | 80 | case 'CLIENT_CLOSED': { 81 | activeClientIds.delete(clientId) 82 | 83 | const remainingClients = allClients.filter((client) => { 84 | return client.id !== clientId 85 | }) 86 | 87 | // Unregister itself when there are no more clients 88 | if (remainingClients.length === 0) { 89 | self.registration.unregister() 90 | } 91 | 92 | break 93 | } 94 | } 95 | }) 96 | 97 | self.addEventListener('fetch', function (event) { 98 | const { request } = event 99 | 100 | // Bypass navigation requests. 101 | if (request.mode === 'navigate') { 102 | return 103 | } 104 | 105 | // Opening the DevTools triggers the "only-if-cached" request 106 | // that cannot be handled by the worker. Bypass such requests. 107 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 108 | return 109 | } 110 | 111 | // Bypass all requests when there are no active clients. 112 | // Prevents the self-unregistered worked from handling requests 113 | // after it's been deleted (still remains active until the next reload). 114 | if (activeClientIds.size === 0) { 115 | return 116 | } 117 | 118 | // Generate unique request ID. 119 | const requestId = crypto.randomUUID() 120 | event.respondWith(handleRequest(event, requestId)) 121 | }) 122 | 123 | async function handleRequest(event, requestId) { 124 | const client = await resolveMainClient(event) 125 | const response = await getResponse(event, client, requestId) 126 | 127 | // Send back the response clone for the "response:*" life-cycle events. 128 | // Ensure MSW is active and ready to handle the message, otherwise 129 | // this message will pend indefinitely. 130 | if (client && activeClientIds.has(client.id)) { 131 | ;(async function () { 132 | const responseClone = response.clone() 133 | 134 | sendToClient( 135 | client, 136 | { 137 | type: 'RESPONSE', 138 | payload: { 139 | requestId, 140 | isMockedResponse: IS_MOCKED_RESPONSE in response, 141 | type: responseClone.type, 142 | status: responseClone.status, 143 | statusText: responseClone.statusText, 144 | body: responseClone.body, 145 | headers: Object.fromEntries(responseClone.headers.entries()), 146 | }, 147 | }, 148 | [responseClone.body], 149 | ) 150 | })() 151 | } 152 | 153 | return response 154 | } 155 | 156 | // Resolve the main client for the given event. 157 | // Client that issues a request doesn't necessarily equal the client 158 | // that registered the worker. It's with the latter the worker should 159 | // communicate with during the response resolving phase. 160 | async function resolveMainClient(event) { 161 | const client = await self.clients.get(event.clientId) 162 | 163 | if (activeClientIds.has(event.clientId)) { 164 | return client 165 | } 166 | 167 | if (client?.frameType === 'top-level') { 168 | return client 169 | } 170 | 171 | const allClients = await self.clients.matchAll({ 172 | type: 'window', 173 | }) 174 | 175 | return allClients 176 | .filter((client) => { 177 | // Get only those clients that are currently visible. 178 | return client.visibilityState === 'visible' 179 | }) 180 | .find((client) => { 181 | // Find the client ID that's recorded in the 182 | // set of clients that have registered the worker. 183 | return activeClientIds.has(client.id) 184 | }) 185 | } 186 | 187 | async function getResponse(event, client, requestId) { 188 | const { request } = event 189 | 190 | // Clone the request because it might've been already used 191 | // (i.e. its body has been read and sent to the client). 192 | const requestClone = request.clone() 193 | 194 | function passthrough() { 195 | // Cast the request headers to a new Headers instance 196 | // so the headers can be manipulated with. 197 | const headers = new Headers(requestClone.headers) 198 | 199 | // Remove the "accept" header value that marked this request as passthrough. 200 | // This prevents request alteration and also keeps it compliant with the 201 | // user-defined CORS policies. 202 | const acceptHeader = headers.get('accept') 203 | if (acceptHeader) { 204 | const values = acceptHeader.split(',').map((value) => value.trim()) 205 | const filteredValues = values.filter( 206 | (value) => value !== 'msw/passthrough', 207 | ) 208 | 209 | if (filteredValues.length > 0) { 210 | headers.set('accept', filteredValues.join(', ')) 211 | } else { 212 | headers.delete('accept') 213 | } 214 | } 215 | 216 | return fetch(requestClone, { headers }) 217 | } 218 | 219 | // Bypass mocking when the client is not active. 220 | if (!client) { 221 | return passthrough() 222 | } 223 | 224 | // Bypass initial page load requests (i.e. static assets). 225 | // The absence of the immediate/parent client in the map of the active clients 226 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 227 | // and is not ready to handle requests. 228 | if (!activeClientIds.has(client.id)) { 229 | return passthrough() 230 | } 231 | 232 | // Notify the client that a request has been intercepted. 233 | const requestBuffer = await request.arrayBuffer() 234 | const clientMessage = await sendToClient( 235 | client, 236 | { 237 | type: 'REQUEST', 238 | payload: { 239 | id: requestId, 240 | url: request.url, 241 | mode: request.mode, 242 | method: request.method, 243 | headers: Object.fromEntries(request.headers.entries()), 244 | cache: request.cache, 245 | credentials: request.credentials, 246 | destination: request.destination, 247 | integrity: request.integrity, 248 | redirect: request.redirect, 249 | referrer: request.referrer, 250 | referrerPolicy: request.referrerPolicy, 251 | body: requestBuffer, 252 | keepalive: request.keepalive, 253 | }, 254 | }, 255 | [requestBuffer], 256 | ) 257 | 258 | switch (clientMessage.type) { 259 | case 'MOCK_RESPONSE': { 260 | return respondWithMock(clientMessage.data) 261 | } 262 | 263 | case 'PASSTHROUGH': { 264 | return passthrough() 265 | } 266 | } 267 | 268 | return passthrough() 269 | } 270 | 271 | function sendToClient(client, message, transferrables = []) { 272 | return new Promise((resolve, reject) => { 273 | const channel = new MessageChannel() 274 | 275 | channel.port1.onmessage = (event) => { 276 | if (event.data && event.data.error) { 277 | return reject(event.data.error) 278 | } 279 | 280 | resolve(event.data) 281 | } 282 | 283 | client.postMessage( 284 | message, 285 | [channel.port2].concat(transferrables.filter(Boolean)), 286 | ) 287 | }) 288 | } 289 | 290 | async function respondWithMock(response) { 291 | // Setting response status code to 0 is a no-op. 292 | // However, when responding with a "Response.error()", the produced Response 293 | // instance will have status code set to 0. Since it's not possible to create 294 | // a Response instance with status code 0, handle that use-case separately. 295 | if (response.status === 0) { 296 | return Response.error() 297 | } 298 | 299 | const mockedResponse = new Response(response.body, response) 300 | 301 | Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { 302 | value: true, 303 | enumerable: true, 304 | }) 305 | 306 | return mockedResponse 307 | } 308 | -------------------------------------------------------------------------------- /tests/reactRouterV7/setupTests.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach } from 'vitest'; 2 | import '@testing-library/jest-dom'; 3 | import { mockLocalStorage } from './test-utils'; 4 | 5 | beforeEach(() => { 6 | mockLocalStorage(); 7 | }); 8 | -------------------------------------------------------------------------------- /tests/reactRouterV7/stories/AdvancedRouting.stories.tsx: -------------------------------------------------------------------------------- 1 | import { StoryObj } from '@storybook/react-vite'; 2 | import React, { useState } from 'react'; 3 | import { Link, NavLink, Outlet, useLocation, useParams } from 'react-router'; 4 | import { 5 | reactRouterNestedAncestors, 6 | reactRouterNestedOutlets, 7 | reactRouterOutlet, 8 | reactRouterOutlets, 9 | reactRouterParameters, 10 | withRouter, 11 | } from 'storybook-addon-remix-react-router'; 12 | 13 | export default { 14 | title: 'v2/AdvancedRouting', 15 | decorators: [withRouter], 16 | }; 17 | 18 | export const RoutingOutlets = { 19 | render: () => { 20 | const location = useLocation(); 21 | return ( 22 |
23 |

Story

24 |

Current URL : {location.pathname}

25 | 26 |

Go to :

27 |
    28 |
  • 29 | Index 30 |
  • 31 |
  • 32 | One 33 |
  • 34 |
  • 35 | Two 36 |
  • 37 |
38 | 39 |
40 | ); 41 | }, 42 | parameters: { 43 | reactRouter: reactRouterParameters({ 44 | routing: reactRouterOutlets([ 45 | { 46 | path: '', 47 | element:

Outlet Index

, 48 | }, 49 | { 50 | path: 'one', 51 | element:

Outlet One

, 52 | }, 53 | { 54 | path: 'two', 55 | element:

Outlet Two

, 56 | }, 57 | ]), 58 | }), 59 | }, 60 | }; 61 | 62 | export const RoutingNestedOutlets = { 63 | render: ({ title }) => ( 64 |
65 |

{title}

66 | 67 |
68 | ), 69 | args: { 70 | title: 'Story', 71 | }, 72 | parameters: { 73 | reactRouter: reactRouterParameters({ 74 | routing: reactRouterNestedOutlets([ 75 | <> 76 |

Outlet level 1

77 | 78 | , 79 | <> 80 |

Outlet level 2

81 | 82 | , 83 | <> 84 |

Outlet level 3

85 | 86 | , 87 | ]), 88 | }), 89 | }, 90 | } satisfies StoryObj<{ title: string }>; 91 | 92 | export const RoutingNestedAncestors = { 93 | render: ({ title }) => { 94 | const [count, setCount] = useState(0); 95 | 96 | return ( 97 |
98 |

{title}

99 | 100 |
{count}
101 |
102 | ); 103 | }, 104 | args: { 105 | title: 'Story', 106 | }, 107 | parameters: { 108 | reactRouter: reactRouterParameters({ 109 | routing: reactRouterNestedAncestors([ 110 | <> 111 |

Ancestor level 1

112 | 113 | , 114 | <> 115 |

Ancestor level 2

116 | 117 | , 118 | <> 119 |

Ancestor level 3

120 | 121 | , 122 | ]), 123 | }), 124 | }, 125 | } satisfies StoryObj<{ title: string }>; 126 | 127 | export const RouteWithChildren = { 128 | render: () => ( 129 |
130 |

List of items

131 |
    132 |
  • 133 | Book 42 134 |
  • 135 |
136 | 137 | 138 |
139 | ), 140 | parameters: { 141 | reactRouter: reactRouterParameters({ 142 | location: { 143 | path: '/book', 144 | }, 145 | routing: { 146 | path: '/book', 147 | useStoryElement: true, 148 | children: [ 149 | { 150 | path: ':bookId', 151 | Component: () => { 152 | const routeParams = useParams(); 153 | return ( 154 |
155 |

Book #{routeParams.bookId}

156 |

Book details

157 |
158 | ); 159 | }, 160 | }, 161 | ], 162 | }, 163 | }), 164 | }, 165 | }; 166 | 167 | export const StoryOutlet = { 168 | render: () => ( 169 |
170 |

List of items

171 |
    172 |
  • 173 | Book 42 174 |
  • 175 |
176 | 177 | 178 |
179 | ), 180 | parameters: { 181 | reactRouter: reactRouterParameters({ 182 | location: { 183 | path: '/books', 184 | }, 185 | routing: reactRouterOutlet( 186 | { 187 | path: '/books', 188 | }, 189 | { 190 | path: ':bookId', 191 | Component: () => { 192 | const routeParams = useParams(); 193 | return ( 194 |
195 |

Book #{routeParams.bookId}

196 |

Book details

197 |
198 | ); 199 | }, 200 | } 201 | ), 202 | }), 203 | }, 204 | }; 205 | -------------------------------------------------------------------------------- /tests/reactRouterV7/stories/Basics.stories.tsx: -------------------------------------------------------------------------------- 1 | import { generatePath } from '@remix-run/router'; 2 | import React, { useState } from 'react'; 3 | import { Link, Outlet, useLocation, useMatches, useParams, useSearchParams } from 'react-router'; 4 | import { reactRouterOutlet, reactRouterParameters, withRouter } from 'storybook-addon-remix-react-router'; 5 | 6 | export default { 7 | title: 'v2/Basics', 8 | decorators: [withRouter], 9 | }; 10 | 11 | export const ZeroConfig = { 12 | render: () =>

Hi

, 13 | }; 14 | 15 | export const PreserveComponentState = { 16 | render: ({ id }: { id: string }) => { 17 | const [count, setCount] = useState(0); 18 | 19 | return ( 20 |
21 |

{id}

22 | 23 |
{count}
24 |
25 | ); 26 | }, 27 | args: { 28 | id: '42', 29 | }, 30 | }; 31 | 32 | export const LocationPath = { 33 | render: () => { 34 | const location = useLocation(); 35 | return

{location.pathname}

; 36 | }, 37 | parameters: { 38 | reactRouter: reactRouterParameters({ 39 | location: { 40 | path: '/books', 41 | }, 42 | routing: { path: '/books' }, 43 | }), 44 | }, 45 | }; 46 | 47 | export const DefaultLocation = { 48 | render: () => { 49 | const location = useLocation(); 50 | return

{location.pathname}

; 51 | }, 52 | parameters: { 53 | reactRouter: reactRouterParameters({ 54 | location: { 55 | pathParams: { bookId: '42' }, 56 | }, 57 | routing: { path: '/books/:bookId' }, 58 | }), 59 | }, 60 | }; 61 | 62 | export const LocationPathFromFunctionStringResult = { 63 | render: () => { 64 | const location = useLocation(); 65 | return

{location.pathname}

; 66 | }, 67 | parameters: { 68 | reactRouter: reactRouterParameters({ 69 | location: { 70 | path: (inferredPath, pathParams) => { 71 | return generatePath(inferredPath, pathParams); 72 | }, 73 | pathParams: { bookId: 777 }, 74 | }, 75 | routing: { path: '/books/:bookId' }, 76 | }), 77 | }, 78 | }; 79 | 80 | export const LocationPathFromFunctionUndefinedResult = { 81 | render: () => { 82 | const location = useLocation(); 83 | return

{location.pathname}

; 84 | }, 85 | parameters: { 86 | reactRouter: reactRouterParameters({ 87 | location: { 88 | path: () => undefined, 89 | }, 90 | routing: { path: '/books' }, 91 | }), 92 | }, 93 | }; 94 | 95 | export const LocationPathBestGuess = { 96 | render: () => ( 97 |
98 |

Story

99 | The outlet should be shown below. 100 | 101 |
102 | ), 103 | parameters: { 104 | reactRouter: reactRouterParameters({ 105 | routing: reactRouterOutlet( 106 | { 107 | path: 'books', 108 | }, 109 | { 110 | path: 'summary', 111 | element:

I'm the outlet

, 112 | } 113 | ), 114 | }), 115 | }, 116 | }; 117 | 118 | export const LocationPathParams = { 119 | render: () => { 120 | const routeParams = useParams(); 121 | return

{JSON.stringify(routeParams)}

; 122 | }, 123 | parameters: { 124 | reactRouter: reactRouterParameters({ 125 | location: { 126 | path: '/book/:bookId', 127 | pathParams: { 128 | bookId: '42', 129 | }, 130 | }, 131 | routing: { 132 | path: '/book/:bookId', 133 | }, 134 | }), 135 | }, 136 | }; 137 | 138 | export const LocationSearchParams = { 139 | render: () => { 140 | const [searchParams] = useSearchParams(); 141 | return

{JSON.stringify(Object.fromEntries(searchParams.entries()))}

; 142 | }, 143 | parameters: { 144 | reactRouter: reactRouterParameters({ 145 | location: { 146 | searchParams: { page: '42' }, 147 | }, 148 | }), 149 | }, 150 | }; 151 | 152 | export const LocationHash = { 153 | render: () => { 154 | const location = useLocation(); 155 | return

{location.hash}

; 156 | }, 157 | parameters: { 158 | reactRouter: reactRouterParameters({ 159 | location: { 160 | hash: 'section-title', 161 | }, 162 | }), 163 | }, 164 | }; 165 | 166 | export const LocationState = { 167 | render: () => { 168 | const location = useLocation(); 169 | return

{location.state}

; 170 | }, 171 | parameters: { 172 | reactRouter: reactRouterParameters({ 173 | location: { 174 | state: 'location state', 175 | }, 176 | }), 177 | }, 178 | }; 179 | 180 | export const RouteId = { 181 | render: () => { 182 | const matches = useMatches(); 183 | return

{JSON.stringify(matches.map((m) => m.id))}

; 184 | }, 185 | parameters: { 186 | reactRouter: reactRouterParameters({ 187 | routing: { 188 | id: 'SomeRouteId', 189 | }, 190 | }), 191 | }, 192 | }; 193 | 194 | export const RoutingString = { 195 | render: () => { 196 | const location = useLocation(); 197 | return

{location.pathname}

; 198 | }, 199 | parameters: { 200 | reactRouter: reactRouterParameters({ 201 | routing: '/books', 202 | }), 203 | }, 204 | }; 205 | 206 | export const RoutingHandles = { 207 | render: () => { 208 | const matches = useMatches(); 209 | return

{JSON.stringify(matches.map((m) => m.handle))}

; 210 | }, 211 | parameters: { 212 | reactRouter: reactRouterParameters({ 213 | routing: reactRouterOutlet( 214 | { handle: 'Handle part 1 out of 2' }, 215 | { 216 | handle: 'Handle part 2 out of 2', 217 | element:

I'm the outlet.

, 218 | } 219 | ), 220 | }), 221 | }, 222 | }; 223 | 224 | export const RoutingOutletJSX = { 225 | render: () => , 226 | parameters: { 227 | reactRouter: reactRouterParameters({ 228 | routing: reactRouterOutlet(

I'm an outlet defined by a JSX element

), 229 | }), 230 | }, 231 | }; 232 | 233 | export const RoutingOutletConfigObject = { 234 | render: () => , 235 | parameters: { 236 | reactRouter: reactRouterParameters({ 237 | routing: reactRouterOutlet({ 238 | element:

I'm an outlet defined with a config object

, 239 | }), 240 | }), 241 | }, 242 | }; 243 | 244 | export const MultipleStoryInjection = { 245 | render: () => { 246 | const location = useLocation(); 247 | return ( 248 |
249 |

{location.pathname}

250 | Login | Sign Up 251 |
252 | ); 253 | }, 254 | parameters: { 255 | reactRouter: reactRouterParameters({ 256 | location: { 257 | path: '/login', 258 | }, 259 | routing: [ 260 | { path: '/login', useStoryElement: true }, 261 | { path: '/signup', useStoryElement: true }, 262 | ], 263 | }), 264 | }, 265 | }; 266 | -------------------------------------------------------------------------------- /tests/reactRouterV7/stories/DataRouter/Action.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useFetcher } from 'react-router'; 3 | 4 | import { reactRouterParameters, withRouter } from 'storybook-addon-remix-react-router'; 5 | 6 | export default { 7 | title: 'v2/DataRouter/Action', 8 | decorators: [withRouter], 9 | }; 10 | 11 | function TextForm() { 12 | const fetcher = useFetcher(); 13 | 14 | return ( 15 |
16 | 17 | 18 | 19 | 20 |
21 | ); 22 | } 23 | 24 | export const TextFormData = { 25 | render: () => , 26 | parameters: { 27 | reactRouter: reactRouterParameters({ 28 | routing: { action: async () => ({ result: 42 }) }, 29 | }), 30 | }, 31 | }; 32 | 33 | function FileForm() { 34 | const fetcher = useFetcher(); 35 | 36 | return ( 37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | ); 46 | } 47 | 48 | export const FileFormData = { 49 | render: () => , 50 | parameters: { 51 | reactRouter: reactRouterParameters({ 52 | routing: { action: async () => ({ result: 'file saved' }) }, 53 | }), 54 | }, 55 | }; 56 | -------------------------------------------------------------------------------- /tests/reactRouterV7/stories/DataRouter/Complex.stories.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Credits : https://github.com/remix-run/react-router 3 | * reactrouter.com 4 | */ 5 | 6 | import React from 'react'; 7 | import { 8 | ActionFunctionArgs, 9 | Form, 10 | Link, 11 | LoaderFunctionArgs, 12 | Outlet, 13 | useFetcher, 14 | useLoaderData, 15 | useNavigation, 16 | useParams, 17 | } from 'react-router'; 18 | 19 | import { reactRouterOutlet, reactRouterParameters, withRouter } from 'storybook-addon-remix-react-router'; 20 | 21 | export default { 22 | title: 'v2/DataRouter/Complex', 23 | decorators: [withRouter], 24 | }; 25 | 26 | function sleep(n = 500) { 27 | return new Promise((r) => setTimeout(r, n)); 28 | } 29 | 30 | interface Todos { 31 | [key: string]: string; 32 | } 33 | 34 | const TODOS_KEY = 'todos'; 35 | 36 | const uuid = () => Math.random().toString(36).substr(2, 9); 37 | 38 | function saveTodos(todos: Todos): void { 39 | return localStorage.setItem(TODOS_KEY, JSON.stringify(todos)); 40 | } 41 | 42 | function initializeTodos(): Todos { 43 | const todos: Todos = new Array(3) 44 | .fill(null) 45 | .reduce((acc, _, index) => Object.assign(acc, { [uuid()]: `Seeded Todo #${index + 1}` }), {}); 46 | saveTodos(todos); 47 | return todos; 48 | } 49 | 50 | function getTodos(): Todos { 51 | const todosFromStorage = localStorage.getItem(TODOS_KEY); 52 | 53 | if (todosFromStorage === null) { 54 | return initializeTodos(); 55 | } 56 | 57 | return JSON.parse(todosFromStorage); 58 | } 59 | 60 | function addTodo(todo: string): void { 61 | const newTodos = { ...getTodos() }; 62 | newTodos[uuid()] = todo; 63 | saveTodos(newTodos); 64 | } 65 | 66 | function deleteTodo(id: string): void { 67 | const newTodos = { ...getTodos() }; 68 | delete newTodos[id]; 69 | saveTodos(newTodos); 70 | } 71 | 72 | async function todoListAction({ request }: ActionFunctionArgs) { 73 | await sleep(); 74 | 75 | const formData = await request.formData(); 76 | 77 | // Deletion via fetcher 78 | if (formData.get('action') === 'delete') { 79 | const id = formData.get('todoId'); 80 | if (typeof id === 'string') { 81 | deleteTodo(id); 82 | return { ok: true }; 83 | } 84 | } 85 | 86 | // Addition via
87 | const todo = formData.get('todo'); 88 | if (typeof todo === 'string') { 89 | addTodo(todo); 90 | } 91 | 92 | return new Response(null, { 93 | status: 302, 94 | headers: { Location: '/todos' }, 95 | }); 96 | } 97 | 98 | async function todoListLoader(): Promise { 99 | await sleep(100); 100 | return getTodos(); 101 | } 102 | 103 | function TodosList() { 104 | const todos = useLoaderData() as Todos; 105 | const navigation = useNavigation(); 106 | const formRef = React.useRef(null); 107 | 108 | // If we add and then we delete - this will keep isAdding=true until the 109 | // fetcher completes it's revalidation 110 | const [isAdding, setIsAdding] = React.useState(false); 111 | React.useEffect(() => { 112 | if (navigation.formData?.get('action') === 'add') { 113 | setIsAdding(true); 114 | } else if (navigation.state === 'idle') { 115 | setIsAdding(false); 116 | formRef.current?.reset(); 117 | } 118 | }, [navigation]); 119 | 120 | const items = Object.entries(todos).map(([id, todo]) => ( 121 |
  • 122 | 123 |
  • 124 | )); 125 | 126 | return ( 127 | <> 128 |

    Todos

    129 |
      130 |
    • 131 | Click to trigger error 132 |
    • 133 | {items} 134 |
    135 | 136 | 137 | 138 | 141 | 142 | 143 | 144 | ); 145 | } 146 | 147 | interface TodoItemProps { 148 | id: string; 149 | todo: string; 150 | } 151 | 152 | function TodoListItem({ id, todo }: TodoItemProps) { 153 | const fetcher = useFetcher(); 154 | const isDeleting = fetcher.formData != null; 155 | 156 | return ( 157 | <> 158 | {todo} 159 | 160 | 161 | 164 | 165 | 166 | ); 167 | } 168 | 169 | function Todo() { 170 | const params = useParams(); 171 | const todo = useLoaderData() as string; 172 | return ( 173 | <> 174 |

    Todo:

    175 |

    id: {params.id}

    176 |

    title: {todo}

    177 | 178 | ); 179 | } 180 | 181 | async function todoLoader({ params }: LoaderFunctionArgs) { 182 | await sleep(); 183 | const todos = getTodos(); 184 | 185 | if (!params.id) throw new Error('Expected params.id'); 186 | const todo = todos[params.id]; 187 | 188 | if (!todo) throw new Error(`Uh oh, I couldn't find a todo with id "${params.id}"`); 189 | return todo; 190 | } 191 | 192 | export const TodoListScenario = { 193 | render: () => , 194 | parameters: { 195 | reactRouter: reactRouterParameters({ 196 | location: { path: '/todos' }, 197 | routing: reactRouterOutlet( 198 | { 199 | path: '/todos', 200 | loader: todoListLoader, 201 | action: todoListAction, 202 | }, 203 | { 204 | path: ':id', 205 | element: , 206 | loader: todoLoader, 207 | } 208 | ), 209 | }), 210 | }, 211 | }; 212 | -------------------------------------------------------------------------------- /tests/reactRouterV7/stories/DataRouter/Lazy.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useLoaderData } from 'react-router'; 3 | 4 | import { reactRouterParameters, withRouter } from 'storybook-addon-remix-react-router'; 5 | 6 | export default { 7 | title: 'v2/DataRouter/Lazy', 8 | decorators: [withRouter], 9 | }; 10 | 11 | export const LazyRouting = { 12 | render: () => { 13 | const data = useLoaderData() as { count: number }; 14 | return
    Data from lazy loader : {data.count}
    ; 15 | }, 16 | parameters: { 17 | reactRouter: reactRouterParameters({ 18 | routing: { 19 | lazy: async () => { 20 | const { getCount } = await import('./lazy'); 21 | return { loader: getCount }; 22 | }, 23 | }, 24 | }), 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /tests/reactRouterV7/stories/DataRouter/Loader.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, Outlet, useLoaderData, useLocation, useRouteError } from 'react-router'; 3 | 4 | import { reactRouterOutlet, reactRouterParameters, withRouter } from 'storybook-addon-remix-react-router'; 5 | 6 | export default { 7 | title: 'v2/DataRouter/Loader', 8 | decorators: [withRouter], 9 | }; 10 | 11 | function sleep(n = 500) { 12 | return new Promise((r) => setTimeout(r, n)); 13 | } 14 | 15 | function loader(response: unknown) { 16 | return async () => sleep(100).then(() => ({ foo: response })); 17 | } 18 | 19 | function DataLoader() { 20 | const data = useLoaderData() as { foo: string }; 21 | return

    {data.foo}

    ; 22 | } 23 | 24 | export const RouteLoader = { 25 | render: () => , 26 | parameters: { 27 | reactRouter: reactRouterParameters({ 28 | routing: { loader: loader('Data loaded') }, 29 | }), 30 | }, 31 | }; 32 | 33 | function DataLoaderWithOutlet() { 34 | const data = useLoaderData() as { foo: string }; 35 | return ( 36 |
    37 |

    {data.foo}

    38 | 39 |
    40 | ); 41 | } 42 | 43 | function DataLoaderOutlet() { 44 | const data = useLoaderData() as { foo: string }; 45 | return ( 46 |
    47 |

    {data.foo}

    48 |
    49 | ); 50 | } 51 | 52 | export const RouteAndOutletLoader = { 53 | render: () => , 54 | parameters: { 55 | reactRouter: reactRouterParameters({ 56 | routing: reactRouterOutlet( 57 | { 58 | loader: loader('Data loaded'), 59 | }, 60 | { 61 | index: true, 62 | element: , 63 | loader: loader('Outlet data loaded'), 64 | } 65 | ), 66 | }), 67 | }, 68 | }; 69 | 70 | export const RouteShouldNotRevalidate = { 71 | render: () => { 72 | const location = useLocation(); 73 | 74 | return ( 75 |
    76 | {location.search} 77 |
    78 | Add Search Param 79 |
    80 |
    81 | ); 82 | }, 83 | parameters: { 84 | reactRouter: reactRouterParameters({ 85 | routing: { 86 | loader: loader('Should not appear again after search param is added'), 87 | shouldRevalidate: () => false, 88 | }, 89 | }), 90 | }, 91 | }; 92 | 93 | function DataErrorBoundary() { 94 | const error = useRouteError() as Error; 95 | return

    Fancy error component : {error.message}

    ; 96 | } 97 | 98 | async function failingLoader() { 99 | throw new Error('Meh.'); 100 | } 101 | 102 | export const ErrorBoundary = { 103 | render: () => , 104 | parameters: { 105 | reactRouter: reactRouterParameters({ 106 | routing: { 107 | loader: failingLoader, 108 | errorElement: , 109 | }, 110 | }), 111 | }, 112 | }; 113 | -------------------------------------------------------------------------------- /tests/reactRouterV7/stories/DataRouter/lazy.ts: -------------------------------------------------------------------------------- 1 | function sleep(n = 500) { 2 | return new Promise((r) => setTimeout(r, n)); 3 | } 4 | 5 | export async function getCount() { 6 | return sleep(100).then(() => ({ count: 42 })); 7 | } 8 | -------------------------------------------------------------------------------- /tests/reactRouterV7/stories/DescendantRoutes.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, Route, Routes, useParams } from 'react-router'; 3 | 4 | import { reactRouterParameters, withRouter } from 'storybook-addon-remix-react-router'; 5 | 6 | export default { 7 | title: 'v2/DescendantRoutes', 8 | decorators: [withRouter], 9 | }; 10 | 11 | function LibraryComponent() { 12 | return ( 13 |
    14 | 15 | Get back to the Library 16 | 17 |

    Below is the Library {``}

    18 | 19 | } /> 20 | } /> 21 | 22 |
    23 | ); 24 | } 25 | 26 | function CollectionComponent() { 27 | const params = useParams(); 28 | return ( 29 |
    30 |

    Collection: {params.collectionId}

    31 |

    Below is the Collection {``}

    32 | 33 | } /> 34 | } /> 35 | } /> 36 | 37 |
    38 | ); 39 | } 40 | 41 | function LibraryIndexComponent() { 42 | return ( 43 |
    44 |

    Library Index

    45 |
      46 |
    • 47 | Explore collection 13 48 |
    • 49 |
    • 50 | Explore collection 14 51 |
    • 52 |
    53 |
    54 | ); 55 | } 56 | 57 | function CollectionIndexComponent() { 58 | return ( 59 |
    60 |
      61 |
    • 62 | See our partner's book 63 |
    • 64 |
    • 65 | Pick a book at random 66 |
    • 67 |
    68 |
    69 | ); 70 | } 71 | 72 | function BookDetailsComponent() { 73 | const params = useParams(); 74 | const isPartnerBook = params.bookId === undefined; 75 | return ( 76 |
    77 | {isPartnerBook &&

    Our partner book

    } 78 | {!isPartnerBook &&

    Book id: {params.bookId}

    } 79 |

    What a wonderful book !

    80 |
    81 | ); 82 | } 83 | 84 | export const DescendantRoutesOneIndex = { 85 | render: () => , 86 | parameters: { 87 | reactRouter: reactRouterParameters({ 88 | location: { path: '/library' }, 89 | routing: { path: '/library/*' }, 90 | }), 91 | }, 92 | }; 93 | 94 | export const DescendantRoutesOneRouteMatch = { 95 | render: () => , 96 | parameters: { 97 | reactRouter: reactRouterParameters({ 98 | routing: { path: '/library/*' }, 99 | location: { path: '/library/13' }, 100 | }), 101 | }, 102 | }; 103 | 104 | export const DescendantRoutesTwoRouteMatch = { 105 | render: () => , 106 | parameters: { 107 | reactRouter: reactRouterParameters({ 108 | routing: { path: '/library/*' }, 109 | location: { path: '/library/13/777' }, 110 | }), 111 | }, 112 | }; 113 | -------------------------------------------------------------------------------- /tests/reactRouterV7/stories/RemixRunReact/Imports.stories.tsx: -------------------------------------------------------------------------------- 1 | import { generatePath } from '@remix-run/router'; 2 | import React, { useState } from 'react'; 3 | import { Link, Outlet, useLocation, useMatches, useParams, useSearchParams } from '@remix-run/react'; 4 | import { reactRouterOutlet, reactRouterParameters, withRouter } from 'storybook-addon-remix-react-router'; 5 | 6 | export default { 7 | title: 'v2/RemixRunReact/Imports', 8 | decorators: [withRouter], 9 | }; 10 | 11 | export const ZeroConfig = { 12 | render: () =>

    Hi

    , 13 | }; 14 | 15 | export const PreserveComponentState = { 16 | render: ({ id }: { id: string }) => { 17 | const [count, setCount] = useState(0); 18 | 19 | return ( 20 |
    21 |

    {id}

    22 | 23 |
    {count}
    24 |
    25 | ); 26 | }, 27 | args: { 28 | id: '42', 29 | }, 30 | }; 31 | 32 | export const LocationPath = { 33 | render: () => { 34 | const location = useLocation(); 35 | return

    {location.pathname}

    ; 36 | }, 37 | parameters: { 38 | reactRouter: reactRouterParameters({ 39 | location: { 40 | path: '/books', 41 | }, 42 | routing: { path: '/books' }, 43 | }), 44 | }, 45 | }; 46 | 47 | export const DefaultLocation = { 48 | render: () => { 49 | const location = useLocation(); 50 | return

    {location.pathname}

    ; 51 | }, 52 | parameters: { 53 | reactRouter: reactRouterParameters({ 54 | location: { 55 | pathParams: { bookId: '42' }, 56 | }, 57 | routing: { path: '/books/:bookId' }, 58 | }), 59 | }, 60 | }; 61 | 62 | export const LocationPathFromFunctionStringResult = { 63 | render: () => { 64 | const location = useLocation(); 65 | return

    {location.pathname}

    ; 66 | }, 67 | parameters: { 68 | reactRouter: reactRouterParameters({ 69 | location: { 70 | path: (inferredPath, pathParams) => { 71 | return generatePath(inferredPath, pathParams); 72 | }, 73 | pathParams: { bookId: 777 }, 74 | }, 75 | routing: { path: '/books/:bookId' }, 76 | }), 77 | }, 78 | }; 79 | 80 | export const LocationPathFromFunctionUndefinedResult = { 81 | render: () => { 82 | const location = useLocation(); 83 | return

    {location.pathname}

    ; 84 | }, 85 | parameters: { 86 | reactRouter: reactRouterParameters({ 87 | location: { 88 | path: () => undefined, 89 | }, 90 | routing: { path: '/books' }, 91 | }), 92 | }, 93 | }; 94 | 95 | export const LocationPathBestGuess = { 96 | render: () => ( 97 |
    98 |

    Story

    99 | The outlet should be shown below. 100 | 101 |
    102 | ), 103 | parameters: { 104 | reactRouter: reactRouterParameters({ 105 | routing: reactRouterOutlet( 106 | { 107 | path: 'books', 108 | }, 109 | { 110 | path: 'summary', 111 | element:

    I'm the outlet

    , 112 | } 113 | ), 114 | }), 115 | }, 116 | }; 117 | 118 | export const LocationPathParams = { 119 | render: () => { 120 | const routeParams = useParams(); 121 | return

    {JSON.stringify(routeParams)}

    ; 122 | }, 123 | parameters: { 124 | reactRouter: reactRouterParameters({ 125 | location: { 126 | path: '/book/:bookId', 127 | pathParams: { 128 | bookId: '42', 129 | }, 130 | }, 131 | routing: { 132 | path: '/book/:bookId', 133 | }, 134 | }), 135 | }, 136 | }; 137 | 138 | export const LocationSearchParams = { 139 | render: () => { 140 | const [searchParams] = useSearchParams(); 141 | return

    {JSON.stringify(Object.fromEntries(searchParams.entries()))}

    ; 142 | }, 143 | parameters: { 144 | reactRouter: reactRouterParameters({ 145 | location: { 146 | searchParams: { page: '42' }, 147 | }, 148 | }), 149 | }, 150 | }; 151 | 152 | export const LocationHash = { 153 | render: () => { 154 | const location = useLocation(); 155 | return

    {location.hash}

    ; 156 | }, 157 | parameters: { 158 | reactRouter: reactRouterParameters({ 159 | location: { 160 | hash: 'section-title', 161 | }, 162 | }), 163 | }, 164 | }; 165 | 166 | export const LocationState = { 167 | render: () => { 168 | const location = useLocation(); 169 | return

    {location.state}

    ; 170 | }, 171 | parameters: { 172 | reactRouter: reactRouterParameters({ 173 | location: { 174 | state: 'location state', 175 | }, 176 | }), 177 | }, 178 | }; 179 | 180 | export const RouteId = { 181 | render: () => { 182 | const matches = useMatches(); 183 | return

    {JSON.stringify(matches.map((m) => m.id))}

    ; 184 | }, 185 | parameters: { 186 | reactRouter: reactRouterParameters({ 187 | routing: { 188 | id: 'SomeRouteId', 189 | }, 190 | }), 191 | }, 192 | }; 193 | 194 | export const RoutingString = { 195 | render: () => { 196 | const location = useLocation(); 197 | return

    {location.pathname}

    ; 198 | }, 199 | parameters: { 200 | reactRouter: reactRouterParameters({ 201 | routing: '/books', 202 | }), 203 | }, 204 | }; 205 | 206 | export const RoutingHandles = { 207 | render: () => { 208 | const matches = useMatches(); 209 | return

    {JSON.stringify(matches.map((m) => m.handle))}

    ; 210 | }, 211 | parameters: { 212 | reactRouter: reactRouterParameters({ 213 | routing: reactRouterOutlet( 214 | { handle: 'Handle part 1 out of 2' }, 215 | { 216 | handle: 'Handle part 2 out of 2', 217 | element:

    I'm the outlet.

    , 218 | } 219 | ), 220 | }), 221 | }, 222 | }; 223 | 224 | export const RoutingOutletJSX = { 225 | render: () => , 226 | parameters: { 227 | reactRouter: reactRouterParameters({ 228 | routing: reactRouterOutlet(

    I'm an outlet defined by a JSX element

    ), 229 | }), 230 | }, 231 | }; 232 | 233 | export const RoutingOutletConfigObject = { 234 | render: () => , 235 | parameters: { 236 | reactRouter: reactRouterParameters({ 237 | routing: reactRouterOutlet({ 238 | element:

    I'm an outlet defined with a config object

    , 239 | }), 240 | }), 241 | }, 242 | }; 243 | 244 | export const MultipleStoryInjection = { 245 | render: () => { 246 | const location = useLocation(); 247 | return ( 248 |
    249 |

    {location.pathname}

    250 | Login | Sign Up 251 |
    252 | ); 253 | }, 254 | parameters: { 255 | reactRouter: reactRouterParameters({ 256 | location: { 257 | path: '/login', 258 | }, 259 | routing: [ 260 | { path: '/login', useStoryElement: true }, 261 | { path: '/signup', useStoryElement: true }, 262 | ], 263 | }), 264 | }, 265 | }; 266 | -------------------------------------------------------------------------------- /tests/reactRouterV7/stories/v2Stories.spec.tsx: -------------------------------------------------------------------------------- 1 | import { composeStories } from '@storybook/react-vite'; 2 | import { render, screen, waitFor } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import React from 'react'; 5 | 6 | import { describe, expect, it, vi } from 'vitest'; 7 | import { invariant } from '../utils'; 8 | 9 | import * as AdvancedRoutingStories from './AdvancedRouting.stories'; 10 | import * as BasicStories from './Basics.stories'; 11 | import * as ActionStories from './DataRouter/Action.stories'; 12 | import * as ComplexStories from './DataRouter/Complex.stories'; 13 | import * as LazyStories from './DataRouter/Lazy.stories'; 14 | import * as LoaderStories from './DataRouter/Loader.stories'; 15 | import * as DescendantRoutesStories from './DescendantRoutes.stories'; 16 | 17 | describe('StoryRouteTree', () => { 18 | describe('Basics', () => { 19 | const { 20 | ZeroConfig, 21 | PreserveComponentState, 22 | LocationPath, 23 | DefaultLocation, 24 | LocationPathFromFunctionStringResult, 25 | LocationPathFromFunctionUndefinedResult, 26 | LocationPathParams, 27 | LocationPathBestGuess, 28 | LocationSearchParams, 29 | LocationHash, 30 | LocationState, 31 | RouteId, 32 | RoutingString, 33 | RoutingHandles, 34 | RoutingOutletJSX, 35 | RoutingOutletConfigObject, 36 | MultipleStoryInjection, 37 | } = composeStories(BasicStories); 38 | 39 | const { RoutingOutlets, RoutingNestedOutlets, RoutingNestedAncestors } = composeStories(AdvancedRoutingStories); 40 | 41 | it('should render the story with zero config', () => { 42 | render(); 43 | expect(screen.getByRole('heading', { name: 'Hi' })).toBeInTheDocument(); 44 | }); 45 | 46 | it('should preserve the state of the component when its updated locally or when the story args are changed', async () => { 47 | const { rerender } = render(); 48 | 49 | const user = userEvent.setup(); 50 | await user.click(screen.getByRole('button')); 51 | 52 | expect(screen.getByRole('heading', { name: '42' })).toBeInTheDocument(); 53 | expect(screen.getByRole('status')).toHaveTextContent('1'); 54 | 55 | PreserveComponentState.args.id = '43'; 56 | 57 | rerender(); 58 | 59 | expect(screen.getByRole('heading', { name: '43' })).toBeInTheDocument(); 60 | expect(screen.getByRole('status')).toHaveTextContent('1'); 61 | }); 62 | 63 | it('should render component at the specified path', async () => { 64 | render(); 65 | expect(screen.getByText('/books')).toBeInTheDocument(); 66 | }); 67 | 68 | it('should render component at the path inferred from the routing & the route params', async () => { 69 | render(); 70 | expect(screen.getByText('/books/42')).toBeInTheDocument(); 71 | }); 72 | 73 | it('should render component at the path return by the function', async () => { 74 | render(); 75 | expect(screen.getByText('/books/777')).toBeInTheDocument(); 76 | }); 77 | 78 | it('should render component at the inferred path if the function returns undefined', async () => { 79 | render(); 80 | expect(screen.getByText('/books')).toBeInTheDocument(); 81 | }); 82 | 83 | it('should render component with the specified route params', async () => { 84 | render(); 85 | expect(screen.getByText('{"bookId":"42"}')).toBeInTheDocument(); 86 | }); 87 | 88 | it('should guess the location path and render the component tree', () => { 89 | render(); 90 | expect(screen.getByRole('heading', { level: 2, name: "I'm the outlet" })).toBeInTheDocument(); 91 | }); 92 | 93 | it('should render component with the specified search params', async () => { 94 | render(); 95 | expect(screen.getByText('{"page":"42"}')).toBeInTheDocument(); 96 | }); 97 | 98 | it('should render component with the specified hash', async () => { 99 | render(); 100 | expect(screen.getByText('section-title')).toBeInTheDocument(); 101 | }); 102 | 103 | it('should render component with the specified state', async () => { 104 | render(); 105 | expect(screen.getByText('location state')).toBeInTheDocument(); 106 | }); 107 | 108 | it('should render route with the assigned id', () => { 109 | render(); 110 | expect(screen.getByText('["SomeRouteId"]')).toBeInTheDocument(); 111 | }); 112 | 113 | it('should render component with the specified route handle', async () => { 114 | render(); 115 | expect(screen.getByText('/books')).toBeInTheDocument(); 116 | }); 117 | 118 | it('should render component with the specified route handle', async () => { 119 | render(); 120 | expect(screen.getByText('["Handle part 1 out of 2","Handle part 2 out of 2"]')).toBeInTheDocument(); 121 | }); 122 | 123 | it('should render outlet component defined by a JSX element', () => { 124 | render(); 125 | expect(screen.getByRole('heading', { name: "I'm an outlet defined by a JSX element" })).toBeInTheDocument(); 126 | }); 127 | 128 | it('should render outlet component defined with config object', () => { 129 | render(); 130 | expect(screen.getByRole('heading', { name: "I'm an outlet defined with a config object" })).toBeInTheDocument(); 131 | }); 132 | 133 | it('should render the component tree and the matching outlet if many are set', async () => { 134 | render(); 135 | 136 | expect(screen.getByText('Outlet Index')).toBeInTheDocument(); 137 | 138 | const user = userEvent.setup(); 139 | await user.click(screen.getByRole('link', { name: 'One' })); 140 | 141 | expect(screen.getByText('Outlet One')).toBeInTheDocument(); 142 | }); 143 | 144 | it('should render all the nested outlets when there is only one per level ', () => { 145 | render(); 146 | expect(screen.getByText('Story')).toBeInTheDocument(); 147 | expect(screen.getByText('Outlet level 1')).toBeInTheDocument(); 148 | expect(screen.getByText('Outlet level 2')).toBeInTheDocument(); 149 | expect(screen.getByText('Outlet level 3')).toBeInTheDocument(); 150 | }); 151 | 152 | it('should render all the nested ancestors when there is only one per level ', () => { 153 | render(); 154 | expect(screen.getByText('Ancestor level 1')).toBeInTheDocument(); 155 | expect(screen.getByText('Ancestor level 2')).toBeInTheDocument(); 156 | expect(screen.getByText('Ancestor level 3')).toBeInTheDocument(); 157 | expect(screen.getByText('Story')).toBeInTheDocument(); 158 | }); 159 | 160 | it('should render the story for each route with useStoryElement', async () => { 161 | render(); 162 | expect(screen.getByText('/login')).toBeInTheDocument(); 163 | 164 | const user = userEvent.setup(); 165 | await user.click(screen.getByRole('link', { name: 'Sign Up' })); 166 | 167 | expect(screen.getByText('/signup')).toBeInTheDocument(); 168 | }); 169 | }); 170 | 171 | describe('DescendantRoutes', () => { 172 | const { DescendantRoutesOneIndex, DescendantRoutesOneRouteMatch, DescendantRoutesTwoRouteMatch } = 173 | composeStories(DescendantRoutesStories); 174 | 175 | it('should render the index route when on root path', async () => { 176 | render(); 177 | 178 | expect(screen.queryByRole('heading', { name: 'Library Index' })).toBeInTheDocument(); 179 | expect(screen.queryByRole('link', { name: 'Explore collection 13' })).toBeInTheDocument(); 180 | 181 | expect(screen.queryByRole('link', { name: 'Pick a book at random' })).not.toBeInTheDocument(); 182 | expect(screen.queryByRole('heading', { name: 'Collection: 13' })).not.toBeInTheDocument(); 183 | expect(screen.queryByRole('heading', { name: 'Book id: 777' })).not.toBeInTheDocument(); 184 | }); 185 | 186 | it('should navigate appropriately when clicking a link', async () => { 187 | render(); 188 | 189 | const user = userEvent.setup(); 190 | await user.click(screen.getByRole('link', { name: 'Explore collection 13' })); 191 | 192 | expect(screen.queryByRole('heading', { name: 'Library Index' })).not.toBeInTheDocument(); 193 | expect(screen.queryByRole('link', { name: 'Explore collection 13' })).not.toBeInTheDocument(); 194 | 195 | expect(screen.queryByRole('heading', { name: 'Collection: 13' })).toBeInTheDocument(); 196 | expect(screen.queryByRole('link', { name: 'Pick a book at random' })).toBeInTheDocument(); 197 | }); 198 | 199 | it('should render the nested matching route when accessed directly by location pathname', () => { 200 | render(); 201 | 202 | expect(screen.queryByRole('heading', { name: 'Library Index' })).not.toBeInTheDocument(); 203 | expect(screen.queryByRole('link', { name: 'Explore collection 13' })).not.toBeInTheDocument(); 204 | 205 | expect(screen.queryByRole('heading', { name: 'Collection: 13' })).toBeInTheDocument(); 206 | expect(screen.queryByRole('link', { name: 'Pick a book at random' })).toBeInTheDocument(); 207 | }); 208 | 209 | it('should render the deeply nested matching route when accessed directly by location pathname', () => { 210 | render(); 211 | 212 | expect(screen.queryByRole('heading', { name: 'Library Index' })).not.toBeInTheDocument(); 213 | expect(screen.queryByRole('link', { name: 'Explore collection 13' })).not.toBeInTheDocument(); 214 | expect(screen.queryByRole('link', { name: 'Pick a book at random' })).not.toBeInTheDocument(); 215 | 216 | expect(screen.queryByRole('heading', { name: 'Collection: 13' })).toBeInTheDocument(); 217 | expect(screen.queryByRole('heading', { name: 'Book id: 777' })).toBeInTheDocument(); 218 | }); 219 | }); 220 | 221 | describe('Loader', () => { 222 | const { RouteLoader, RouteAndOutletLoader, ErrorBoundary } = composeStories(LoaderStories); 223 | 224 | it('should render component with route loader', async () => { 225 | render(); 226 | await waitFor(() => expect(screen.getByRole('heading', { name: 'Data loaded' })).toBeInTheDocument(), { 227 | timeout: 1000, 228 | }); 229 | }); 230 | 231 | it('should render component with route loader and outlet loader', async () => { 232 | render(); 233 | await waitFor(() => expect(screen.getByRole('heading', { level: 1, name: 'Data loaded' })).toBeInTheDocument(), { 234 | timeout: 1000, 235 | }); 236 | await waitFor( 237 | () => expect(screen.getByRole('heading', { level: 2, name: 'Outlet data loaded' })).toBeInTheDocument(), 238 | { timeout: 1000 } 239 | ); 240 | }); 241 | 242 | it('should render the error boundary if the route loader fails', async () => { 243 | render(); 244 | await waitFor(() => 245 | expect(screen.queryByRole('heading', { name: 'Fancy error component : Meh.', level: 1 })).toBeInTheDocument() 246 | ); 247 | }); 248 | 249 | it('should not revalidate the route data', async () => { 250 | const { RouteShouldNotRevalidate } = composeStories(LoaderStories); 251 | invariant(RouteShouldNotRevalidate.parameters); 252 | 253 | const loader = vi.fn(() => 'Yo'); 254 | 255 | RouteShouldNotRevalidate.parameters.reactRouter.routing.loader = loader; 256 | 257 | render(); 258 | 259 | await waitFor(() => expect(loader).toHaveBeenCalledOnce()); 260 | 261 | const user = userEvent.setup(); 262 | await user.click(screen.getByRole('link')); 263 | 264 | screen.getByText('?foo=bar'); 265 | 266 | expect(loader).toHaveBeenCalledOnce(); 267 | }); 268 | }); 269 | 270 | describe('Action', () => { 271 | const { TextFormData, FileFormData } = composeStories(ActionStories); 272 | 273 | it('should handle route action with text form', async () => { 274 | const action = vi.fn(); 275 | 276 | invariant(TextFormData.parameters); 277 | TextFormData.parameters.reactRouter.routing.action = action; 278 | 279 | render(); 280 | 281 | const user = userEvent.setup(); 282 | await user.click(screen.getByRole('button')); 283 | 284 | expect(action).toHaveBeenCalledOnce(); 285 | 286 | expect(action.mock.lastCall?.[0].request).toBeInstanceOf(Request); 287 | 288 | const formData = await (action.mock.lastCall?.[0].request as Request).formData(); 289 | const pojoFormData = Object.fromEntries(formData.entries()); 290 | 291 | expect(pojoFormData).toEqual({ foo: 'bar' }); 292 | }); 293 | 294 | it('should handle route action with file form', async () => { 295 | const action = vi.fn(async () => ({ result: 'test' })); 296 | 297 | invariant(FileFormData.parameters); 298 | FileFormData.parameters.reactRouter.routing.action = action; 299 | 300 | const file = new File(['hello'], 'hello.txt', { type: 'plain/text' }); 301 | 302 | render(); 303 | 304 | const input = screen.getByLabelText(/file/i) as HTMLInputElement; 305 | 306 | const user = userEvent.setup(); 307 | await user.upload(input, file); 308 | await user.click(screen.getByRole('button')); 309 | 310 | expect(input.files).toHaveLength(1); 311 | expect(input.files?.item(0)).toStrictEqual(file); 312 | 313 | await waitFor(() => expect(action).toHaveBeenCalledOnce(), { timeout: 100 }); 314 | 315 | const lastCall = action.mock.lastCall as unknown as [any]; 316 | expect(lastCall[0].request).toBeInstanceOf(Request); 317 | }); 318 | }); 319 | 320 | describe('Complex', () => { 321 | const { TodoListScenario } = composeStories(ComplexStories); 322 | 323 | it('should render route with actions properly', async () => { 324 | render(); 325 | 326 | await waitFor(() => expect(screen.queryByRole('heading', { level: 1, name: 'Todos' })).toBeInTheDocument(), { 327 | timeout: 1000, 328 | }); 329 | }); 330 | }); 331 | 332 | describe('Lazy', () => { 333 | const { LazyRouting } = composeStories(LazyStories); 334 | 335 | it('should render route with loader properly', async () => { 336 | render(); 337 | 338 | await waitFor(() => expect(screen.queryByText('Data from lazy loader : 42')).toBeInTheDocument(), { 339 | timeout: 1000, 340 | }); 341 | }); 342 | }); 343 | }); 344 | -------------------------------------------------------------------------------- /tests/reactRouterV7/suites/RouterLogger.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { beforeEach, describe, it, vi, expect } from 'vitest'; 3 | import { composeStories } from '@storybook/react-vite'; 4 | import { render, screen, waitFor } from '@testing-library/react'; 5 | import { EVENTS } from 'storybook-addon-remix-react-router/internals'; 6 | import { addons } from 'storybook/preview-api'; 7 | 8 | import { Channel } from 'storybook/internal/channels'; 9 | import type { MockInstance } from 'vitest'; 10 | import userEvent from '@testing-library/user-event'; 11 | 12 | import * as BasicsStories from '../stories/Basics.stories'; 13 | import * as LazyStories from '../stories/DataRouter/Lazy.stories'; 14 | import * as NestingStories from '../stories/DescendantRoutes.stories'; 15 | import * as ActionStories from '../stories/DataRouter/Action.stories'; 16 | import * as LoaderStories from '../stories/DataRouter/Loader.stories'; 17 | 18 | type LocalTestContext = { 19 | emitSpy: MockInstance; 20 | }; 21 | 22 | describe('RouterLogger', () => { 23 | beforeEach((context) => { 24 | const transport = { 25 | setHandler: vi.fn(), 26 | send: vi.fn(), 27 | }; 28 | 29 | const channelMock = new Channel({ transport }); 30 | context.emitSpy = vi.spyOn(channelMock, 'emit'); 31 | 32 | addons.setChannel(channelMock); 33 | }); 34 | 35 | it('should log when the story loads', async (context) => { 36 | const { DescendantRoutesTwoRouteMatch } = composeStories(NestingStories); 37 | 38 | render(); 39 | 40 | await waitFor(() => { 41 | expect(context.emitSpy).toHaveBeenCalledWith(EVENTS.STORY_LOADED, { 42 | type: EVENTS.STORY_LOADED, 43 | key: `${EVENTS.STORY_LOADED}_1`, 44 | data: { 45 | url: '/library/13/777', 46 | path: '/library/13/777', 47 | routeParams: { '*': '13/777' }, 48 | searchParams: {}, 49 | routeMatches: [ 50 | { path: '/library/*', params: { '*': '13/777' } }, 51 | { path: ':collectionId/*', params: { '*': '777', 'collectionId': '13' } }, 52 | { path: ':bookId', params: { '*': '777', 'collectionId': '13', 'bookId': '777' } }, 53 | ], 54 | hash: '', 55 | routeState: null, 56 | }, 57 | }); 58 | }); 59 | }); 60 | 61 | it('should includes a question mark between the pathname and the query string', async (context) => { 62 | const { LocationSearchParams } = composeStories(BasicsStories); 63 | 64 | render(); 65 | 66 | await waitFor(() => { 67 | expect(context.emitSpy).toHaveBeenCalledWith(EVENTS.STORY_LOADED, { 68 | type: EVENTS.STORY_LOADED, 69 | key: `${EVENTS.STORY_LOADED}_1`, 70 | data: { 71 | url: '/?page=42', 72 | path: '/', 73 | routeParams: {}, 74 | searchParams: { page: '42' }, 75 | routeMatches: [{ path: '/' }], 76 | hash: '', 77 | routeState: null, 78 | }, 79 | }); 80 | }); 81 | }); 82 | 83 | it('should includes a sharp character between the pathname and the hash string', async (context) => { 84 | const { LocationHash } = composeStories(BasicsStories); 85 | 86 | render(); 87 | 88 | await waitFor(() => { 89 | expect(context.emitSpy).toHaveBeenCalledWith(EVENTS.STORY_LOADED, { 90 | type: EVENTS.STORY_LOADED, 91 | key: `${EVENTS.STORY_LOADED}_1`, 92 | data: { 93 | url: '/#section-title', 94 | path: '/', 95 | routeParams: {}, 96 | searchParams: {}, 97 | routeMatches: [{ path: '/' }], 98 | hash: 'section-title', 99 | routeState: null, 100 | }, 101 | }); 102 | }); 103 | }); 104 | 105 | it('should log navigation when a link is clicked', async (context) => { 106 | const { DescendantRoutesOneIndex } = composeStories(NestingStories); 107 | 108 | render(); 109 | 110 | const user = userEvent.setup(); 111 | await user.click(screen.getByRole('link', { name: 'Explore collection 13' })); 112 | 113 | await waitFor(() => { 114 | expect(context.emitSpy).toHaveBeenCalledWith(EVENTS.NAVIGATION, { 115 | type: EVENTS.NAVIGATION, 116 | key: expect.stringContaining(EVENTS.NAVIGATION), 117 | data: { 118 | navigationType: 'PUSH', 119 | url: '/library/13', 120 | path: '/library/13', 121 | routeParams: { '*': '13' }, 122 | searchParams: {}, 123 | routeMatches: [ 124 | { path: '/library/*', params: { '*': '' } }, 125 | { path: undefined, params: { '*': '' } }, 126 | ], 127 | hash: '', 128 | routeState: null, 129 | }, 130 | }); 131 | }); 132 | }); 133 | 134 | it('should log new route match when nested Routes is mounted', async (context) => { 135 | const { DescendantRoutesOneIndex } = composeStories(NestingStories); 136 | 137 | render(); 138 | 139 | const user = userEvent.setup(); 140 | await user.click(screen.getByRole('link', { name: 'Explore collection 13' })); 141 | 142 | await waitFor(() => { 143 | expect(context.emitSpy).toHaveBeenCalledWith(EVENTS.ROUTE_MATCHES, { 144 | type: EVENTS.ROUTE_MATCHES, 145 | key: expect.stringContaining(EVENTS.ROUTE_MATCHES), 146 | data: { 147 | matches: [ 148 | { path: '/library/*', params: { '*': '13' } }, 149 | { path: ':collectionId/*', params: { '*': '', 'collectionId': '13' } }, 150 | { path: undefined, params: { '*': '', 'collectionId': '13' } }, 151 | ], 152 | }, 153 | }); 154 | }); 155 | }); 156 | 157 | it('should log data router action when triggered', async (context) => { 158 | const { TextFormData } = composeStories(ActionStories); 159 | render(); 160 | 161 | context.emitSpy.mockClear(); 162 | 163 | const user = userEvent.setup(); 164 | await user.click(screen.getByRole('button')); 165 | 166 | await waitFor(() => { 167 | expect(context.emitSpy).toHaveBeenCalledWith( 168 | EVENTS.ACTION_INVOKED, 169 | expect.objectContaining({ 170 | type: EVENTS.ACTION_INVOKED, 171 | key: expect.stringContaining(EVENTS.ACTION_INVOKED), 172 | data: expect.objectContaining({ 173 | request: { 174 | url: 'http://localhost/', 175 | method: 'POST', 176 | body: { 177 | foo: 'bar', 178 | }, 179 | }, 180 | }), 181 | }) 182 | ); 183 | }); 184 | }); 185 | 186 | // Some internals have changed, leading to a different body format 187 | it.skip('should log file info when route action is triggered', async (context) => { 188 | const { FileFormData } = composeStories(ActionStories); 189 | 190 | render(); 191 | 192 | const file = new File(['hello'], 'hello.txt', { type: 'plain/text' }); 193 | const input = screen.getByLabelText(/file/i) as HTMLInputElement; 194 | 195 | const user = userEvent.setup(); 196 | await user.upload(input, file); 197 | await user.click(screen.getByRole('button')); 198 | 199 | await waitFor(() => { 200 | expect(context.emitSpy).toHaveBeenCalledWith(EVENTS.ACTION_INVOKED, { 201 | type: EVENTS.ACTION_INVOKED, 202 | key: expect.stringContaining(EVENTS.ACTION_INVOKED), 203 | data: expect.objectContaining({ 204 | request: { 205 | url: 'http://localhost/', 206 | method: 'POST', 207 | body: { 208 | myFile: expect.objectContaining({}), 209 | }, 210 | }, 211 | }), 212 | }); 213 | }); 214 | }); 215 | 216 | it('should log when data router action settled', async (context) => { 217 | const { TextFormData } = composeStories(ActionStories); 218 | render(); 219 | 220 | const user = userEvent.setup(); 221 | await user.click(screen.getByRole('button')); 222 | 223 | await waitFor(() => { 224 | expect(context.emitSpy).toHaveBeenCalledWith(EVENTS.ACTION_SETTLED, { 225 | type: EVENTS.ACTION_SETTLED, 226 | key: expect.stringContaining(EVENTS.ACTION_SETTLED), 227 | data: { result: 42 }, 228 | }); 229 | }); 230 | }); 231 | 232 | it('should log data router loader when triggered', async (context) => { 233 | const { RouteAndOutletLoader } = composeStories(LoaderStories); 234 | render(); 235 | 236 | await waitFor(() => { 237 | expect(context.emitSpy).toHaveBeenCalledWith( 238 | EVENTS.LOADER_INVOKED, 239 | expect.objectContaining({ 240 | type: EVENTS.LOADER_INVOKED, 241 | key: expect.stringContaining(EVENTS.LOADER_INVOKED), 242 | data: expect.objectContaining({ 243 | request: expect.anything(), 244 | }), 245 | }) 246 | ); 247 | }); 248 | }); 249 | 250 | it('should log when data router loader settled', async (context) => { 251 | const { RouteAndOutletLoader } = composeStories(LoaderStories); 252 | render(); 253 | 254 | await waitFor(() => { 255 | expect(context.emitSpy).toHaveBeenCalledWith(EVENTS.LOADER_SETTLED, { 256 | type: EVENTS.LOADER_SETTLED, 257 | key: expect.stringContaining(EVENTS.LOADER_SETTLED), 258 | data: { foo: 'Data loaded' }, 259 | }); 260 | }); 261 | 262 | await waitFor(() => { 263 | expect(context.emitSpy).toHaveBeenCalledWith(EVENTS.LOADER_SETTLED, { 264 | type: EVENTS.LOADER_SETTLED, 265 | key: expect.stringContaining(EVENTS.LOADER_SETTLED), 266 | data: { foo: 'Outlet data loaded' }, 267 | }); 268 | }); 269 | }); 270 | 271 | it('should log lazy data router loader when triggered', async (context) => { 272 | const { LazyRouting } = composeStories(LazyStories); 273 | render(); 274 | 275 | await waitFor(() => { 276 | expect(context.emitSpy).toHaveBeenCalledWith(EVENTS.LOADER_INVOKED, { 277 | type: EVENTS.LOADER_INVOKED, 278 | key: expect.stringContaining(EVENTS.LOADER_INVOKED), 279 | data: expect.objectContaining({ 280 | request: expect.anything(), 281 | }), 282 | }); 283 | }); 284 | }); 285 | }); 286 | -------------------------------------------------------------------------------- /tests/reactRouterV7/suites/injectStory.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it, expect } from 'vitest'; 3 | import { injectStory } from 'storybook-addon-remix-react-router/internals'; 4 | import { isValidReactNode } from 'storybook-addon-remix-react-router/internals'; 5 | 6 | describe('injectStory', () => { 7 | it('should return an empty array if routes is an empty array', () => { 8 | const result = injectStory([],

    StoryComponent

    ); 9 | expect(result).toEqual([]); 10 | }); 11 | 12 | it('should return the same routes array if no route has useStoryElement property', () => { 13 | const routes = [ 14 | { path: '/', element:
    }, 15 | { path: '/about', element:
    }, 16 | ]; 17 | const result = injectStory(routes,

    StoryComponent

    ); 18 | expect(result).toEqual(routes); 19 | expect(result).not.toBe(routes); 20 | }); 21 | 22 | it('should return the same route array if no route has children property', () => { 23 | const routes = [ 24 | { path: '/', element:
    }, 25 | { path: '/about', element:
    }, 26 | ]; 27 | const result = injectStory(routes,

    StoryComponent

    ); 28 | expect(result).toEqual(routes); 29 | }); 30 | 31 | it('should inject the story in the story route object', () => { 32 | const routes = [ 33 | { path: '/', element:
    }, 34 | { path: '/about', useStoryElement: true }, 35 | ]; 36 | const StoryElement =

    StoryComponent

    ; 37 | const result = injectStory(routes, StoryElement); 38 | expect(result).toEqual([ 39 | { path: '/', element:
    }, 40 | { path: '/about', useStoryElement: true, element: StoryElement }, 41 | ]); 42 | 43 | expect(isValidReactNode(result[1].element)).toBeTruthy(); 44 | expect(result[1]).not.toBe(routes[1]); 45 | }); 46 | 47 | it('should inject the story into every route with useStoryElement', () => { 48 | const routes = [ 49 | { path: '/login', useStoryElement: true }, 50 | { path: '/signup', useStoryElement: true }, 51 | ]; 52 | const StoryElement =

    StoryComponent

    ; 53 | const result = injectStory(routes, StoryElement); 54 | expect(result).toEqual([ 55 | { path: '/login', useStoryElement: true, element: StoryElement }, 56 | { path: '/signup', useStoryElement: true, element: StoryElement }, 57 | ]); 58 | 59 | expect(isValidReactNode(result[0].element)).toBeTruthy(); 60 | expect(isValidReactNode(result[1].element)).toBeTruthy(); 61 | expect(result[0]).not.toBe(routes[0]); 62 | expect(result[1]).not.toBe(routes[1]); 63 | }); 64 | 65 | it('should inject the story when the story route is deep', () => { 66 | const routes = [ 67 | { 68 | path: '/', 69 | element:
    , 70 | children: [ 71 | { path: '/child1', element:
    }, 72 | { path: '/child2', useStoryElement: true }, 73 | ], 74 | }, 75 | ]; 76 | const result = injectStory(routes,

    StoryComponent

    ); 77 | expect(result).toEqual([ 78 | { 79 | path: '/', 80 | element:
    , 81 | children: [ 82 | { path: '/child1', element:
    }, 83 | expect.objectContaining({ path: '/child2', useStoryElement: true }), 84 | ], 85 | }, 86 | ]); 87 | 88 | expect(isValidReactNode(result[0].children?.[1].element)).toBeTruthy(); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /tests/reactRouterV7/suites/isValidReactNode.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { describe, it, expect } from 'vitest'; 3 | import { isValidReactNode } from 'storybook-addon-remix-react-router/internals'; 4 | 5 | describe('isValidReactNode', () => { 6 | it('should return true when a JSX element is given', () => { 7 | expect(isValidReactNode(
    )).toBe(true); 8 | }); 9 | 10 | it('should return true when `null` is given', () => { 11 | expect(isValidReactNode(null)).toBe(true); 12 | }); 13 | 14 | it('should return true when `undefined` is given', () => { 15 | expect(isValidReactNode(undefined)).toBe(true); 16 | }); 17 | 18 | it('should return true when a string is given', () => { 19 | expect(isValidReactNode('hello')).toBe(true); 20 | }); 21 | 22 | it('should return true when a number is given', () => { 23 | expect(isValidReactNode(42)).toBe(true); 24 | }); 25 | 26 | it('should return true when a React.Fragment is given', () => { 27 | expect(isValidReactNode(<>)).toBe(true); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/reactRouterV7/test-utils.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | type Vi = typeof vi; 4 | 5 | export class LocalStorage { 6 | store!: Record; 7 | 8 | constructor(vi: Vi) { 9 | Object.defineProperty(this, 'store', { 10 | enumerable: false, 11 | writable: true, 12 | value: {}, 13 | }); 14 | Object.defineProperty(this, 'getItem', { 15 | enumerable: false, 16 | value: vi.fn((key: string) => (this.store[key] !== undefined ? this.store[key] : null)), 17 | }); 18 | Object.defineProperty(this, 'setItem', { 19 | enumerable: false, 20 | // not mentioned in the spec, but we must always coerce to a string 21 | value: vi.fn((key: string, val = '') => { 22 | this.store[key] = val + ''; 23 | }), 24 | }); 25 | Object.defineProperty(this, 'removeItem', { 26 | enumerable: false, 27 | value: vi.fn((key: string) => { 28 | delete this.store[key]; 29 | }), 30 | }); 31 | Object.defineProperty(this, 'clear', { 32 | enumerable: false, 33 | value: vi.fn(() => { 34 | Object.keys(this.store).map((key: string) => delete this.store[key]); 35 | }), 36 | }); 37 | Object.defineProperty(this, 'toString', { 38 | enumerable: false, 39 | value: vi.fn(() => { 40 | return '[object Storage]'; 41 | }), 42 | }); 43 | Object.defineProperty(this, 'key', { 44 | enumerable: false, 45 | value: vi.fn((idx) => Object.keys(this.store)[idx] || null), 46 | }); 47 | } // end constructor 48 | 49 | get length() { 50 | return Object.keys(this.store).length; 51 | } 52 | // for backwards compatibility 53 | get __STORE__() { 54 | return this.store; 55 | } 56 | } 57 | 58 | export function mockLocalStorage(): void { 59 | if (!(window.localStorage instanceof LocalStorage)) { 60 | vi.stubGlobal('localStorage', new LocalStorage(vi)); 61 | vi.stubGlobal('sessionStorage', new LocalStorage(vi)); 62 | } 63 | } 64 | 65 | export function sleep(n = 500) { 66 | return new Promise((r) => setTimeout(r, n)); 67 | } -------------------------------------------------------------------------------- /tests/reactRouterV7/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": ".", 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "incremental": false, 9 | "isolatedModules": true, 10 | "resolveJsonModule": true, 11 | "jsx": "react", 12 | "lib": ["es2020", "dom", "dom.iterable"], 13 | "module": "esnext", 14 | "moduleResolution": "bundler", 15 | "noImplicitAny": true, 16 | "noUncheckedIndexedAccess": false, 17 | "skipLibCheck": true, 18 | "target": "esnext" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/reactRouterV7/utils.ts: -------------------------------------------------------------------------------- 1 | export function invariant(value: boolean, message?: string): asserts value; 2 | export function invariant(value: T | null | undefined, message?: string): asserts value is T; 3 | export function invariant(value: any, message?: string) { 4 | if (value === false || value === null || typeof value === 'undefined') { 5 | console.warn('Test invariant failed:', message); 6 | throw new Error(message); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/reactRouterV7/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | }); 7 | -------------------------------------------------------------------------------- /tests/reactRouterV7/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineProject } from 'vitest/config'; 2 | 3 | export default defineProject({ 4 | test: { 5 | globals: true, 6 | environment: 'happy-dom', 7 | restoreMocks: true, 8 | unstubEnvs: true, 9 | unstubGlobals: true, 10 | setupFiles: ['./setupTests.ts'], 11 | testTimeout: 20000, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | workspace: './vitest.workspace.ts', 6 | sequence: { 7 | setupFiles: 'list', 8 | hooks: 'stack', 9 | }, 10 | reporters: ['default', 'html'], 11 | outputFile: { 12 | html: './tests-report/index.html', 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | import { defineWorkspace } from 'vitest/config'; 2 | 3 | export default defineWorkspace(['packages/*/vitest.config.ts', 'tests/*/vitest.config.ts']); 4 | --------------------------------------------------------------------------------