@funstack/router 0.0.7 → 0.0.9
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/dist/docs/ApiComponentsPage.tsx +33 -0
- package/dist/docs/ApiHooksPage.tsx +8 -3
- package/dist/docs/ApiTypesPage.tsx +56 -8
- package/dist/docs/ApiUtilitiesPage.tsx +26 -8
- package/dist/docs/ExamplesPage.tsx +445 -0
- package/dist/docs/GettingStartedPage.tsx +10 -33
- package/dist/docs/LearnNavigationApiPage.tsx +1 -4
- package/dist/docs/LearnNestedRoutesPage.tsx +8 -6
- package/dist/docs/LearnRscPage.tsx +77 -94
- package/dist/docs/LearnSsgPage.tsx +133 -0
- package/dist/docs/{LearnSsrPage.tsx → LearnSsrBasicPage.tsx} +44 -21
- package/dist/docs/LearnSsrWithLoadersPage.tsx +141 -0
- package/dist/docs/LearnTransitionsPage.tsx +80 -6
- package/dist/docs/LearnTypeSafetyPage.tsx +28 -22
- package/dist/docs/index.md +5 -2
- package/dist/index.d.mts +56 -25
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -26
- package/dist/index.mjs.map +1 -1
- package/dist/{route-ClVnhrQD.d.mts → route-DRcgs0Pt.d.mts} +61 -6
- package/dist/route-DRcgs0Pt.d.mts.map +1 -0
- package/dist/route-p_gr5yPI.mjs.map +1 -1
- package/dist/server.d.mts +1 -1
- package/package.json +1 -1
- package/dist/route-ClVnhrQD.d.mts.map +0 -1
|
@@ -55,6 +55,39 @@ export function ApiComponentsPage() {
|
|
|
55
55
|
the router will intercept the navigation.
|
|
56
56
|
</td>
|
|
57
57
|
</tr>
|
|
58
|
+
<tr>
|
|
59
|
+
<td>
|
|
60
|
+
<code>fallback</code>
|
|
61
|
+
</td>
|
|
62
|
+
<td>
|
|
63
|
+
<code>{'"none" | "static"'}</code>
|
|
64
|
+
</td>
|
|
65
|
+
<td>
|
|
66
|
+
Fallback mode when Navigation API is unavailable.{" "}
|
|
67
|
+
<code>"none"</code> (default) renders nothing;{" "}
|
|
68
|
+
<code>"static"</code> renders matched routes using{" "}
|
|
69
|
+
<code>window.location</code> without navigation interception
|
|
70
|
+
(MPA behavior).
|
|
71
|
+
</td>
|
|
72
|
+
</tr>
|
|
73
|
+
<tr>
|
|
74
|
+
<td>
|
|
75
|
+
<code>ssr</code>
|
|
76
|
+
</td>
|
|
77
|
+
<td>
|
|
78
|
+
<code>SSRConfig</code>
|
|
79
|
+
</td>
|
|
80
|
+
<td>
|
|
81
|
+
SSR configuration for route matching during server-side
|
|
82
|
+
rendering. Accepts an object with <code>path</code> (the
|
|
83
|
+
pathname to match against) and an optional{" "}
|
|
84
|
+
<code>runLoaders</code> boolean (defaults to <code>false</code>
|
|
85
|
+
). When <code>runLoaders</code> is <code>false</code>, routes
|
|
86
|
+
with loaders are skipped during SSR. Once the client hydrates,
|
|
87
|
+
the real URL from the Navigation API takes over. See the{" "}
|
|
88
|
+
<a href="/learn/ssr">SSR guide</a> for details.
|
|
89
|
+
</td>
|
|
90
|
+
</tr>
|
|
58
91
|
</tbody>
|
|
59
92
|
</table>
|
|
60
93
|
</article>
|
|
@@ -163,9 +163,14 @@ function MyComponent() {
|
|
|
163
163
|
<ul>
|
|
164
164
|
<li>
|
|
165
165
|
This hook is powered by React's <code>useTransition</code>. The
|
|
166
|
-
router wraps
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
router wraps navigations in <code>startTransition</code>, so React
|
|
167
|
+
defers rendering suspended routes and keeps the current UI visible.
|
|
168
|
+
</li>
|
|
169
|
+
<li>
|
|
170
|
+
Sync state updates via <code>setStateSync</code> and{" "}
|
|
171
|
+
<code>resetStateSync</code> bypass transitions entirely, so{" "}
|
|
172
|
+
<code>isPending</code> will <strong>not</strong> become{" "}
|
|
173
|
+
<code>true</code> for those updates.
|
|
169
174
|
</li>
|
|
170
175
|
<li>
|
|
171
176
|
The same <code>isPending</code> value is also available as a prop on
|
|
@@ -37,7 +37,10 @@ type Props = {
|
|
|
37
37
|
state: { scrollPosition: number } |
|
|
38
38
|
((prev: { scrollPosition: number } | undefined) => { scrollPosition: number })
|
|
39
39
|
) => void;
|
|
40
|
-
|
|
40
|
+
// Async reset via replace navigation
|
|
41
|
+
resetState: () => Promise<void>;
|
|
42
|
+
// Sync reset via updateCurrentEntry
|
|
43
|
+
resetStateSync: () => void;
|
|
41
44
|
info: unknown; // Ephemeral navigation info
|
|
42
45
|
isPending: boolean; // Whether a navigation transition is pending
|
|
43
46
|
};`}</CodeBlock>
|
|
@@ -48,11 +51,27 @@ type Props = {
|
|
|
48
51
|
<li>
|
|
49
52
|
<code>setState</code> - Async method that returns a Promise. Uses
|
|
50
53
|
replace navigation internally, ensuring the state update goes
|
|
51
|
-
through the full navigation cycle.
|
|
54
|
+
through the full navigation cycle. Because it performs a navigation,
|
|
55
|
+
it is wrapped in a React transition and may set{" "}
|
|
56
|
+
<code>isPending</code> to <code>true</code>.
|
|
52
57
|
</li>
|
|
53
58
|
<li>
|
|
54
59
|
<code>setStateSync</code> - Synchronous method that updates state
|
|
55
|
-
immediately using <code>navigation.updateCurrentEntry()</code>.
|
|
60
|
+
immediately using <code>navigation.updateCurrentEntry()</code>. This
|
|
61
|
+
is <strong>not</strong> a navigation, so it bypasses React
|
|
62
|
+
transitions and will never set <code>isPending</code> to{" "}
|
|
63
|
+
<code>true</code>.
|
|
64
|
+
</li>
|
|
65
|
+
<li>
|
|
66
|
+
<code>resetState</code> - Async method that clears navigation state
|
|
67
|
+
via replace navigation. Like <code>setState</code>, it goes through
|
|
68
|
+
a React transition and may set <code>isPending</code> to{" "}
|
|
69
|
+
<code>true</code>.
|
|
70
|
+
</li>
|
|
71
|
+
<li>
|
|
72
|
+
<code>resetStateSync</code> - Clears navigation state synchronously.
|
|
73
|
+
Like <code>setStateSync</code>, this bypasses React transitions and
|
|
74
|
+
will never set <code>isPending</code> to <code>true</code>.
|
|
56
75
|
</li>
|
|
57
76
|
</ul>
|
|
58
77
|
</article>
|
|
@@ -80,7 +99,8 @@ type Props = {
|
|
|
80
99
|
state: { selectedTab: string } | undefined;
|
|
81
100
|
setState: (state: ...) => Promise<void>; // async
|
|
82
101
|
setStateSync: (state: ...) => void; // sync
|
|
83
|
-
resetState: () => void
|
|
102
|
+
resetState: () => Promise<void>; // async
|
|
103
|
+
resetStateSync: () => void; // sync
|
|
84
104
|
info: unknown; // Ephemeral navigation info
|
|
85
105
|
isPending: boolean; // Whether a navigation transition is pending
|
|
86
106
|
};`}</CodeBlock>
|
|
@@ -238,8 +258,9 @@ type SettingsPageProps = RouteComponentPropsOf<typeof settingsRoute>;
|
|
|
238
258
|
<code>component: UserPage</code>): Router automatically injects
|
|
239
259
|
props (<code>params</code>, <code>state</code>,{" "}
|
|
240
260
|
<code>setState</code>, <code>setStateSync</code>,{" "}
|
|
241
|
-
<code>resetState</code>, <code>
|
|
242
|
-
and <code>data</code>
|
|
261
|
+
<code>resetState</code>, <code>resetStateSync</code>,{" "}
|
|
262
|
+
<code>info</code>, <code>isPending</code>, and <code>data</code>{" "}
|
|
263
|
+
when a loader is defined).
|
|
243
264
|
</li>
|
|
244
265
|
<li>
|
|
245
266
|
<strong>JSX element</strong> (e.g.,{" "}
|
|
@@ -268,15 +289,42 @@ routeState<{ tab: string }>()({
|
|
|
268
289
|
});`}</CodeBlock>
|
|
269
290
|
</article>
|
|
270
291
|
|
|
292
|
+
<article className="api-item">
|
|
293
|
+
<h3>
|
|
294
|
+
<code>ActionArgs</code>
|
|
295
|
+
</h3>
|
|
296
|
+
<p>
|
|
297
|
+
Arguments passed to route action functions. The <code>request</code>{" "}
|
|
298
|
+
carries the POST method and <code>FormData</code> body from the form
|
|
299
|
+
submission.
|
|
300
|
+
</p>
|
|
301
|
+
<CodeBlock language="typescript">{`interface ActionArgs<Params> {
|
|
302
|
+
params: Params;
|
|
303
|
+
request: Request; // method: "POST", body: FormData
|
|
304
|
+
signal: AbortSignal;
|
|
305
|
+
}`}</CodeBlock>
|
|
306
|
+
</article>
|
|
307
|
+
|
|
271
308
|
<article className="api-item">
|
|
272
309
|
<h3>
|
|
273
310
|
<code>LoaderArgs</code>
|
|
274
311
|
</h3>
|
|
275
|
-
<
|
|
276
|
-
|
|
312
|
+
<p>
|
|
313
|
+
Arguments passed to route loader functions. The optional{" "}
|
|
314
|
+
<code>actionResult</code> parameter contains the return value of the
|
|
315
|
+
route's action when the loader runs after a form submission.
|
|
316
|
+
</p>
|
|
317
|
+
<CodeBlock language="typescript">{`interface LoaderArgs<Params, ActionResult = undefined> {
|
|
318
|
+
params: Params;
|
|
277
319
|
request: Request;
|
|
278
320
|
signal: AbortSignal;
|
|
321
|
+
actionResult: ActionResult | undefined;
|
|
279
322
|
}`}</CodeBlock>
|
|
323
|
+
<p>
|
|
324
|
+
On normal navigations, <code>actionResult</code> is{" "}
|
|
325
|
+
<code>undefined</code>. After a form submission, it contains the
|
|
326
|
+
action's return value (awaited if the action is async).
|
|
327
|
+
</p>
|
|
280
328
|
</article>
|
|
281
329
|
|
|
282
330
|
<article className="api-item">
|
|
@@ -17,8 +17,9 @@ export function ApiUtilitiesPage() {
|
|
|
17
17
|
always receives a <code>params</code> prop with types inferred from
|
|
18
18
|
the path pattern. When a <code>loader</code> is defined, the component
|
|
19
19
|
also receives a <code>data</code> prop. Components also receive{" "}
|
|
20
|
-
<code>state</code>, <code>setState</code>, <code>setStateSync</code>,
|
|
21
|
-
and <code>
|
|
20
|
+
<code>state</code>, <code>setState</code>, <code>setStateSync</code>,{" "}
|
|
21
|
+
<code>resetState</code>, and <code>resetStateSync</code> props for
|
|
22
|
+
navigation state management.
|
|
22
23
|
</p>
|
|
23
24
|
<CodeBlock language="tsx">{`import { route } from "@funstack/router";
|
|
24
25
|
|
|
@@ -84,6 +85,20 @@ const myRoute = route({
|
|
|
84
85
|
(and <code>data</code> prop if loader is defined)
|
|
85
86
|
</td>
|
|
86
87
|
</tr>
|
|
88
|
+
<tr>
|
|
89
|
+
<td>
|
|
90
|
+
<code>action</code>
|
|
91
|
+
</td>
|
|
92
|
+
<td>
|
|
93
|
+
<code>(args: ActionArgs) => T</code>
|
|
94
|
+
</td>
|
|
95
|
+
<td>
|
|
96
|
+
Function to handle form submissions (POST navigations). Receives
|
|
97
|
+
a <code>Request</code> with <code>FormData</code> body. The
|
|
98
|
+
return value is passed to the loader as{" "}
|
|
99
|
+
<code>actionResult</code>.
|
|
100
|
+
</td>
|
|
101
|
+
</tr>
|
|
87
102
|
<tr>
|
|
88
103
|
<td>
|
|
89
104
|
<code>loader</code>
|
|
@@ -204,11 +219,15 @@ const productRoute = routeState<{ filter: string }>()({
|
|
|
204
219
|
<li>
|
|
205
220
|
<code>setState</code> - Async method that uses replace navigation.
|
|
206
221
|
Returns a Promise that resolves when the navigation completes.
|
|
222
|
+
Because it performs a navigation, the update is wrapped in a React
|
|
223
|
+
transition (may set <code>isPending</code> to <code>true</code>).
|
|
207
224
|
</li>
|
|
208
225
|
<li>
|
|
209
226
|
<code>setStateSync</code> - Sync method that uses{" "}
|
|
210
227
|
<code>navigation.updateCurrentEntry()</code>. Updates state
|
|
211
|
-
immediately without waiting.
|
|
228
|
+
immediately without waiting. This is not a navigation, so it
|
|
229
|
+
bypasses React transitions entirely (<code>isPending</code> stays{" "}
|
|
230
|
+
<code>false</code>).
|
|
212
231
|
</li>
|
|
213
232
|
</ul>
|
|
214
233
|
<p>Navigation state characteristics:</p>
|
|
@@ -276,8 +295,9 @@ const routes = [
|
|
|
276
295
|
<code>routeState</code> - Route definition helper with typed state
|
|
277
296
|
</li>
|
|
278
297
|
<li>
|
|
279
|
-
Types: <code>
|
|
280
|
-
<code>
|
|
298
|
+
Types: <code>ActionArgs</code>, <code>LoaderArgs</code>,{" "}
|
|
299
|
+
<code>RouteDefinition</code>, <code>PathParams</code>,{" "}
|
|
300
|
+
<code>RouteComponentProps</code>,{" "}
|
|
281
301
|
<code>RouteComponentPropsWithData</code>
|
|
282
302
|
</li>
|
|
283
303
|
</ul>
|
|
@@ -287,9 +307,7 @@ const routes = [
|
|
|
287
307
|
</p>
|
|
288
308
|
<p>
|
|
289
309
|
See the{" "}
|
|
290
|
-
<a href="/
|
|
291
|
-
React Server Components
|
|
292
|
-
</a>{" "}
|
|
310
|
+
<a href="/learn/react-server-components">React Server Components</a>{" "}
|
|
293
311
|
guide for a full walkthrough of using the server entry point.
|
|
294
312
|
</p>
|
|
295
313
|
</article>
|
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { CodeBlock } from "../components/CodeBlock.js";
|
|
2
|
+
|
|
3
|
+
export function ExamplesPage() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="page docs-page">
|
|
6
|
+
<h1>Examples</h1>
|
|
7
|
+
|
|
8
|
+
<section>
|
|
9
|
+
<h2>Basic Routing</h2>
|
|
10
|
+
<p>A simple example with home and about pages:</p>
|
|
11
|
+
<CodeBlock language="tsx">{`import { Router, route, Outlet, useNavigate } from "@funstack/router";
|
|
12
|
+
|
|
13
|
+
function Home() {
|
|
14
|
+
return <h1>Home Page</h1>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function About() {
|
|
18
|
+
return <h1>About Page</h1>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function Layout() {
|
|
22
|
+
const navigate = useNavigate();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div>
|
|
26
|
+
<nav>
|
|
27
|
+
<button onClick={() => navigate("/")}>Home</button>
|
|
28
|
+
<button onClick={() => navigate("/about")}>About</button>
|
|
29
|
+
</nav>
|
|
30
|
+
<Outlet />
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const routes = [
|
|
36
|
+
route({
|
|
37
|
+
path: "/",
|
|
38
|
+
component: Layout,
|
|
39
|
+
children: [
|
|
40
|
+
route({ path: "/", component: Home }),
|
|
41
|
+
route({ path: "/about", component: About }),
|
|
42
|
+
],
|
|
43
|
+
}),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function App() {
|
|
47
|
+
return <Router routes={routes} />;
|
|
48
|
+
}`}</CodeBlock>
|
|
49
|
+
</section>
|
|
50
|
+
|
|
51
|
+
<section>
|
|
52
|
+
<h2>Dynamic Routes with Params</h2>
|
|
53
|
+
<p>
|
|
54
|
+
Handle dynamic URL segments with parameters. Components receive params
|
|
55
|
+
via props:
|
|
56
|
+
</p>
|
|
57
|
+
<CodeBlock language="tsx">{`import { route } from "@funstack/router";
|
|
58
|
+
|
|
59
|
+
function UserProfile({ params }: { params: { userId: string } }) {
|
|
60
|
+
return <h1>Viewing user: {params.userId}</h1>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function PostDetail({
|
|
64
|
+
params,
|
|
65
|
+
}: {
|
|
66
|
+
params: { userId: string; postId: string };
|
|
67
|
+
}) {
|
|
68
|
+
return (
|
|
69
|
+
<div>
|
|
70
|
+
<h1>Post {params.postId}</h1>
|
|
71
|
+
<p>By user {params.userId}</p>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const routes = [
|
|
77
|
+
route({
|
|
78
|
+
path: "/users/:userId",
|
|
79
|
+
component: UserProfile,
|
|
80
|
+
}),
|
|
81
|
+
route({
|
|
82
|
+
path: "/users/:userId/posts/:postId",
|
|
83
|
+
component: PostDetail,
|
|
84
|
+
}),
|
|
85
|
+
];`}</CodeBlock>
|
|
86
|
+
</section>
|
|
87
|
+
|
|
88
|
+
<section>
|
|
89
|
+
<h2>Nested Routes</h2>
|
|
90
|
+
<p>Create complex layouts with nested routing:</p>
|
|
91
|
+
<CodeBlock language="tsx">{`import { route, Outlet } from "@funstack/router";
|
|
92
|
+
|
|
93
|
+
function Dashboard() {
|
|
94
|
+
return (
|
|
95
|
+
<div className="dashboard">
|
|
96
|
+
<aside>
|
|
97
|
+
<nav>Dashboard Menu</nav>
|
|
98
|
+
</aside>
|
|
99
|
+
<main>
|
|
100
|
+
<Outlet />
|
|
101
|
+
</main>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function DashboardHome() {
|
|
107
|
+
return <h2>Dashboard Overview</h2>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function DashboardSettings() {
|
|
111
|
+
return <h2>Settings</h2>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function DashboardProfile() {
|
|
115
|
+
return <h2>Your Profile</h2>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const dashboardRoutes = route({
|
|
119
|
+
path: "/dashboard",
|
|
120
|
+
component: Dashboard,
|
|
121
|
+
children: [
|
|
122
|
+
route({ path: "/", component: DashboardHome }),
|
|
123
|
+
route({ path: "/settings", component: DashboardSettings }),
|
|
124
|
+
route({ path: "/profile", component: DashboardProfile }),
|
|
125
|
+
],
|
|
126
|
+
});`}</CodeBlock>
|
|
127
|
+
</section>
|
|
128
|
+
|
|
129
|
+
<section>
|
|
130
|
+
<h2>Data Loading</h2>
|
|
131
|
+
<p>
|
|
132
|
+
Load data with loaders. When a loader returns a Promise, the component
|
|
133
|
+
receives that Promise and uses React's <code>use</code> hook to unwrap
|
|
134
|
+
it. Use <code><Suspense></code> within your pages or layouts to
|
|
135
|
+
handle loading states.
|
|
136
|
+
</p>
|
|
137
|
+
<CodeBlock language="tsx">{`import { use, Suspense } from "react";
|
|
138
|
+
import { route } from "@funstack/router";
|
|
139
|
+
|
|
140
|
+
interface Post {
|
|
141
|
+
id: number;
|
|
142
|
+
title: string;
|
|
143
|
+
body: string;
|
|
144
|
+
userId: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// When the loader returns a Promise, the component receives that Promise.
|
|
148
|
+
// Use React's \`use\` hook to unwrap the Promise.
|
|
149
|
+
function UserPostsContent({
|
|
150
|
+
data,
|
|
151
|
+
params,
|
|
152
|
+
}: {
|
|
153
|
+
data: Promise<Post[]>;
|
|
154
|
+
params: { userId: string };
|
|
155
|
+
}) {
|
|
156
|
+
const posts = use(data);
|
|
157
|
+
return (
|
|
158
|
+
<div>
|
|
159
|
+
<h2>Posts by user {params.userId}</h2>
|
|
160
|
+
<ul>
|
|
161
|
+
{posts.map((post) => (
|
|
162
|
+
<li key={post.id}>{post.title}</li>
|
|
163
|
+
))}
|
|
164
|
+
</ul>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Wrap the content with Suspense at the page level
|
|
170
|
+
function UserPosts(props: {
|
|
171
|
+
data: Promise<Post[]>;
|
|
172
|
+
params: { userId: string };
|
|
173
|
+
}) {
|
|
174
|
+
return (
|
|
175
|
+
<Suspense fallback={<div>Loading posts...</div>}>
|
|
176
|
+
<UserPostsContent {...props} />
|
|
177
|
+
</Suspense>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const userPostsRoute = route({
|
|
182
|
+
path: "/users/:userId/posts",
|
|
183
|
+
component: UserPosts,
|
|
184
|
+
loader: async ({ params }): Promise<Post[]> => {
|
|
185
|
+
const response = await fetch(
|
|
186
|
+
\`https://jsonplaceholder.typicode.com/posts?userId=\${params.userId}\`
|
|
187
|
+
);
|
|
188
|
+
return response.json();
|
|
189
|
+
},
|
|
190
|
+
});`}</CodeBlock>
|
|
191
|
+
</section>
|
|
192
|
+
|
|
193
|
+
<section>
|
|
194
|
+
<h2>Form Submissions</h2>
|
|
195
|
+
<p>
|
|
196
|
+
Handle form POST submissions with route actions. The action receives
|
|
197
|
+
the form data, and its return value flows to the loader via{" "}
|
|
198
|
+
<code>actionResult</code>. Native <code><form></code> elements
|
|
199
|
+
work out of the box — no wrapper component needed.
|
|
200
|
+
</p>
|
|
201
|
+
<CodeBlock language="tsx">{`import { route, type RouteComponentPropsOf } from "@funstack/router";
|
|
202
|
+
|
|
203
|
+
// Define a route with both action and loader
|
|
204
|
+
const editUserRoute = route({
|
|
205
|
+
id: "editUser",
|
|
206
|
+
path: "/users/:userId/edit",
|
|
207
|
+
action: async ({ request, params, signal }) => {
|
|
208
|
+
const formData = await request.formData();
|
|
209
|
+
const response = await fetch(\`/api/users/\${params.userId}\`, {
|
|
210
|
+
method: "PUT",
|
|
211
|
+
body: formData,
|
|
212
|
+
signal,
|
|
213
|
+
});
|
|
214
|
+
return response.json() as Promise<{ success: boolean; error?: string }>;
|
|
215
|
+
},
|
|
216
|
+
loader: async ({ params, signal, actionResult }) => {
|
|
217
|
+
const user = await fetchUser(params.userId, signal);
|
|
218
|
+
return {
|
|
219
|
+
user,
|
|
220
|
+
// actionResult is undefined on normal navigation,
|
|
221
|
+
// contains the action's return value after form submission
|
|
222
|
+
updateResult: actionResult ?? null,
|
|
223
|
+
};
|
|
224
|
+
},
|
|
225
|
+
component: EditUserPage,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Component receives data from the loader (which includes the action result)
|
|
229
|
+
function EditUserPage({ data, isPending }: RouteComponentPropsOf<typeof editUserRoute>) {
|
|
230
|
+
return (
|
|
231
|
+
<form method="post">
|
|
232
|
+
{data.updateResult?.error && (
|
|
233
|
+
<p className="error">{data.updateResult.error}</p>
|
|
234
|
+
)}
|
|
235
|
+
{data.updateResult?.success && (
|
|
236
|
+
<p className="success">User updated successfully!</p>
|
|
237
|
+
)}
|
|
238
|
+
<input name="name" defaultValue={data.user.name} />
|
|
239
|
+
<input name="email" defaultValue={data.user.email} />
|
|
240
|
+
<button type="submit" disabled={isPending}>
|
|
241
|
+
{isPending ? "Saving..." : "Save"}
|
|
242
|
+
</button>
|
|
243
|
+
</form>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Route with action only (pure side effect, no data for component)
|
|
248
|
+
const deleteRoute = route({
|
|
249
|
+
path: "/users/:userId/delete",
|
|
250
|
+
action: async ({ params, signal }) => {
|
|
251
|
+
await fetch(\`/api/users/\${params.userId}\`, {
|
|
252
|
+
method: "DELETE",
|
|
253
|
+
signal,
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
component: DeleteConfirmation,
|
|
257
|
+
});`}</CodeBlock>
|
|
258
|
+
<p>Key behaviors:</p>
|
|
259
|
+
<ul>
|
|
260
|
+
<li>
|
|
261
|
+
Actions handle POST form submissions; loaders handle GET navigations
|
|
262
|
+
</li>
|
|
263
|
+
<li>Action results are never cached — each submission runs fresh</li>
|
|
264
|
+
<li>
|
|
265
|
+
After an action completes, all matched loaders are revalidated
|
|
266
|
+
</li>
|
|
267
|
+
<li>
|
|
268
|
+
POST submissions to routes without an action are not intercepted
|
|
269
|
+
(browser handles normally)
|
|
270
|
+
</li>
|
|
271
|
+
<li>
|
|
272
|
+
The deepest matched route with an action handles the submission
|
|
273
|
+
</li>
|
|
274
|
+
</ul>
|
|
275
|
+
</section>
|
|
276
|
+
|
|
277
|
+
<section>
|
|
278
|
+
<h2>Search Parameters</h2>
|
|
279
|
+
<p>Work with URL query parameters:</p>
|
|
280
|
+
<CodeBlock language="tsx">{`import { useSearchParams } from "@funstack/router";
|
|
281
|
+
|
|
282
|
+
function ProductList() {
|
|
283
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
284
|
+
|
|
285
|
+
const category = searchParams.get("category") || "all";
|
|
286
|
+
const sortBy = searchParams.get("sort") || "name";
|
|
287
|
+
|
|
288
|
+
const handleCategoryChange = (newCategory: string) => {
|
|
289
|
+
setSearchParams({
|
|
290
|
+
category: newCategory,
|
|
291
|
+
sort: sortBy,
|
|
292
|
+
});
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const handleSortChange = (newSort: string) => {
|
|
296
|
+
setSearchParams({
|
|
297
|
+
category,
|
|
298
|
+
sort: newSort,
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<div>
|
|
304
|
+
<select value={category} onChange={(e) => handleCategoryChange(e.target.value)}>
|
|
305
|
+
<option value="all">All Categories</option>
|
|
306
|
+
<option value="electronics">Electronics</option>
|
|
307
|
+
<option value="clothing">Clothing</option>
|
|
308
|
+
</select>
|
|
309
|
+
|
|
310
|
+
<select value={sortBy} onChange={(e) => handleSortChange(e.target.value)}>
|
|
311
|
+
<option value="name">Sort by Name</option>
|
|
312
|
+
<option value="price">Sort by Price</option>
|
|
313
|
+
</select>
|
|
314
|
+
|
|
315
|
+
<p>Showing {category} products, sorted by {sortBy}</p>
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}`}</CodeBlock>
|
|
319
|
+
</section>
|
|
320
|
+
|
|
321
|
+
<section>
|
|
322
|
+
<h2>Pathless Routes</h2>
|
|
323
|
+
<p>
|
|
324
|
+
Routes without a <code>path</code> are called "pathless routes". They
|
|
325
|
+
always match and don't consume any pathname, making them ideal for
|
|
326
|
+
layout wrappers that don't affect the URL structure.
|
|
327
|
+
</p>
|
|
328
|
+
<CodeBlock language="tsx">{`import { route, Outlet } from "@funstack/router";
|
|
329
|
+
|
|
330
|
+
// A layout wrapper that provides authentication context
|
|
331
|
+
function AuthLayout() {
|
|
332
|
+
return (
|
|
333
|
+
<AuthProvider>
|
|
334
|
+
<Outlet />
|
|
335
|
+
</AuthProvider>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// A layout wrapper that provides a sidebar
|
|
340
|
+
function DashboardLayout() {
|
|
341
|
+
return (
|
|
342
|
+
<div className="dashboard">
|
|
343
|
+
<Sidebar />
|
|
344
|
+
<main>
|
|
345
|
+
<Outlet />
|
|
346
|
+
</main>
|
|
347
|
+
</div>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const routes = [
|
|
352
|
+
route({
|
|
353
|
+
path: "/",
|
|
354
|
+
component: RootLayout,
|
|
355
|
+
children: [
|
|
356
|
+
route({ path: "/", component: HomePage }),
|
|
357
|
+
route({ path: "/about", component: AboutPage }),
|
|
358
|
+
// Pathless route wraps authenticated pages
|
|
359
|
+
route({
|
|
360
|
+
component: AuthLayout,
|
|
361
|
+
children: [
|
|
362
|
+
// Another pathless route for dashboard layout
|
|
363
|
+
route({
|
|
364
|
+
component: DashboardLayout,
|
|
365
|
+
children: [
|
|
366
|
+
route({ path: "/dashboard", component: DashboardHome }),
|
|
367
|
+
route({ path: "/dashboard/settings", component: Settings }),
|
|
368
|
+
route({ path: "/dashboard/profile", component: Profile }),
|
|
369
|
+
],
|
|
370
|
+
}),
|
|
371
|
+
],
|
|
372
|
+
}),
|
|
373
|
+
],
|
|
374
|
+
}),
|
|
375
|
+
];`}</CodeBlock>
|
|
376
|
+
<p>Key behaviors of pathless routes:</p>
|
|
377
|
+
<ul>
|
|
378
|
+
<li>
|
|
379
|
+
<strong>Always match</strong> - They don't participate in path
|
|
380
|
+
matching
|
|
381
|
+
</li>
|
|
382
|
+
<li>
|
|
383
|
+
<strong>Consume no pathname</strong> - Children receive the full
|
|
384
|
+
remaining pathname
|
|
385
|
+
</li>
|
|
386
|
+
<li>
|
|
387
|
+
<strong>No params</strong> - The <code>params</code> prop is always
|
|
388
|
+
an empty object
|
|
389
|
+
</li>
|
|
390
|
+
<li>
|
|
391
|
+
<strong>Can have loaders</strong> - Pathless routes can still load
|
|
392
|
+
data
|
|
393
|
+
</li>
|
|
394
|
+
</ul>
|
|
395
|
+
<h3>Catch-All Routes</h3>
|
|
396
|
+
<p>
|
|
397
|
+
Use <code>path: "/*"</code> at the end of a route list to catch any
|
|
398
|
+
unmatched paths:
|
|
399
|
+
</p>
|
|
400
|
+
<CodeBlock language="tsx">{`const routes = [
|
|
401
|
+
route({ path: "/", component: HomePage }),
|
|
402
|
+
route({ path: "/about", component: AboutPage }),
|
|
403
|
+
// Catch-all: matches any unmatched path
|
|
404
|
+
route({ path: "/*", component: NotFoundPage }),
|
|
405
|
+
];`}</CodeBlock>
|
|
406
|
+
</section>
|
|
407
|
+
|
|
408
|
+
<section>
|
|
409
|
+
<h2>Navigation Callback</h2>
|
|
410
|
+
<p>
|
|
411
|
+
React to navigation events. The callback receives the{" "}
|
|
412
|
+
<code>NavigateEvent</code> from the Navigation API and an info object
|
|
413
|
+
containing matched routes and whether the navigation will be
|
|
414
|
+
intercepted:
|
|
415
|
+
</p>
|
|
416
|
+
<CodeBlock language="tsx">{`import { Router, route, type OnNavigateCallback } from "@funstack/router";
|
|
417
|
+
|
|
418
|
+
function App() {
|
|
419
|
+
const handleNavigate: OnNavigateCallback = (event, info) => {
|
|
420
|
+
// Track page views
|
|
421
|
+
const url = new URL(event.destination.url);
|
|
422
|
+
analytics.track("page_view", {
|
|
423
|
+
path: url.pathname,
|
|
424
|
+
search: url.search,
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// info.matches contains the matched routes (or null if no match)
|
|
428
|
+
// info.intercepting indicates if the router will handle this navigation
|
|
429
|
+
console.log("Matched routes:", info.matches);
|
|
430
|
+
console.log("Intercepting:", info.intercepting);
|
|
431
|
+
|
|
432
|
+
// You can call event.preventDefault() to cancel the navigation
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
return (
|
|
436
|
+
<Router
|
|
437
|
+
routes={routes}
|
|
438
|
+
onNavigate={handleNavigate}
|
|
439
|
+
/>
|
|
440
|
+
);
|
|
441
|
+
}`}</CodeBlock>
|
|
442
|
+
</section>
|
|
443
|
+
</div>
|
|
444
|
+
);
|
|
445
|
+
}
|