@mpen/rerouter 0.1.9 → 0.3.0
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.
- package/README.md +76 -18
- package/dist/bin.d.ts +29 -0
- package/dist/bin.js +228 -0
- package/dist/hooks-Dlwcb0sV.js +20 -0
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +2 -0
- package/dist/index-BYXpNitc.d.ts +5 -0
- package/dist/index.d.ts +265 -0
- package/dist/index.js +139 -0
- package/dist/routes-Hpf6cwcZ.js +135 -0
- package/examples/App.tsx +111 -0
- package/examples/index.html +67 -0
- package/examples/pages/BlogPost.tsx +17 -0
- package/examples/pages/FetchLoading.tsx +53 -0
- package/examples/pages/FetchLoadingItem.tsx +45 -0
- package/examples/pages/Home.tsx +3 -0
- package/examples/pages/KitchenSink.tsx +23 -0
- package/examples/pages/Login.tsx +3 -0
- package/examples/pages/Match.tsx +5 -0
- package/examples/pages/NotFound.tsx +3 -0
- package/examples/pages/SlowLoading.tsx +8 -0
- package/examples/routes.gen.ts +125 -0
- package/examples/routes.ts +40 -0
- package/package.json +37 -32
- package/src/bin.test.ts +199 -0
- package/src/bin.ts +333 -0
- package/src/components/Link.test.tsx +139 -0
- package/src/components/Link.tsx +87 -0
- package/src/components/NavLink.test.tsx +119 -0
- package/src/components/NavLink.tsx +71 -0
- package/src/components/Router.tsx +75 -0
- package/src/fixtures/bin/kitchen-sink.tsx +15 -0
- package/src/fixtures/bin/optional.tsx +3 -0
- package/src/fixtures/bin/pages/Home.tsx +3 -0
- package/src/fixtures/bin/pages/KitchenSink.tsx +3 -0
- package/src/fixtures/bin/pages/Login.tsx +3 -0
- package/src/fixtures/bin/pages/Match.tsx +3 -0
- package/src/fixtures/bin/pages/NotFound.tsx +3 -0
- package/src/fixtures/bin/pages/Optional.tsx +3 -0
- package/src/fixtures/bin/regexp-groups.tsx +11 -0
- package/src/fixtures/bin/simple.tsx +1 -0
- package/src/fixtures/bin/unnamed.tsx +4 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useUrl.ts +22 -0
- package/src/index.ts +6 -0
- package/src/lib/mergeSearch.test.ts +37 -0
- package/src/lib/mergeSearch.ts +21 -0
- package/src/lib/routes.test.ts +67 -0
- package/src/lib/routes.ts +245 -0
- package/src/lib/url.ts +9 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +22 -0
- package/LICENSE +0 -21
- package/dist/bundle.cjs +0 -422
- package/dist/bundle.d.ts +0 -2
- package/dist/bundle.mjs +0 -420
- package/dist/dev.d.ts +0 -1
- package/dist/log.d.ts +0 -1
- package/dist/uri-template.d.ts +0 -56
package/dist/index.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { n as useUrlSearchParams, t as useUrlPath } from "./hooks-Dlwcb0sV.js";
|
|
2
|
+
import { n as normalizeRoutes, t as normalizeLegacyPathToRegexpSyntax } from "./routes-Hpf6cwcZ.js";
|
|
3
|
+
import { cc } from "@mpen/classcat";
|
|
4
|
+
import { jsx } from "react/jsx-runtime";
|
|
5
|
+
import { Suspense, lazy, useMemo } from "react";
|
|
6
|
+
//#region src/lib/url.ts
|
|
7
|
+
function pushUrl(next, state) {
|
|
8
|
+
window.history.pushState(state, "", next);
|
|
9
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
10
|
+
}
|
|
11
|
+
function replaceUrl(next, state) {
|
|
12
|
+
window.history.replaceState(state, "", next);
|
|
13
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/lib/mergeSearch.ts
|
|
17
|
+
function mergeSearch(to, search) {
|
|
18
|
+
const hashIndex = to.indexOf("#");
|
|
19
|
+
const hash = hashIndex !== -1 ? to.slice(hashIndex) : "";
|
|
20
|
+
const pathAndQuery = hashIndex !== -1 ? to.slice(0, hashIndex) : to;
|
|
21
|
+
const queryIndex = pathAndQuery.indexOf("?");
|
|
22
|
+
const path = queryIndex !== -1 ? pathAndQuery.slice(0, queryIndex) : pathAndQuery;
|
|
23
|
+
const existingQuery = queryIndex !== -1 ? pathAndQuery.slice(queryIndex + 1) : "";
|
|
24
|
+
const params = new URLSearchParams(existingQuery);
|
|
25
|
+
const newParams = new URLSearchParams(search);
|
|
26
|
+
for (const [key, value] of newParams) params.set(key, value);
|
|
27
|
+
const queryString = params.toString();
|
|
28
|
+
return `${path}${queryString ? "?" + queryString : ""}${hash}`;
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/components/Link.tsx
|
|
32
|
+
/**
|
|
33
|
+
* Renders an anchor that navigates with rerouter history updates on ordinary clicks.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* <Link to="/matches/42" search={{ tab: 'details' }}>
|
|
38
|
+
* View match
|
|
39
|
+
* </Link>
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* @param props - Anchor props plus rerouter navigation options.
|
|
43
|
+
* @returns An anchor element that pushes or replaces the browser URL.
|
|
44
|
+
*/
|
|
45
|
+
function Link({ to, search, children, className, replace, ...rest }) {
|
|
46
|
+
const href = search ? mergeSearch(to, search) : to;
|
|
47
|
+
const linkClassName = cc(className);
|
|
48
|
+
const onClick = (ev) => {
|
|
49
|
+
if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
|
50
|
+
if (ev.button !== 0) return;
|
|
51
|
+
ev.preventDefault();
|
|
52
|
+
if (replace) replaceUrl(href);
|
|
53
|
+
else pushUrl(href);
|
|
54
|
+
};
|
|
55
|
+
return /* @__PURE__ */ jsx("a", {
|
|
56
|
+
...rest,
|
|
57
|
+
className: linkClassName || void 0,
|
|
58
|
+
href,
|
|
59
|
+
onClick,
|
|
60
|
+
children
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
//#region src/components/NavLink.tsx
|
|
65
|
+
function isActivePath(pathname, targetPathname, match) {
|
|
66
|
+
if (pathname === targetPathname) return true;
|
|
67
|
+
if (match === "exact") return false;
|
|
68
|
+
if (targetPathname === "/") return false;
|
|
69
|
+
return pathname.startsWith(`${targetPathname}/`);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Renders a [`Link`]{@link Link} with classes selected from the current route.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```tsx
|
|
76
|
+
* <NavLink
|
|
77
|
+
* activeClass={['pill', 'active']}
|
|
78
|
+
* inactiveClass={['pill', { muted: true }]}
|
|
79
|
+
* to="/settings"
|
|
80
|
+
* >
|
|
81
|
+
* Settings
|
|
82
|
+
* </NavLink>
|
|
83
|
+
* ```
|
|
84
|
+
*
|
|
85
|
+
* @param props - Link props plus active and inactive class values.
|
|
86
|
+
* @returns An anchor element that navigates through rerouter.
|
|
87
|
+
*/
|
|
88
|
+
function NavLink({ activeClass, className, inactiveClass, match = "exact", to, ...props }) {
|
|
89
|
+
const linkClassName = cc(className, isActivePath(useUrlPath(), new URL(to, window.location.href).pathname, match) ? activeClass : inactiveClass);
|
|
90
|
+
return /* @__PURE__ */ jsx(Link, {
|
|
91
|
+
...props,
|
|
92
|
+
className: linkClassName || void 0,
|
|
93
|
+
to
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/components/Router.tsx
|
|
98
|
+
const lazyRouteComponents = /* @__PURE__ */ new WeakMap();
|
|
99
|
+
function getLazyRouteComponent(component) {
|
|
100
|
+
let LazyComponent = lazyRouteComponents.get(component);
|
|
101
|
+
if (!LazyComponent) {
|
|
102
|
+
LazyComponent = lazy(component);
|
|
103
|
+
lazyRouteComponents.set(component, LazyComponent);
|
|
104
|
+
}
|
|
105
|
+
return LazyComponent;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Renders the first route that matches the current URL pathname.
|
|
109
|
+
*
|
|
110
|
+
* @param props - The router props.
|
|
111
|
+
* @returns The matched lazy route component, the loading fallback, or `null`.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```tsx
|
|
115
|
+
* import routes from './routes'
|
|
116
|
+
*
|
|
117
|
+
* function App() {
|
|
118
|
+
* return <Router routes={routes} loading={<div>Loading...</div>} />
|
|
119
|
+
* }
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
function Router({ routes, loading = null }) {
|
|
123
|
+
const pathname = useUrlPath();
|
|
124
|
+
const normalizedRoutes = useMemo(() => normalizeRoutes(routes).map((route) => ({
|
|
125
|
+
...route,
|
|
126
|
+
Component: getLazyRouteComponent(route.component)
|
|
127
|
+
})), [routes]);
|
|
128
|
+
for (const { matches, Component } of normalizedRoutes) {
|
|
129
|
+
const params = matches(pathname);
|
|
130
|
+
if (!params) continue;
|
|
131
|
+
return /* @__PURE__ */ jsx(Suspense, {
|
|
132
|
+
fallback: loading,
|
|
133
|
+
children: /* @__PURE__ */ jsx(Component, { ...params })
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
export { Link, NavLink, Router, normalizeLegacyPathToRegexpSyntax, normalizeRoutes, pushUrl, replaceUrl, useUrlPath, useUrlSearchParams };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { match } from "path-to-regexp";
|
|
2
|
+
//#region src/lib/routes.ts
|
|
3
|
+
function toUrlPattern(pattern) {
|
|
4
|
+
if (typeof pattern !== "string") return pattern;
|
|
5
|
+
return new URLPattern({ pathname: pattern });
|
|
6
|
+
}
|
|
7
|
+
function decodeRouteParams(groups) {
|
|
8
|
+
const params = {};
|
|
9
|
+
for (const [key, value] of Object.entries(groups)) if (value == null) params[key] = void 0;
|
|
10
|
+
else params[key] = decodeURIComponent(String(value));
|
|
11
|
+
return params;
|
|
12
|
+
}
|
|
13
|
+
function stripLegacyParamPattern(pattern, startIndex) {
|
|
14
|
+
let depth = 1;
|
|
15
|
+
let endIndex = startIndex + 1;
|
|
16
|
+
while (endIndex < pattern.length && depth > 0) {
|
|
17
|
+
const char = pattern[endIndex];
|
|
18
|
+
if (char === "\\") {
|
|
19
|
+
endIndex += 2;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (char === "(") depth++;
|
|
23
|
+
else if (char === ")") depth--;
|
|
24
|
+
endIndex++;
|
|
25
|
+
}
|
|
26
|
+
return endIndex;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Converts legacy `path-to-regexp` syntax that is ignored by URL generation into syntax accepted
|
|
30
|
+
* by the current parser.
|
|
31
|
+
*
|
|
32
|
+
* @param pattern - The route pattern to normalize.
|
|
33
|
+
* @returns The pattern with custom regexp constraints stripped and optional group suffixes removed.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* normalizeLegacyPathToRegexpSyntax('/blog/:id(\\d+){-:title}?')
|
|
38
|
+
* // '/blog/:id{-:title}'
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
function normalizeLegacyPathToRegexpSyntax(pattern) {
|
|
44
|
+
let normalized = "";
|
|
45
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
46
|
+
const char = pattern[i];
|
|
47
|
+
if (char === "\\") {
|
|
48
|
+
normalized += char;
|
|
49
|
+
if (i + 1 < pattern.length) normalized += pattern[++i];
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (char === ":" || char === "*") {
|
|
53
|
+
normalized += char;
|
|
54
|
+
while (i + 1 < pattern.length && /[$_\p{ID_Continue}]/u.test(pattern[i + 1])) normalized += pattern[++i];
|
|
55
|
+
if (pattern[i + 1] === "(") i = stripLegacyParamPattern(pattern, i + 1) - 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (char === "}" && pattern[i + 1] === "?") {
|
|
59
|
+
normalized += char;
|
|
60
|
+
i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
normalized += char;
|
|
64
|
+
}
|
|
65
|
+
return normalized;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Normalizes routes into objects with a shared matcher implementation.
|
|
69
|
+
*
|
|
70
|
+
* @param routes - The route definitions to normalize.
|
|
71
|
+
* @returns Routes with stable `name`, `pattern`, `component`, and `matches` fields.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```tsx
|
|
75
|
+
* const normalized = normalizeRoutes(routes)
|
|
76
|
+
* const match = normalized[0]?.matches('/users/123')
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
function normalizeRoutes(routes) {
|
|
80
|
+
return routes.map((route) => {
|
|
81
|
+
const { name, pattern, component } = route;
|
|
82
|
+
if (typeof pattern !== "string") {
|
|
83
|
+
const urlPattern = toUrlPattern(pattern);
|
|
84
|
+
return {
|
|
85
|
+
name,
|
|
86
|
+
pattern,
|
|
87
|
+
component,
|
|
88
|
+
matches(pathname) {
|
|
89
|
+
const match = urlPattern.exec({ pathname });
|
|
90
|
+
if (!match) return null;
|
|
91
|
+
return match.pathname?.groups ?? {};
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (pattern === "*" || pattern === "/*") return {
|
|
96
|
+
name,
|
|
97
|
+
pattern,
|
|
98
|
+
component,
|
|
99
|
+
matches: (_pathname) => ({})
|
|
100
|
+
};
|
|
101
|
+
let matcher;
|
|
102
|
+
let urlPattern;
|
|
103
|
+
try {
|
|
104
|
+
matcher = match(pattern, { decode: decodeURIComponent });
|
|
105
|
+
} catch {
|
|
106
|
+
try {
|
|
107
|
+
urlPattern = toUrlPattern(pattern);
|
|
108
|
+
} catch {
|
|
109
|
+
matcher = match(normalizeLegacyPathToRegexpSyntax(pattern), { decode: decodeURIComponent });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
name,
|
|
114
|
+
pattern,
|
|
115
|
+
component,
|
|
116
|
+
matches(pathname) {
|
|
117
|
+
if (urlPattern) {
|
|
118
|
+
const match = urlPattern.exec({ pathname });
|
|
119
|
+
if (!match) return null;
|
|
120
|
+
return decodeRouteParams(match.pathname?.groups ?? {});
|
|
121
|
+
}
|
|
122
|
+
if (!matcher) return null;
|
|
123
|
+
const match = matcher(pathname);
|
|
124
|
+
if (!match) return null;
|
|
125
|
+
const params = {};
|
|
126
|
+
for (const [key, value] of Object.entries(match.params)) if (value == null) params[key] = void 0;
|
|
127
|
+
else if (Array.isArray(value)) params[key] = value.join("/");
|
|
128
|
+
else params[key] = String(value);
|
|
129
|
+
return params;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
export { normalizeRoutes as n, normalizeLegacyPathToRegexpSyntax as t };
|
package/examples/App.tsx
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { StrictMode } from 'react'
|
|
2
|
+
import { createRoot } from 'react-dom/client'
|
|
3
|
+
import { NavLink, Router, useUrlPath } from '../src'
|
|
4
|
+
import routes from './routes'
|
|
5
|
+
import * as routesGen from './routes.gen'
|
|
6
|
+
|
|
7
|
+
function CurrentPath() {
|
|
8
|
+
const path = useUrlPath()
|
|
9
|
+
return (
|
|
10
|
+
<div className="card">
|
|
11
|
+
<div
|
|
12
|
+
style={{
|
|
13
|
+
display: 'flex',
|
|
14
|
+
justifyContent: 'space-between',
|
|
15
|
+
gap: 12,
|
|
16
|
+
flexWrap: 'wrap',
|
|
17
|
+
}}
|
|
18
|
+
>
|
|
19
|
+
<div>
|
|
20
|
+
<div style={{ fontSize: 12, opacity: 0.8 }}>Current pathname</div>
|
|
21
|
+
<div>
|
|
22
|
+
<code>{path}</code>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
<div className="nav">
|
|
26
|
+
<NavLink activeClass="active" className="pill" to={routesGen.home()}>
|
|
27
|
+
Home
|
|
28
|
+
</NavLink>
|
|
29
|
+
<NavLink activeClass="active" className="pill" to={routesGen.login()}>
|
|
30
|
+
Login
|
|
31
|
+
</NavLink>
|
|
32
|
+
<NavLink
|
|
33
|
+
activeClass="active"
|
|
34
|
+
className="pill"
|
|
35
|
+
to={routesGen.match({ id: '123' })}
|
|
36
|
+
>
|
|
37
|
+
Match 123
|
|
38
|
+
</NavLink>
|
|
39
|
+
<NavLink
|
|
40
|
+
activeClass="active"
|
|
41
|
+
className="pill"
|
|
42
|
+
to={routesGen.match({ id: 'a/b' })}
|
|
43
|
+
>
|
|
44
|
+
Match a/b (encoded)
|
|
45
|
+
</NavLink>
|
|
46
|
+
<NavLink
|
|
47
|
+
activeClass="active"
|
|
48
|
+
className="pill"
|
|
49
|
+
to={routesGen.blogPost({ id: 123 })}
|
|
50
|
+
>
|
|
51
|
+
Blog 123
|
|
52
|
+
</NavLink>
|
|
53
|
+
<NavLink
|
|
54
|
+
activeClass="active"
|
|
55
|
+
className="pill"
|
|
56
|
+
to={routesGen.blogPost({ id: 123, title: 'hello world' })}
|
|
57
|
+
>
|
|
58
|
+
Blog 123 Title
|
|
59
|
+
</NavLink>
|
|
60
|
+
<NavLink activeClass="active" className="pill" to={routesGen.slowLoading()}>
|
|
61
|
+
Slow Loading
|
|
62
|
+
</NavLink>
|
|
63
|
+
<NavLink
|
|
64
|
+
activeClass="active"
|
|
65
|
+
className="pill"
|
|
66
|
+
match="prefix"
|
|
67
|
+
to={routesGen.fetchLoading()}
|
|
68
|
+
>
|
|
69
|
+
Fetch Loading
|
|
70
|
+
</NavLink>
|
|
71
|
+
<NavLink
|
|
72
|
+
activeClass="active"
|
|
73
|
+
className="pill"
|
|
74
|
+
to={routesGen.kitchenSink({
|
|
75
|
+
foo: 'a/b',
|
|
76
|
+
baz: 'c',
|
|
77
|
+
splat: ['x', 'y'],
|
|
78
|
+
})}
|
|
79
|
+
>
|
|
80
|
+
KitchenSink
|
|
81
|
+
</NavLink>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function Layout() {
|
|
89
|
+
return (
|
|
90
|
+
<div className="app">
|
|
91
|
+
<div className="card">
|
|
92
|
+
<h1 style={{ margin: 0 }}>rerouter examples</h1>
|
|
93
|
+
<div style={{ opacity: 0.8, marginTop: 8 }}>
|
|
94
|
+
Client-only dev server using <code>bun --hot examples/index.html</code>.
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<CurrentPath />
|
|
99
|
+
|
|
100
|
+
<div className="card">
|
|
101
|
+
<Router routes={routes} loading={<div>Loading route...</div>} />
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
createRoot(document.getElementById('root')!).render(
|
|
108
|
+
<StrictMode>
|
|
109
|
+
<Layout />
|
|
110
|
+
</StrictMode>,
|
|
111
|
+
)
|
|
@@ -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
|
+
</head>
|
|
63
|
+
<body>
|
|
64
|
+
<div id="root"></div>
|
|
65
|
+
<script type="module" src="./App.tsx"></script>
|
|
66
|
+
</body>
|
|
67
|
+
</html>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RouteComponent } from '../../src'
|
|
2
|
+
|
|
3
|
+
type BlogPostParams = {
|
|
4
|
+
id: string
|
|
5
|
+
title?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const BlogPost: RouteComponent<BlogPostParams> = ({ id, title }) => {
|
|
9
|
+
return (
|
|
10
|
+
<div>
|
|
11
|
+
<div>Blog post: {id}</div>
|
|
12
|
+
<div>Title: {title}</div>
|
|
13
|
+
</div>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default BlogPost
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { use } from 'react'
|
|
2
|
+
import { NavLink, Router, type RouteObject } from '../../src'
|
|
3
|
+
import FetchLoadingItem from './FetchLoadingItem'
|
|
4
|
+
import * as routesGen from '../routes.gen'
|
|
5
|
+
|
|
6
|
+
const itemRoutes: readonly RouteObject[] = [
|
|
7
|
+
{
|
|
8
|
+
pattern: '/fetch-loading/:id',
|
|
9
|
+
component: async () => ({ default: FetchLoadingItem }),
|
|
10
|
+
},
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
type FetchResult = {
|
|
14
|
+
message: string
|
|
15
|
+
loadedAt: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const fetchResult = new Promise<FetchResult>((resolve) => {
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
resolve({
|
|
21
|
+
message: 'The route component loaded immediately, then Suspense waited for data.',
|
|
22
|
+
loadedAt: new Date().toLocaleTimeString(),
|
|
23
|
+
})
|
|
24
|
+
}, 2000)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export default function FetchLoading() {
|
|
28
|
+
const result = use(fetchResult)
|
|
29
|
+
const itemIds = ['abc-123', 'invoice-456', 'with/slash']
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div>
|
|
33
|
+
<h2 style={{ marginTop: 0 }}>Fetch loading page</h2>
|
|
34
|
+
<div>{result.message}</div>
|
|
35
|
+
<div style={{ marginTop: 8, opacity: 0.8 }}>Loaded at {result.loadedAt}</div>
|
|
36
|
+
<div className="nav" style={{ marginTop: 16 }}>
|
|
37
|
+
{itemIds.map((id) => (
|
|
38
|
+
<NavLink
|
|
39
|
+
activeClass="active"
|
|
40
|
+
className="pill"
|
|
41
|
+
key={id}
|
|
42
|
+
to={routesGen.fetchLoadingItem({ id })}
|
|
43
|
+
>
|
|
44
|
+
Fetch {id}
|
|
45
|
+
</NavLink>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
<div style={{ marginTop: 16 }}>
|
|
49
|
+
<Router routes={itemRoutes} loading={<div>Loading item...</div>} />
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { use } from 'react'
|
|
2
|
+
|
|
3
|
+
type FetchLoadingItemProps = {
|
|
4
|
+
id: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
type FetchResult = {
|
|
8
|
+
id: string
|
|
9
|
+
message: string
|
|
10
|
+
loadedAt: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const fetchResults = new Map<string, Promise<FetchResult>>()
|
|
14
|
+
|
|
15
|
+
function fetchItem(id: string): Promise<FetchResult> {
|
|
16
|
+
let result = fetchResults.get(id)
|
|
17
|
+
if (!result) {
|
|
18
|
+
result = new Promise<FetchResult>((resolve) => {
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
resolve({
|
|
21
|
+
id,
|
|
22
|
+
message: `Fetched fake data for item ${id}.`,
|
|
23
|
+
loadedAt: new Date().toLocaleTimeString(),
|
|
24
|
+
})
|
|
25
|
+
}, 2000)
|
|
26
|
+
})
|
|
27
|
+
fetchResults.set(id, result)
|
|
28
|
+
}
|
|
29
|
+
return result
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function FetchLoadingItem({ id }: FetchLoadingItemProps) {
|
|
33
|
+
const result = use(fetchItem(id))
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div>
|
|
37
|
+
<h2 style={{ marginTop: 0 }}>Fetch loading item</h2>
|
|
38
|
+
<div>
|
|
39
|
+
URL param: <code>{result.id}</code>
|
|
40
|
+
</div>
|
|
41
|
+
<div style={{ marginTop: 8 }}>{result.message}</div>
|
|
42
|
+
<div style={{ marginTop: 8, opacity: 0.8 }}>Loaded at {result.loadedAt}</div>
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RouteComponent } from '../../src'
|
|
2
|
+
|
|
3
|
+
type KitchenSinkParams = {
|
|
4
|
+
foo: string
|
|
5
|
+
baz: string
|
|
6
|
+
splat: string
|
|
7
|
+
optional?: string
|
|
8
|
+
two?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const KitchenSink: RouteComponent<KitchenSinkParams> = ({ foo, baz, splat, optional, two }) => {
|
|
12
|
+
return (
|
|
13
|
+
<div>
|
|
14
|
+
<div>foo: {foo}</div>
|
|
15
|
+
<div>baz: {baz}</div>
|
|
16
|
+
<div>splat: {splat}</div>
|
|
17
|
+
<div>optional: {optional}</div>
|
|
18
|
+
<div>two: {two}</div>
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default KitchenSink
|