(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 |
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 |
--------------------------------------------------------------------------------