@mpen/rerouter 0.3.0 → 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 (66) hide show
  1. package/README.md +4 -0
  2. package/{src → cli}/bin.test.ts +24 -2
  3. package/{src → cli}/bin.ts +27 -18
  4. package/cli/tsconfig.json +9 -0
  5. package/dist/acorn-k7ED_tOl.js +4968 -0
  6. package/dist/angular--Iqdw9UJ.js +4057 -0
  7. package/dist/babel-hfWAujRY.js +9878 -0
  8. package/dist/bin.d.ts +1 -1
  9. package/dist/bin.js +28 -23
  10. package/dist/estree-C1Zjnvlw.js +7266 -0
  11. package/dist/flow-BaD9LyIP.js +52912 -0
  12. package/dist/glimmer-CvCjW_1V.js +7541 -0
  13. package/dist/graphql-BdtzBuWh.js +1945 -0
  14. package/dist/html-DkZtUVbo.js +7137 -0
  15. package/dist/index.d.ts +19 -6
  16. package/dist/index.js +135 -27
  17. package/dist/markdown-Z8Vrc69e.js +6876 -0
  18. package/dist/meriyah-DeO4stuH.js +7590 -0
  19. package/dist/postcss-BmgGJ0E5.js +6777 -0
  20. package/dist/prettier-BT_F8kIx.js +15629 -0
  21. package/dist/typescript-DtIxStjy.js +22936 -0
  22. package/dist/yaml-CWOPBY0q.js +5281 -0
  23. package/examples/App.tsx +18 -49
  24. package/examples/dist/BlogPost-c10d9w2p.js +1 -0
  25. package/examples/dist/FetchLoading-534mdrgz.js +1 -0
  26. package/examples/dist/FetchLoading-sbxbdkre.js +1 -0
  27. package/examples/dist/Home-a1258p25.js +1 -0
  28. package/examples/dist/KitchenSink-821mjg0h.js +1 -0
  29. package/examples/dist/Login-wywx6bp7.js +1 -0
  30. package/examples/dist/Match-1e72jm5w.js +1 -0
  31. package/examples/dist/NotFound-smxj24jw.js +1 -0
  32. package/examples/dist/SlowLoading-59xxmbfk.js +1 -0
  33. package/examples/dist/index-0d4kj0rv.js +2 -0
  34. package/examples/dist/index-3x197sbt.js +9 -0
  35. package/examples/dist/index-a2hkfx1n.js +9 -0
  36. package/examples/dist/index-d21me1mc.js +9 -0
  37. package/examples/dist/index-ktqdknsn.js +2 -0
  38. package/examples/dist/index-p53qxxzd.js +2 -0
  39. package/examples/dist/index.html +67 -0
  40. package/examples/routes.gen.ts +66 -86
  41. package/examples/routes.ts +2 -2
  42. package/examples/server/serve-dist.ts +33 -0
  43. package/examples/server/tsconfig.json +9 -0
  44. package/package.json +11 -6
  45. package/src/components/Link.tsx +8 -6
  46. package/src/components/Router.test.tsx +183 -0
  47. package/src/components/Router.tsx +161 -29
  48. package/src/lib/routes.ts +2 -0
  49. package/tsconfig.json +3 -2
  50. package/tsdown.config.ts +3 -4
  51. package/dist/hooks-Dlwcb0sV.js +0 -20
  52. package/dist/hooks.d.ts +0 -2
  53. package/dist/hooks.js +0 -2
  54. package/dist/index-BYXpNitc.d.ts +0 -5
  55. /package/{src → cli}/fixtures/bin/kitchen-sink.tsx +0 -0
  56. /package/{src → cli}/fixtures/bin/optional.tsx +0 -0
  57. /package/{src → cli}/fixtures/bin/pages/Home.tsx +0 -0
  58. /package/{src → cli}/fixtures/bin/pages/KitchenSink.tsx +0 -0
  59. /package/{src → cli}/fixtures/bin/pages/Login.tsx +0 -0
  60. /package/{src → cli}/fixtures/bin/pages/Match.tsx +0 -0
  61. /package/{src → cli}/fixtures/bin/pages/NotFound.tsx +0 -0
  62. /package/{src → cli}/fixtures/bin/pages/Optional.tsx +0 -0
  63. /package/{src → cli}/fixtures/bin/regexp-groups.tsx +0 -0
  64. /package/{src → cli}/fixtures/bin/simple.tsx +0 -0
  65. /package/{src → cli}/fixtures/bin/unnamed.tsx +0 -0
  66. /package/dist/{routes-Hpf6cwcZ.js → routes-PW-bNm8e.js} +0 -0
@@ -0,0 +1,67 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>@mpen/rerouter examples</title>
7
+ <style>
8
+ body {
9
+ margin: 0;
10
+ font-family:
11
+ ui-sans-serif,
12
+ system-ui,
13
+ -apple-system,
14
+ Segoe UI,
15
+ Roboto,
16
+ Helvetica,
17
+ Arial,
18
+ 'Apple Color Emoji',
19
+ 'Segoe UI Emoji';
20
+ background: #0b1020;
21
+ color: #e8eefc;
22
+ }
23
+ a {
24
+ color: inherit;
25
+ }
26
+ .app {
27
+ max-width: 900px;
28
+ margin: 0 auto;
29
+ padding: 24px;
30
+ }
31
+ .card {
32
+ background: rgba(255, 255, 255, 0.06);
33
+ border: 1px solid rgba(255, 255, 255, 0.12);
34
+ border-radius: 12px;
35
+ padding: 16px;
36
+ margin-bottom: 16px;
37
+ }
38
+ .nav {
39
+ display: flex;
40
+ flex-wrap: wrap;
41
+ gap: 10px;
42
+ margin-top: 8px;
43
+ }
44
+ .pill {
45
+ padding: 6px 10px;
46
+ border-radius: 999px;
47
+ border: 1px solid rgba(255, 255, 255, 0.16);
48
+ background: rgba(255, 255, 255, 0.06);
49
+ text-decoration: none;
50
+ }
51
+ .pill.active {
52
+ border-color: rgba(90, 200, 250, 0.7);
53
+ background: rgba(90, 200, 250, 0.18);
54
+ }
55
+ code {
56
+ font-family:
57
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
58
+ 'Courier New', monospace;
59
+ font-size: 0.95em;
60
+ }
61
+ </style>
62
+ <script type="module" crossorigin src="./index-d21me1mc.js"></script></head>
63
+ <body>
64
+ <div id="root"></div>
65
+
66
+ </body>
67
+ </html>
@@ -1,125 +1,105 @@
1
1
  // Do not modify this file. It was auto-generated with the following command:
2
- // $ rerouter ./examples/routes.ts -w
2
+ // $ rerouter ./examples/routes.ts -w -p
3
3
 
4
- type AllOrNone<T> =
5
- | Required<T>
6
- | { [K in keyof T]?: never }
4
+ type AllOrNone<T> = Required<T> | { [K in keyof T]?: never }
7
5
 
8
6
  type ParamType = string | number | boolean
9
7
  type WildcardType = Iterable<ParamType>
10
8
 
11
9
  export function home(): string {
12
- let sb = ""
10
+ let sb = ''
13
11
 
14
- sb += "/"
12
+ sb += '/'
15
13
 
16
- return sb
14
+ return sb
17
15
  }
18
16
 
19
17
  export function kitchenSink(
20
- params: {
21
- "foo": ParamType
22
- "baz": ParamType
23
- "splat": WildcardType
24
- } & AllOrNone<{
25
- "optional": ParamType
26
- "two": ParamType
27
- }>
18
+ params: { foo: ParamType; baz: ParamType; splat: WildcardType } & AllOrNone<{
19
+ optional: ParamType
20
+ two: ParamType
21
+ }>,
28
22
  ): string {
29
- let sb = ""
30
-
31
- if (params["foo"] == null) throw new Error("Missing param: foo")
32
- if (params["baz"] == null) throw new Error("Missing param: baz")
33
- if (params["splat"] == null) throw new Error("Missing param: splat")
34
- sb += "/hello/"
35
- sb += (encodeURIComponent)(String(params["foo"]))
36
- sb += "/bar/"
37
- sb += (encodeURIComponent)(String(params["baz"]))
38
- sb += "/"
39
- sb += Array.from(params["splat"], v => (encodeURIComponent)(String(v))).join("/")
40
- sb += "/xxx"
41
- if (params["optional"] != null && params["two"] != null) {
42
- sb += "/"
43
- sb += (encodeURIComponent)(String(params["optional"]))
44
- sb += "/lol/"
45
- sb += (encodeURIComponent)(String(params["two"]))
46
- } else if (!(params["optional"] == null && params["two"] == null)) {
47
- throw new Error("Group requires all-or-none: \"optional\", \"two\"")
48
- }
49
-
50
- return sb
23
+ let sb = ''
24
+
25
+ if (params['foo'] == null) throw new Error('Missing param: foo')
26
+ if (params['baz'] == null) throw new Error('Missing param: baz')
27
+ if (params['splat'] == null) throw new Error('Missing param: splat')
28
+ sb += '/hello/'
29
+ sb += encodeURIComponent(String(params['foo']))
30
+ sb += '/bar/'
31
+ sb += encodeURIComponent(String(params['baz']))
32
+ sb += '/'
33
+ sb += Array.from(params['splat'], (v) => encodeURIComponent(String(v))).join('/')
34
+ sb += '/xxx'
35
+ if (params['optional'] != null && params['two'] != null) {
36
+ sb += '/'
37
+ sb += encodeURIComponent(String(params['optional']))
38
+ sb += '/lol/'
39
+ sb += encodeURIComponent(String(params['two']))
40
+ } else if (!(params['optional'] == null && params['two'] == null)) {
41
+ throw new Error('Group requires all-or-none: "optional", "two"')
42
+ }
43
+
44
+ return sb
51
45
  }
52
46
 
53
- export function blogPost(
54
- params: {
55
- "id": ParamType
56
- } & AllOrNone<{
57
- "title": ParamType
58
- }>
59
- ): string {
60
- let sb = ""
61
-
62
- if (params["id"] == null) throw new Error("Missing param: id")
63
- sb += "/blog/"
64
- sb += (encodeURIComponent)(String(params["id"]))
65
- if (params["title"] != null) {
66
- sb += "-"
67
- sb += (encodeURIComponent)(String(params["title"]))
68
- } else if (!(params["title"] == null)) {
69
- throw new Error("Group requires all-or-none: \"title\"")
70
- }
71
-
72
- return sb
47
+ export function blogPost(params: { id: ParamType } & AllOrNone<{ title: ParamType }>): string {
48
+ let sb = ''
49
+
50
+ if (params['id'] == null) throw new Error('Missing param: id')
51
+ sb += '/blog/'
52
+ sb += encodeURIComponent(String(params['id']))
53
+ if (params['title'] != null) {
54
+ sb += '-'
55
+ sb += encodeURIComponent(String(params['title']))
56
+ } else if (!(params['title'] == null)) {
57
+ throw new Error('Group requires all-or-none: "title"')
58
+ }
59
+
60
+ return sb
73
61
  }
74
62
 
75
63
  export function slowLoading(): string {
76
- let sb = ""
64
+ let sb = ''
77
65
 
78
- sb += "/slow-loading"
66
+ sb += '/slow-loading'
79
67
 
80
- return sb
68
+ return sb
81
69
  }
82
70
 
83
71
  export function fetchLoading(): string {
84
- let sb = ""
72
+ let sb = ''
85
73
 
86
- sb += "/fetch-loading"
74
+ sb += '/fetch-loading'
87
75
 
88
- return sb
76
+ return sb
89
77
  }
90
78
 
91
- export function fetchLoadingItem(
92
- params: {
93
- "id": ParamType
94
- }
95
- ): string {
96
- let sb = ""
79
+ export function fetchLoadingItem(params: { id: ParamType }): string {
80
+ let sb = ''
97
81
 
98
- if (params["id"] == null) throw new Error("Missing param: id")
99
- sb += "/fetch-loading/"
100
- sb += (encodeURIComponent)(String(params["id"]))
82
+ if (params['id'] == null) throw new Error('Missing param: id')
83
+ sb += '/fetch-loading/'
84
+ sb += encodeURIComponent(String(params['id']))
101
85
 
102
- return sb
86
+ return sb
103
87
  }
104
88
 
105
89
  export function login(): string {
106
- let sb = ""
90
+ let sb = ''
107
91
 
108
- sb += "/login"
92
+ sb += '/login'
109
93
 
110
- return sb
94
+ return sb
111
95
  }
112
96
 
113
- export function match(
114
- params: {
115
- "id": ParamType
116
- }
117
- ): string {
118
- let sb = ""
97
+ export function match(params: { id: ParamType }): string {
98
+ let sb = ''
119
99
 
120
- if (params["id"] == null) throw new Error("Missing param: id")
121
- sb += "/matches/"
122
- sb += (encodeURIComponent)(String(params["id"]))
100
+ if (params['id'] == null) throw new Error('Missing param: id')
101
+ sb += '/matches/'
102
+ sb += encodeURIComponent(String(params['id']))
123
103
 
124
- return sb
104
+ return sb
125
105
  }
@@ -1,8 +1,8 @@
1
- import type { RouteObject } from '../src'
1
+ import type { Routes } from '@mpen/rerouter'
2
2
 
3
3
  const loadFetchLoading = () => import('./pages/FetchLoading')
4
4
 
5
- const ROUTES: readonly RouteObject[] = [
5
+ const ROUTES: Routes = [
6
6
  { name: 'home', pattern: '/', component: () => import('./pages/Home') },
7
7
  {
8
8
  name: 'kitchenSink',
@@ -0,0 +1,33 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { join } from 'node:path'
3
+
4
+ const distPath = fileURLToPath(new URL('../dist/', import.meta.url))
5
+ const index = Bun.file(join(distPath, 'index.html'))
6
+
7
+ async function fileForPath(pathname: string) {
8
+ const requestedPath = pathname === '/' ? 'index.html' : pathname.slice(1)
9
+ const file = Bun.file(join(distPath, requestedPath))
10
+
11
+ if (await file.exists()) return file
12
+ if (pathname.startsWith('/_bun/') || requestedPath.split('/').at(-1)?.includes('.'))
13
+ return undefined
14
+
15
+ return index
16
+ }
17
+
18
+ const server = Bun.serve({
19
+ async fetch(request) {
20
+ const url = new URL(request.url)
21
+ const file = await fileForPath(url.pathname)
22
+
23
+ if (!file) return new Response('Not found', { status: 404 })
24
+
25
+ return new Response(file, {
26
+ headers: {
27
+ 'Cache-Control': 'no-store',
28
+ },
29
+ })
30
+ },
31
+ })
32
+
33
+ console.log(`Serving production build at ${server.url}`)
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "lib": ["ESNext"],
5
+ "types": ["bun"]
6
+ },
7
+ "include": ["serve-dist.ts"],
8
+ "exclude": ["**/*.test.*"]
9
+ }
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@mpen/rerouter",
3
- "version": "0.3.0",
3
+ "description": "Lightweight type-safe router for React with URL helper generation.",
4
+ "version": "0.3.1",
4
5
  "exports": {
5
6
  ".": "./dist/index.js",
6
7
  "./bin": "./dist/bin.js",
7
- "./hooks": "./dist/hooks.js",
8
8
  "./package.json": "./package.json"
9
9
  },
10
10
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "type": "module",
14
14
  "dependencies": {
15
15
  "@mpen/classcat": "0.1.3",
16
- "@mpen/ts-types": "0.1.1",
16
+ "@mpen/ts-types": "0.1.2",
17
17
  "path-to-regexp": "^8.3.0"
18
18
  },
19
19
  "peerDependencies": {
@@ -31,14 +31,19 @@
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsdown",
34
- "gen": "bun src/bin.ts ./examples/routes.ts -w",
34
+ "gen": "bun cli/bin.ts ./examples/routes.ts -w -p",
35
35
  "serve": "bun --hot examples/index.html",
36
- "dev": "bun run --sequential gen serve"
36
+ "dev": "bun run --sequential gen serve",
37
+ "prod": "bun run gen && bun build --production --splitting examples/index.html --outdir examples/dist && bun examples/server/serve-dist.ts"
37
38
  },
38
39
  "types": "./dist/index.d.ts",
39
40
  "publishConfig": {
40
41
  "access": "public"
41
42
  },
42
43
  "main": "./dist/index.js",
43
- "module": "./dist/index.js"
44
+ "module": "./dist/index.js",
45
+ "inlinedDependencies": {
46
+ "prettier": "3.8.3",
47
+ "process": "0.11.10"
48
+ }
44
49
  }
@@ -1,6 +1,6 @@
1
1
  import { cc, type ClassValue } from '@mpen/classcat'
2
2
  import type { OverrideProps } from '@mpen/ts-types/react'
3
- import type { MouseEvent } from 'react'
3
+ import { startTransition, type MouseEvent } from 'react'
4
4
  import { pushUrl, replaceUrl } from '../lib/url'
5
5
  import { mergeSearch } from '../lib/mergeSearch'
6
6
 
@@ -72,11 +72,13 @@ export function Link({ to, search, children, className, replace, ...rest }: Link
72
72
  if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return
73
73
  if (ev.button !== 0) return
74
74
  ev.preventDefault()
75
- if (replace) {
76
- replaceUrl(href)
77
- } else {
78
- pushUrl(href)
79
- }
75
+ startTransition(() => {
76
+ if (replace) {
77
+ replaceUrl(href)
78
+ } else {
79
+ pushUrl(href)
80
+ }
81
+ })
80
82
  }
81
83
 
82
84
  return (
@@ -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
+ })