@mpen/rerouter 0.1.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +80 -18
  2. package/cli/bin.test.ts +221 -0
  3. package/cli/bin.ts +342 -0
  4. package/cli/fixtures/bin/kitchen-sink.tsx +15 -0
  5. package/cli/fixtures/bin/optional.tsx +3 -0
  6. package/cli/fixtures/bin/pages/Home.tsx +3 -0
  7. package/cli/fixtures/bin/pages/KitchenSink.tsx +3 -0
  8. package/cli/fixtures/bin/pages/Login.tsx +3 -0
  9. package/cli/fixtures/bin/pages/Match.tsx +3 -0
  10. package/cli/fixtures/bin/pages/NotFound.tsx +3 -0
  11. package/cli/fixtures/bin/pages/Optional.tsx +3 -0
  12. package/cli/fixtures/bin/regexp-groups.tsx +11 -0
  13. package/cli/fixtures/bin/simple.tsx +1 -0
  14. package/cli/fixtures/bin/unnamed.tsx +4 -0
  15. package/cli/tsconfig.json +9 -0
  16. package/dist/acorn-k7ED_tOl.js +4968 -0
  17. package/dist/angular--Iqdw9UJ.js +4057 -0
  18. package/dist/babel-hfWAujRY.js +9878 -0
  19. package/dist/bin.d.ts +29 -0
  20. package/dist/bin.js +233 -0
  21. package/dist/estree-C1Zjnvlw.js +7266 -0
  22. package/dist/flow-BaD9LyIP.js +52912 -0
  23. package/dist/glimmer-CvCjW_1V.js +7541 -0
  24. package/dist/graphql-BdtzBuWh.js +1945 -0
  25. package/dist/html-DkZtUVbo.js +7137 -0
  26. package/dist/index.d.ts +278 -0
  27. package/dist/index.js +247 -0
  28. package/dist/markdown-Z8Vrc69e.js +6876 -0
  29. package/dist/meriyah-DeO4stuH.js +7590 -0
  30. package/dist/postcss-BmgGJ0E5.js +6777 -0
  31. package/dist/prettier-BT_F8kIx.js +15629 -0
  32. package/dist/routes-PW-bNm8e.js +135 -0
  33. package/dist/typescript-DtIxStjy.js +22936 -0
  34. package/dist/yaml-CWOPBY0q.js +5281 -0
  35. package/examples/App.tsx +80 -0
  36. package/examples/dist/BlogPost-c10d9w2p.js +1 -0
  37. package/examples/dist/FetchLoading-534mdrgz.js +1 -0
  38. package/examples/dist/FetchLoading-sbxbdkre.js +1 -0
  39. package/examples/dist/Home-a1258p25.js +1 -0
  40. package/examples/dist/KitchenSink-821mjg0h.js +1 -0
  41. package/examples/dist/Login-wywx6bp7.js +1 -0
  42. package/examples/dist/Match-1e72jm5w.js +1 -0
  43. package/examples/dist/NotFound-smxj24jw.js +1 -0
  44. package/examples/dist/SlowLoading-59xxmbfk.js +1 -0
  45. package/examples/dist/index-0d4kj0rv.js +2 -0
  46. package/examples/dist/index-3x197sbt.js +9 -0
  47. package/examples/dist/index-a2hkfx1n.js +9 -0
  48. package/examples/dist/index-d21me1mc.js +9 -0
  49. package/examples/dist/index-ktqdknsn.js +2 -0
  50. package/examples/dist/index-p53qxxzd.js +2 -0
  51. package/examples/dist/index.html +67 -0
  52. package/examples/index.html +67 -0
  53. package/examples/pages/BlogPost.tsx +17 -0
  54. package/examples/pages/FetchLoading.tsx +53 -0
  55. package/examples/pages/FetchLoadingItem.tsx +45 -0
  56. package/examples/pages/Home.tsx +3 -0
  57. package/examples/pages/KitchenSink.tsx +23 -0
  58. package/examples/pages/Login.tsx +3 -0
  59. package/examples/pages/Match.tsx +5 -0
  60. package/examples/pages/NotFound.tsx +3 -0
  61. package/examples/pages/SlowLoading.tsx +8 -0
  62. package/examples/routes.gen.ts +105 -0
  63. package/examples/routes.ts +40 -0
  64. package/examples/server/serve-dist.ts +33 -0
  65. package/examples/server/tsconfig.json +9 -0
  66. package/package.json +41 -31
  67. package/src/components/Link.test.tsx +139 -0
  68. package/src/components/Link.tsx +89 -0
  69. package/src/components/NavLink.test.tsx +119 -0
  70. package/src/components/NavLink.tsx +71 -0
  71. package/src/components/Router.test.tsx +183 -0
  72. package/src/components/Router.tsx +207 -0
  73. package/src/hooks/index.ts +1 -0
  74. package/src/hooks/useUrl.ts +22 -0
  75. package/src/index.ts +6 -0
  76. package/src/lib/mergeSearch.test.ts +37 -0
  77. package/src/lib/mergeSearch.ts +21 -0
  78. package/src/lib/routes.test.ts +67 -0
  79. package/src/lib/routes.ts +247 -0
  80. package/src/lib/url.ts +9 -0
  81. package/tsconfig.json +10 -0
  82. package/tsdown.config.ts +21 -0
  83. package/LICENSE +0 -21
  84. package/dist/bundle.cjs +0 -422
  85. package/dist/bundle.d.ts +0 -2
  86. package/dist/bundle.mjs +0 -420
  87. package/dist/dev.d.ts +0 -1
  88. package/dist/log.d.ts +0 -1
  89. package/dist/uri-template.d.ts +0 -56
@@ -0,0 +1,139 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { Window } from 'happy-dom'
3
+
4
+ const testWindow = new Window({ url: 'http://localhost/start' })
5
+
6
+ testWindow.SyntaxError = SyntaxError
7
+
8
+ Object.assign(globalThis, {
9
+ window: testWindow,
10
+ document: testWindow.document,
11
+ navigator: testWindow.navigator,
12
+ location: testWindow.location,
13
+ history: testWindow.history,
14
+ Event: testWindow.Event,
15
+ HTMLElement: testWindow.HTMLElement,
16
+ MouseEvent: testWindow.MouseEvent,
17
+ Node: testWindow.Node,
18
+ PopStateEvent: testWindow.PopStateEvent,
19
+ SyntaxError,
20
+ getComputedStyle: testWindow.getComputedStyle.bind(testWindow),
21
+ })
22
+
23
+ const { cleanup, fireEvent, render, screen } = await import('@testing-library/react')
24
+ const { Link } = await import('./Link')
25
+
26
+ describe(Link.name, () => {
27
+ beforeEach(() => {
28
+ window.history.replaceState(null, '', '/start')
29
+ })
30
+
31
+ afterEach(() => {
32
+ cleanup()
33
+ window.history.replaceState(null, '', '/start')
34
+ })
35
+
36
+ test('renders a link with merged search params', () => {
37
+ render(
38
+ <Link
39
+ aria-label="Match details"
40
+ className={['match-link', { selected: true, pending: false }]}
41
+ to="/matches?sort=asc"
42
+ search={{ page: 2, sort: 'desc' }}
43
+ >
44
+ View match
45
+ </Link>,
46
+ )
47
+
48
+ const link = screen.getByRole('link', { name: 'Match details' })
49
+
50
+ expect(link.textContent).toBe('View match')
51
+ expect(link.getAttribute('class')).toBe('match-link selected')
52
+ expect(link.getAttribute('href')).toBe('/matches?sort=desc&page=2')
53
+ })
54
+
55
+ test('pushes the target URL and emits popstate on ordinary clicks', () => {
56
+ let popstateCount = 0
57
+ window.addEventListener(
58
+ 'popstate',
59
+ () => {
60
+ popstateCount += 1
61
+ },
62
+ { once: true },
63
+ )
64
+
65
+ render(<Link to="/matches/42?tab=details">View match</Link>)
66
+
67
+ fireEvent.click(screen.getByRole('link', { name: 'View match' }))
68
+
69
+ expect(window.location.pathname).toBe('/matches/42')
70
+ expect(window.location.search).toBe('?tab=details')
71
+ expect(popstateCount).toBe(1)
72
+ })
73
+
74
+ test('replaces the current URL when replace is set', () => {
75
+ const replaceState = window.history.replaceState.bind(window.history)
76
+ let replacedUrl = ''
77
+ window.history.replaceState = ((data, title, url) => {
78
+ replacedUrl = String(url)
79
+ return replaceState(data, title, url)
80
+ }) as History['replaceState']
81
+
82
+ render(
83
+ <Link replace to="/matches/42">
84
+ Replace match
85
+ </Link>,
86
+ )
87
+
88
+ try {
89
+ fireEvent.click(screen.getByRole('link', { name: 'Replace match' }))
90
+
91
+ expect(replacedUrl).toBe('/matches/42')
92
+ expect(window.location.pathname).toBe('/matches/42')
93
+ } finally {
94
+ window.history.replaceState = replaceState
95
+ }
96
+ })
97
+
98
+ test('leaves modified clicks to the browser', () => {
99
+ let popstateCount = 0
100
+ window.addEventListener(
101
+ 'popstate',
102
+ () => {
103
+ popstateCount += 1
104
+ },
105
+ { once: true },
106
+ )
107
+
108
+ render(<Link to="/matches/42">Open elsewhere</Link>)
109
+
110
+ const defaultWasNotPrevented = fireEvent.click(
111
+ screen.getByRole('link', { name: 'Open elsewhere' }),
112
+ { ctrlKey: true },
113
+ )
114
+
115
+ expect(defaultWasNotPrevented).toBe(true)
116
+ expect(popstateCount).toBe(0)
117
+ })
118
+
119
+ test('leaves non-primary button clicks to the browser', () => {
120
+ let popstateCount = 0
121
+ window.addEventListener(
122
+ 'popstate',
123
+ () => {
124
+ popstateCount += 1
125
+ },
126
+ { once: true },
127
+ )
128
+
129
+ render(<Link to="/matches/42">Open with auxiliary button</Link>)
130
+
131
+ const defaultWasNotPrevented = fireEvent.click(
132
+ screen.getByRole('link', { name: 'Open with auxiliary button' }),
133
+ { button: 1 },
134
+ )
135
+
136
+ expect(defaultWasNotPrevented).toBe(true)
137
+ expect(popstateCount).toBe(0)
138
+ })
139
+ })
@@ -0,0 +1,89 @@
1
+ import { cc, type ClassValue } from '@mpen/classcat'
2
+ import type { OverrideProps } from '@mpen/ts-types/react'
3
+ import { startTransition, type MouseEvent } from 'react'
4
+ import { pushUrl, replaceUrl } from '../lib/url'
5
+ import { mergeSearch } from '../lib/mergeSearch'
6
+
7
+ /**
8
+ * Values accepted by [`Link`]{@link Link} for building query strings.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * <Link to="/matches" search={{ page: 2, sort: 'desc' }}>
13
+ * Matches
14
+ * </Link>
15
+ * ```
16
+ */
17
+ export type SearchParamsInit =
18
+ | string
19
+ | string[][]
20
+ | Record<string, string | number | boolean | undefined | null>
21
+ | URLSearchParams
22
+
23
+ /**
24
+ * Props for [`Link`]{@link Link}.
25
+ */
26
+ export type LinkProps = OverrideProps<
27
+ 'a',
28
+ {
29
+ /**
30
+ * Classes to apply to the rendered anchor.
31
+ */
32
+ className?: ClassValue
33
+
34
+ /**
35
+ * Destination URL passed to the rendered anchor's `href` attribute.
36
+ */
37
+ to: string
38
+
39
+ /**
40
+ * Query parameters to merge into [`LinkProps.to`]{@link LinkProps#to}.
41
+ */
42
+ search?: SearchParamsInit
43
+
44
+ /**
45
+ * Whether navigation should replace the current history entry instead of pushing a new one.
46
+ */
47
+ replace?: boolean
48
+
49
+ href: never
50
+ onClick: never
51
+ }
52
+ >
53
+
54
+ /**
55
+ * Renders an anchor that navigates with rerouter history updates on ordinary clicks.
56
+ *
57
+ * @example
58
+ * ```tsx
59
+ * <Link to="/matches/42" search={{ tab: 'details' }}>
60
+ * View match
61
+ * </Link>
62
+ * ```
63
+ *
64
+ * @param props - Anchor props plus rerouter navigation options.
65
+ * @returns An anchor element that pushes or replaces the browser URL.
66
+ */
67
+ export function Link({ to, search, children, className, replace, ...rest }: LinkProps) {
68
+ const href = search ? mergeSearch(to, search) : to
69
+ const linkClassName = cc(className)
70
+
71
+ const onClick = (ev: MouseEvent<HTMLAnchorElement>) => {
72
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return
73
+ if (ev.button !== 0) return
74
+ ev.preventDefault()
75
+ startTransition(() => {
76
+ if (replace) {
77
+ replaceUrl(href)
78
+ } else {
79
+ pushUrl(href)
80
+ }
81
+ })
82
+ }
83
+
84
+ return (
85
+ <a {...rest} className={linkClassName || undefined} href={href} onClick={onClick}>
86
+ {children}
87
+ </a>
88
+ )
89
+ }
@@ -0,0 +1,119 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { Window } from 'happy-dom'
3
+
4
+ const testWindow = new Window({ url: 'http://localhost/start' })
5
+
6
+ testWindow.SyntaxError = SyntaxError
7
+
8
+ Object.assign(globalThis, {
9
+ window: testWindow,
10
+ document: testWindow.document,
11
+ navigator: testWindow.navigator,
12
+ location: testWindow.location,
13
+ history: testWindow.history,
14
+ Event: testWindow.Event,
15
+ HTMLElement: testWindow.HTMLElement,
16
+ MouseEvent: testWindow.MouseEvent,
17
+ Node: testWindow.Node,
18
+ PopStateEvent: testWindow.PopStateEvent,
19
+ SyntaxError,
20
+ getComputedStyle: testWindow.getComputedStyle.bind(testWindow),
21
+ })
22
+
23
+ const { cleanup, render } = await import('@testing-library/react')
24
+ const { NavLink } = await import('./NavLink')
25
+
26
+ describe(NavLink.name, () => {
27
+ beforeEach(() => {
28
+ window.history.replaceState(null, '', '/start')
29
+ })
30
+
31
+ afterEach(() => {
32
+ cleanup()
33
+ window.history.replaceState(null, '', '/start')
34
+ })
35
+
36
+ test('renders active classes when the target path matches the current path', () => {
37
+ const { getByRole } = render(
38
+ <NavLink
39
+ activeClass={{ active: true, pending: false }}
40
+ className="pill"
41
+ inactiveClass="muted"
42
+ to="/start?tab=details"
43
+ >
44
+ Start
45
+ </NavLink>,
46
+ )
47
+
48
+ expect(getByRole('link', { name: 'Start' }).getAttribute('class')).toBe('pill active')
49
+ })
50
+
51
+ test('renders inactive classes when the target path does not match the current path', () => {
52
+ const { getByRole } = render(
53
+ <NavLink
54
+ activeClass="active"
55
+ className="pill"
56
+ inactiveClass={{ muted: true }}
57
+ to="/matches"
58
+ >
59
+ Matches
60
+ </NavLink>,
61
+ )
62
+
63
+ expect(getByRole('link', { name: 'Matches' }).getAttribute('class')).toBe('pill muted')
64
+ })
65
+
66
+ test('normalizes relative targets using the current URL', () => {
67
+ window.history.replaceState(null, '', '/matches/42')
68
+
69
+ const { getByRole } = render(
70
+ <NavLink activeClass="active" className="pill" inactiveClass="muted" to="?tab=details">
71
+ Current match
72
+ </NavLink>,
73
+ )
74
+
75
+ expect(getByRole('link', { name: 'Current match' }).getAttribute('class')).toBe(
76
+ 'pill active',
77
+ )
78
+ })
79
+
80
+ test('renders active classes for prefix matches', () => {
81
+ window.history.replaceState(null, '', '/fetch-loading/abc-123')
82
+
83
+ const { getByRole } = render(
84
+ <NavLink
85
+ activeClass="active"
86
+ className="pill"
87
+ inactiveClass="muted"
88
+ match="prefix"
89
+ to="/fetch-loading"
90
+ >
91
+ Fetch Loading
92
+ </NavLink>,
93
+ )
94
+
95
+ expect(getByRole('link', { name: 'Fetch Loading' }).getAttribute('class')).toBe(
96
+ 'pill active',
97
+ )
98
+ })
99
+
100
+ test('does not render active classes for partial segment prefix matches', () => {
101
+ window.history.replaceState(null, '', '/fetch-loading-old')
102
+
103
+ const { getByRole } = render(
104
+ <NavLink
105
+ activeClass="active"
106
+ className="pill"
107
+ inactiveClass="muted"
108
+ match="prefix"
109
+ to="/fetch-loading"
110
+ >
111
+ Fetch Loading
112
+ </NavLink>,
113
+ )
114
+
115
+ expect(getByRole('link', { name: 'Fetch Loading' }).getAttribute('class')).toBe(
116
+ 'pill muted',
117
+ )
118
+ })
119
+ })
@@ -0,0 +1,71 @@
1
+ import { cc, type ClassValue } from '@mpen/classcat'
2
+ import type { Override } from '@mpen/ts-types'
3
+ import { useUrlPath } from '../hooks/useUrl'
4
+ import { Link, type LinkProps } from './Link'
5
+
6
+ export type NavLinkMatch = 'exact' | 'prefix'
7
+
8
+ /**
9
+ * Props for [`NavLink`]{@link NavLink}.
10
+ */
11
+ export type NavLinkProps = Override<
12
+ LinkProps,
13
+ {
14
+ /**
15
+ * Classes to apply when the link target matches the current path.
16
+ */
17
+ activeClass?: ClassValue
18
+
19
+ /**
20
+ * Classes to apply when the link target does not match the current path.
21
+ */
22
+ inactiveClass?: ClassValue
23
+
24
+ /**
25
+ * How to compare the link target to the current path.
26
+ *
27
+ * @defaultValue `'exact'`
28
+ */
29
+ match?: NavLinkMatch
30
+ }
31
+ >
32
+
33
+ function isActivePath(pathname: string, targetPathname: string, match: NavLinkMatch): boolean {
34
+ if (pathname === targetPathname) return true
35
+ if (match === 'exact') return false
36
+ if (targetPathname === '/') return false
37
+ return pathname.startsWith(`${targetPathname}/`)
38
+ }
39
+
40
+ /**
41
+ * Renders a [`Link`]{@link Link} with classes selected from the current route.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <NavLink
46
+ * activeClass={['pill', 'active']}
47
+ * inactiveClass={['pill', { muted: true }]}
48
+ * to="/settings"
49
+ * >
50
+ * Settings
51
+ * </NavLink>
52
+ * ```
53
+ *
54
+ * @param props - Link props plus active and inactive class values.
55
+ * @returns An anchor element that navigates through rerouter.
56
+ */
57
+ export function NavLink({
58
+ activeClass,
59
+ className,
60
+ inactiveClass,
61
+ match = 'exact',
62
+ to,
63
+ ...props
64
+ }: NavLinkProps) {
65
+ const pathname = useUrlPath()
66
+ const target = new URL(to, window.location.href)
67
+ const active = isActivePath(pathname, target.pathname, match)
68
+ const linkClassName = cc(className, active ? activeClass : inactiveClass)
69
+
70
+ return <Link {...props} className={linkClassName || undefined} to={to} />
71
+ }
@@ -0,0 +1,183 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { Window } from 'happy-dom'
3
+ import type { RouteObject } from '../lib/routes'
4
+
5
+ const testWindow = new Window({ url: 'http://localhost/start' })
6
+
7
+ testWindow.SyntaxError = SyntaxError
8
+
9
+ Object.assign(globalThis, {
10
+ window: testWindow,
11
+ document: testWindow.document,
12
+ navigator: testWindow.navigator,
13
+ location: testWindow.location,
14
+ history: testWindow.history,
15
+ Event: testWindow.Event,
16
+ HTMLElement: testWindow.HTMLElement,
17
+ MouseEvent: testWindow.MouseEvent,
18
+ Node: testWindow.Node,
19
+ PopStateEvent: testWindow.PopStateEvent,
20
+ SyntaxError,
21
+ getComputedStyle: testWindow.getComputedStyle.bind(testWindow),
22
+ })
23
+
24
+ const { act, cleanup, render, waitFor } = await import('@testing-library/react')
25
+ const { Router } = await import('./Router')
26
+ const { pushUrl } = await import('../lib/url')
27
+
28
+ function wait(ms: number): Promise<void> {
29
+ return new Promise((resolve) => {
30
+ setTimeout(resolve, ms)
31
+ })
32
+ }
33
+
34
+ describe(Router.name, () => {
35
+ beforeEach(() => {
36
+ window.history.replaceState(null, '', '/start')
37
+ })
38
+
39
+ afterEach(() => {
40
+ cleanup()
41
+ window.history.replaceState(null, '', '/start')
42
+ })
43
+
44
+ test('delays the loading fallback while a route component is loading', async () => {
45
+ window.history.replaceState(null, '', '/slow')
46
+
47
+ const routes: readonly RouteObject[] = [
48
+ {
49
+ pattern: '/slow',
50
+ component: () =>
51
+ new Promise(() => {
52
+ // Keep the route pending so the fallback delay is observable.
53
+ }),
54
+ },
55
+ ]
56
+
57
+ const view = render(
58
+ <Router loading={<div>Loading route...</div>} loadingDelayMs={25} routes={routes} />,
59
+ )
60
+
61
+ expect(view.queryByText('Loading route...')).toBeNull()
62
+
63
+ await act(async () => {
64
+ await wait(30)
65
+ })
66
+
67
+ expect(view.getByText('Loading route...')).toBeTruthy()
68
+ })
69
+
70
+ test('does not show the loading fallback when the route loads before the delay', async () => {
71
+ window.history.replaceState(null, '', '/quick')
72
+
73
+ const routes: readonly RouteObject[] = [
74
+ {
75
+ pattern: '/quick',
76
+ component: () =>
77
+ wait(5).then(() => ({
78
+ default: function QuickRoute() {
79
+ return <div>Quick route</div>
80
+ },
81
+ })),
82
+ },
83
+ ]
84
+
85
+ const view = render(
86
+ <Router loading={<div>Loading route...</div>} loadingDelayMs={50} routes={routes} />,
87
+ )
88
+
89
+ expect(view.queryByText('Loading route...')).toBeNull()
90
+
91
+ await waitFor(() => {
92
+ expect(view.getByText('Quick route')).toBeTruthy()
93
+ })
94
+
95
+ expect(view.queryByText('Loading route...')).toBeNull()
96
+ })
97
+
98
+ test('keeps the current route visible until a slow next route reaches the delay', async () => {
99
+ const routes: readonly RouteObject[] = [
100
+ {
101
+ pattern: '/start',
102
+ component: async () => ({
103
+ default: function StartRoute() {
104
+ return <div>Start route</div>
105
+ },
106
+ }),
107
+ },
108
+ {
109
+ pattern: '/slow',
110
+ component: () =>
111
+ new Promise(() => {
112
+ // Keep the next route pending so the delayed loading state is observable.
113
+ }),
114
+ },
115
+ ]
116
+
117
+ const view = render(
118
+ <Router loading={<div>Loading route...</div>} loadingDelayMs={25} routes={routes} />,
119
+ )
120
+
121
+ await waitFor(() => {
122
+ expect(view.getByText('Start route')).toBeTruthy()
123
+ })
124
+
125
+ act(() => {
126
+ pushUrl('/slow')
127
+ })
128
+
129
+ expect(view.getByText('Start route')).toBeTruthy()
130
+ expect(view.queryByText('Loading route...')).toBeNull()
131
+
132
+ await act(async () => {
133
+ await wait(30)
134
+ })
135
+
136
+ expect(view.queryByText('Start route')).toBeNull()
137
+ expect(view.getByText('Loading route...')).toBeTruthy()
138
+ })
139
+
140
+ test('keeps the current route visible until a quick next route is ready', async () => {
141
+ const routes: readonly RouteObject[] = [
142
+ {
143
+ pattern: '/start',
144
+ component: async () => ({
145
+ default: function StartRoute() {
146
+ return <div>Start route</div>
147
+ },
148
+ }),
149
+ },
150
+ {
151
+ pattern: '/quick',
152
+ component: () =>
153
+ wait(5).then(() => ({
154
+ default: function QuickRoute() {
155
+ return <div>Quick route</div>
156
+ },
157
+ })),
158
+ },
159
+ ]
160
+
161
+ const view = render(
162
+ <Router loading={<div>Loading route...</div>} loadingDelayMs={50} routes={routes} />,
163
+ )
164
+
165
+ await waitFor(() => {
166
+ expect(view.getByText('Start route')).toBeTruthy()
167
+ })
168
+
169
+ act(() => {
170
+ pushUrl('/quick')
171
+ })
172
+
173
+ expect(view.getByText('Start route')).toBeTruthy()
174
+ expect(view.queryByText('Loading route...')).toBeNull()
175
+
176
+ await waitFor(() => {
177
+ expect(view.getByText('Quick route')).toBeTruthy()
178
+ })
179
+
180
+ expect(view.queryByText('Start route')).toBeNull()
181
+ expect(view.queryByText('Loading route...')).toBeNull()
182
+ })
183
+ })