@funstack/router 0.0.10 → 1.1.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/dist/bin/skill-installer.mjs +2 -3
- package/dist/bin/skill-installer.mjs.map +1 -1
- package/dist/{route-p_gr5yPI.mjs → bindRoute-CQ2-ruTp.mjs} +11 -3
- package/dist/bindRoute-CQ2-ruTp.mjs.map +1 -0
- package/dist/{route-DRcgs0Pt.d.mts → bindRoute-DulMzi5X.d.mts} +169 -12
- package/dist/bindRoute-DulMzi5X.d.mts.map +1 -0
- package/dist/docs/ApiHooksPage.tsx +9 -22
- package/dist/docs/ApiTypesPage.tsx +17 -0
- package/dist/docs/ApiUtilitiesPage.tsx +62 -3
- package/dist/docs/ExamplesPage.tsx +3 -5
- package/dist/docs/FaqPage.tsx +84 -0
- package/dist/docs/GettingStartedPage.tsx +6 -3
- package/dist/docs/LearnActionsPage.tsx +228 -0
- package/dist/docs/LearnLoadersPage.tsx +320 -0
- package/dist/docs/LearnNavigationApiPage.tsx +1 -1
- package/dist/docs/LearnRouteDefinitionsPage.tsx +285 -0
- package/dist/docs/LearnRscPage.tsx +6 -0
- package/dist/docs/LearnSsgPage.tsx +3 -5
- package/dist/docs/index.md +4 -0
- package/dist/index.d.mts +36 -11
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +108 -71
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +2 -2
- package/dist/server.mjs +2 -3
- package/package.json +6 -5
- package/skills/funstack-router-knowledge/SKILL.md +1 -1
- package/dist/route-DRcgs0Pt.d.mts.map +0 -1
- package/dist/route-p_gr5yPI.mjs.map +0 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { CodeBlock } from "../components/CodeBlock.js";
|
|
2
|
+
|
|
3
|
+
export function LearnActionsPage() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="learn-content">
|
|
6
|
+
<h2>Form Actions</h2>
|
|
7
|
+
|
|
8
|
+
<p className="page-intro">
|
|
9
|
+
FUNSTACK Router can intercept <code>{"<form>"}</code> submissions and
|
|
10
|
+
run an <strong>action</strong> function on the client before navigation
|
|
11
|
+
occurs. This guide explains how actions work, when to use them, and
|
|
12
|
+
important considerations for progressive enhancement.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<div className="callout warning">
|
|
16
|
+
<p className="callout-title">Important: Progressive Enhancement</p>
|
|
17
|
+
<p>
|
|
18
|
+
A <code>{'<form method="post">'}</code> should work even{" "}
|
|
19
|
+
<strong>before JavaScript has loaded</strong>. The browser natively
|
|
20
|
+
submits POST forms to the server, so your server must be prepared to
|
|
21
|
+
handle these requests. The router’s <code>action</code> function
|
|
22
|
+
is a <strong>client-side shortcut</strong> that runs only after
|
|
23
|
+
hydration — it does not replace server-side form handling.
|
|
24
|
+
</p>
|
|
25
|
+
<p>
|
|
26
|
+
If your server cannot handle the POST request, users on slow
|
|
27
|
+
connections, users with JavaScript disabled, or users who submit the
|
|
28
|
+
form before hydration completes will experience a broken form. Always
|
|
29
|
+
ensure your server handles POST submissions for the same URL as a
|
|
30
|
+
baseline.
|
|
31
|
+
</p>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<section>
|
|
35
|
+
<h3>How It Works</h3>
|
|
36
|
+
<p>
|
|
37
|
+
When a <code>{'<form method="post">'}</code> is submitted, the router
|
|
38
|
+
matches the form’s destination URL against the route
|
|
39
|
+
definitions. If a matched route defines an <code>action</code>, the
|
|
40
|
+
router intercepts the submission via the Navigation API instead of
|
|
41
|
+
letting the browser send it to the server. The flow is:
|
|
42
|
+
</p>
|
|
43
|
+
<ol>
|
|
44
|
+
<li>
|
|
45
|
+
User submits a form with <code>method="post"</code>
|
|
46
|
+
</li>
|
|
47
|
+
<li>
|
|
48
|
+
The Navigation API fires a <code>navigate</code> event with{" "}
|
|
49
|
+
<code>formData</code>
|
|
50
|
+
</li>
|
|
51
|
+
<li>
|
|
52
|
+
The router finds the deepest matched route that has an{" "}
|
|
53
|
+
<code>action</code>
|
|
54
|
+
</li>
|
|
55
|
+
<li>
|
|
56
|
+
The <code>action</code> function runs with the form data wrapped in
|
|
57
|
+
a <code>Request</code>
|
|
58
|
+
</li>
|
|
59
|
+
<li>
|
|
60
|
+
The action’s return value is passed to the route’s{" "}
|
|
61
|
+
<code>loader</code> as <code>actionResult</code>
|
|
62
|
+
</li>
|
|
63
|
+
<li>The loader runs and the UI updates with fresh data</li>
|
|
64
|
+
</ol>
|
|
65
|
+
<p>
|
|
66
|
+
If the matched route does <strong>not</strong> define an action, the
|
|
67
|
+
router does not intercept the submission and the browser sends it to
|
|
68
|
+
the server as a normal POST request.
|
|
69
|
+
</p>
|
|
70
|
+
</section>
|
|
71
|
+
|
|
72
|
+
<section>
|
|
73
|
+
<h3>Defining an Action</h3>
|
|
74
|
+
<p>
|
|
75
|
+
Add an <code>action</code> function to your route definition. It
|
|
76
|
+
receives an <code>ActionArgs</code> object with the route params, a{" "}
|
|
77
|
+
<code>Request</code>, and an <code>AbortSignal</code>:
|
|
78
|
+
</p>
|
|
79
|
+
<CodeBlock language="tsx">{`import { route } from "@funstack/router";
|
|
80
|
+
|
|
81
|
+
const editRoute = route({
|
|
82
|
+
path: "/posts/:postId/edit",
|
|
83
|
+
action: async ({ params, request, signal }) => {
|
|
84
|
+
const formData = await request.formData();
|
|
85
|
+
const title = formData.get("title") as string;
|
|
86
|
+
const body = formData.get("body") as string;
|
|
87
|
+
|
|
88
|
+
const res = await fetch(\`/api/posts/\${params.postId}\`, {
|
|
89
|
+
method: "PUT",
|
|
90
|
+
headers: { "Content-Type": "application/json" },
|
|
91
|
+
body: JSON.stringify({ title, body }),
|
|
92
|
+
signal,
|
|
93
|
+
});
|
|
94
|
+
return res.json();
|
|
95
|
+
},
|
|
96
|
+
loader: async ({ params, actionResult, signal }) => {
|
|
97
|
+
// After a successful action, actionResult contains
|
|
98
|
+
// the return value. On normal navigations it is undefined.
|
|
99
|
+
const res = await fetch(\`/api/posts/\${params.postId}\`, { signal });
|
|
100
|
+
return res.json();
|
|
101
|
+
},
|
|
102
|
+
component: EditPostPage,
|
|
103
|
+
});`}</CodeBlock>
|
|
104
|
+
</section>
|
|
105
|
+
|
|
106
|
+
<section>
|
|
107
|
+
<h3>The Form</h3>
|
|
108
|
+
<p>
|
|
109
|
+
Use a standard HTML <code>{"<form>"}</code> element with{" "}
|
|
110
|
+
<code>method="post"</code>. There is no special form component needed
|
|
111
|
+
— the router hooks into the Navigation API which intercepts
|
|
112
|
+
native form submissions:
|
|
113
|
+
</p>
|
|
114
|
+
<CodeBlock language="tsx">{`function EditPostPage({ data, params }: EditPostProps) {
|
|
115
|
+
return (
|
|
116
|
+
<form method="post" action={\`/posts/\${params.postId}/edit\`}>
|
|
117
|
+
<input name="title" defaultValue={data.title} />
|
|
118
|
+
<textarea name="body" defaultValue={data.body} />
|
|
119
|
+
<button type="submit">Save</button>
|
|
120
|
+
</form>
|
|
121
|
+
);
|
|
122
|
+
}`}</CodeBlock>
|
|
123
|
+
<p>
|
|
124
|
+
Note that the form’s <code>action</code> attribute points to the
|
|
125
|
+
same URL that the route matches. This is essential for progressive
|
|
126
|
+
enhancement: before hydration, the browser will POST to this URL on
|
|
127
|
+
the server.
|
|
128
|
+
</p>
|
|
129
|
+
</section>
|
|
130
|
+
|
|
131
|
+
<section>
|
|
132
|
+
<h3>Progressive Enhancement in Detail</h3>
|
|
133
|
+
<p>
|
|
134
|
+
The action feature is designed as an{" "}
|
|
135
|
+
<strong>enhancement layer</strong>. The baseline behavior of a POST
|
|
136
|
+
form is a server round-trip, and the router’s action provides a
|
|
137
|
+
faster, client-side alternative once hydration is complete. This
|
|
138
|
+
means:
|
|
139
|
+
</p>
|
|
140
|
+
<ul>
|
|
141
|
+
<li>
|
|
142
|
+
<strong>Before hydration</strong> — The browser submits the
|
|
143
|
+
form to the server as a normal POST request. Your server must handle
|
|
144
|
+
it and return an appropriate response (typically a redirect or a
|
|
145
|
+
re-rendered page).
|
|
146
|
+
</li>
|
|
147
|
+
<li>
|
|
148
|
+
<strong>After hydration</strong> — The router intercepts the
|
|
149
|
+
submission, runs your <code>action</code> function on the client,
|
|
150
|
+
and updates the UI without a full page reload.
|
|
151
|
+
</li>
|
|
152
|
+
</ul>
|
|
153
|
+
<p>
|
|
154
|
+
Both paths should produce the same end result for the user. The client
|
|
155
|
+
action is a shortcut, not a replacement.
|
|
156
|
+
</p>
|
|
157
|
+
|
|
158
|
+
<div className="callout warning">
|
|
159
|
+
<p className="callout-title">
|
|
160
|
+
When your server cannot handle POST requests
|
|
161
|
+
</p>
|
|
162
|
+
<p>
|
|
163
|
+
If you are building a purely client-side application (e.g. a SPA
|
|
164
|
+
with no server-side form handling), consider using React 19’s{" "}
|
|
165
|
+
<code>{"<form action={fn}>"}</code> pattern instead. When a form
|
|
166
|
+
action is a <strong>function</strong> rather than a URL, the browser
|
|
167
|
+
will not attempt a server round-trip on submission. Note that in a
|
|
168
|
+
client-only app the form will not work until React hydrates, since
|
|
169
|
+
the function only exists in the JavaScript bundle.
|
|
170
|
+
</p>
|
|
171
|
+
<p>
|
|
172
|
+
In contrast, FUNSTACK Router’s <code>action</code> intercepts
|
|
173
|
+
URL-based form submissions. If the client has not hydrated yet, the
|
|
174
|
+
browser will POST to the URL, which will fail without server
|
|
175
|
+
handling.
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
</section>
|
|
179
|
+
|
|
180
|
+
<section>
|
|
181
|
+
<h3>Action Result and Loader</h3>
|
|
182
|
+
<p>
|
|
183
|
+
When a route defines both an <code>action</code> and a{" "}
|
|
184
|
+
<code>loader</code>, the loader runs after the action completes. The
|
|
185
|
+
action’s return value is passed to the loader via the{" "}
|
|
186
|
+
<code>actionResult</code> parameter:
|
|
187
|
+
</p>
|
|
188
|
+
<CodeBlock language="typescript">{`action: async ({ request }) => {
|
|
189
|
+
const formData = await request.formData();
|
|
190
|
+
// ... process form
|
|
191
|
+
return { success: true, message: "Saved!" };
|
|
192
|
+
},
|
|
193
|
+
loader: async ({ params, actionResult, signal }) => {
|
|
194
|
+
// actionResult is { success: true, message: "Saved!" }
|
|
195
|
+
// after the action, or undefined on normal navigation
|
|
196
|
+
const data = await fetchData(params.id, signal);
|
|
197
|
+
return { ...data, actionResult };
|
|
198
|
+
},`}</CodeBlock>
|
|
199
|
+
<p>
|
|
200
|
+
This lets your UI display feedback from the action (e.g. success
|
|
201
|
+
messages or validation errors) alongside the refreshed data.
|
|
202
|
+
</p>
|
|
203
|
+
</section>
|
|
204
|
+
|
|
205
|
+
<section>
|
|
206
|
+
<h3>Summary</h3>
|
|
207
|
+
<ul>
|
|
208
|
+
<li>
|
|
209
|
+
<code>action</code> intercepts POST form submissions on the client
|
|
210
|
+
after hydration
|
|
211
|
+
</li>
|
|
212
|
+
<li>
|
|
213
|
+
Your server must handle the same POST endpoint for progressive
|
|
214
|
+
enhancement
|
|
215
|
+
</li>
|
|
216
|
+
<li>
|
|
217
|
+
The action’s return value flows to the loader as{" "}
|
|
218
|
+
<code>actionResult</code>
|
|
219
|
+
</li>
|
|
220
|
+
<li>
|
|
221
|
+
For SPAs without server-side form handling, prefer React 19’s{" "}
|
|
222
|
+
<code>{"<form action={fn}>"}</code> pattern
|
|
223
|
+
</li>
|
|
224
|
+
</ul>
|
|
225
|
+
</section>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { CodeBlock } from "../components/CodeBlock.js";
|
|
2
|
+
|
|
3
|
+
export function LearnLoadersPage() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="learn-content">
|
|
6
|
+
<h2>How Loaders Run</h2>
|
|
7
|
+
|
|
8
|
+
<p className="page-intro">
|
|
9
|
+
Loaders fetch data for a route before the UI renders. This page explains
|
|
10
|
+
when loaders execute, how results are cached, and how different types of
|
|
11
|
+
navigation affect loader behavior.
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<section>
|
|
15
|
+
<h3>Defining a Loader</h3>
|
|
16
|
+
<p>
|
|
17
|
+
A loader is a function on a route definition that receives the route
|
|
18
|
+
params, a <code>Request</code>, and an <code>AbortSignal</code>. It
|
|
19
|
+
can return any value — typically a Promise from a fetch call:
|
|
20
|
+
</p>
|
|
21
|
+
<CodeBlock language="tsx">{`import { route } from "@funstack/router";
|
|
22
|
+
|
|
23
|
+
const userRoute = route({
|
|
24
|
+
path: "/users/:id",
|
|
25
|
+
loader: async ({ params, request, signal }) => {
|
|
26
|
+
const res = await fetch(\`/api/users/\${params.id}\`, { signal });
|
|
27
|
+
return res.json();
|
|
28
|
+
},
|
|
29
|
+
component: UserPage,
|
|
30
|
+
});`}</CodeBlock>
|
|
31
|
+
<p>
|
|
32
|
+
The component receives the loader’s return value as the{" "}
|
|
33
|
+
<code>data</code> prop. For async loaders this is a{" "}
|
|
34
|
+
<code>Promise</code>, which you unwrap with React’s{" "}
|
|
35
|
+
<code>use()</code> hook inside a <code>Suspense</code> boundary:
|
|
36
|
+
</p>
|
|
37
|
+
<CodeBlock language="tsx">{`import { use, Suspense } from "react";
|
|
38
|
+
|
|
39
|
+
function UserPage({ data }: { data: Promise<User> }) {
|
|
40
|
+
return (
|
|
41
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
42
|
+
<UserDetail data={data} />
|
|
43
|
+
</Suspense>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function UserDetail({ data }: { data: Promise<User> }) {
|
|
48
|
+
const user = use(data);
|
|
49
|
+
return <h1>{user.name}</h1>;
|
|
50
|
+
}`}</CodeBlock>
|
|
51
|
+
</section>
|
|
52
|
+
|
|
53
|
+
<section>
|
|
54
|
+
<h3>When Loaders Execute</h3>
|
|
55
|
+
<p>Loaders run at two points in the lifecycle:</p>
|
|
56
|
+
<ol>
|
|
57
|
+
<li>
|
|
58
|
+
<strong>Initial page load</strong> — When the Router first
|
|
59
|
+
renders, it matches the current URL against the route definitions
|
|
60
|
+
and executes all matching loaders immediately.
|
|
61
|
+
</li>
|
|
62
|
+
<li>
|
|
63
|
+
<strong>Navigation events</strong> — When the user navigates
|
|
64
|
+
(by clicking a link, submitting a form, or calling{" "}
|
|
65
|
+
<code>navigate()</code>), the Router matches the destination URL and
|
|
66
|
+
executes loaders for the matched routes.
|
|
67
|
+
</li>
|
|
68
|
+
</ol>
|
|
69
|
+
<p>
|
|
70
|
+
In both cases, all loaders in the matched route stack (parent and
|
|
71
|
+
child) run <strong>in parallel</strong>. The navigation completes once
|
|
72
|
+
every loader’s Promise has resolved.
|
|
73
|
+
</p>
|
|
74
|
+
</section>
|
|
75
|
+
|
|
76
|
+
<section>
|
|
77
|
+
<h3>Caching by Navigation Entry</h3>
|
|
78
|
+
<p>
|
|
79
|
+
Loader results are cached using the{" "}
|
|
80
|
+
<strong>navigation entry ID</strong> from the Navigation API. Each
|
|
81
|
+
time you navigate to a new URL, the browser creates a new navigation
|
|
82
|
+
entry with a unique ID. The Router uses this ID as the cache key, so:
|
|
83
|
+
</p>
|
|
84
|
+
<ul>
|
|
85
|
+
<li>
|
|
86
|
+
Re-renders of the same page <strong>do not</strong> re-execute
|
|
87
|
+
loaders — the cached result is returned.
|
|
88
|
+
</li>
|
|
89
|
+
<li>
|
|
90
|
+
Navigating to a new URL always creates a new entry and{" "}
|
|
91
|
+
<strong>always</strong> executes loaders, even if the URL is the
|
|
92
|
+
same as a previous navigation.
|
|
93
|
+
</li>
|
|
94
|
+
</ul>
|
|
95
|
+
<p>
|
|
96
|
+
This design ensures that loaders run exactly once per navigation while
|
|
97
|
+
preventing unnecessary re-fetches during React re-renders.
|
|
98
|
+
</p>
|
|
99
|
+
</section>
|
|
100
|
+
|
|
101
|
+
<section>
|
|
102
|
+
<h3>Navigation Types and Loader Behavior</h3>
|
|
103
|
+
<p>
|
|
104
|
+
Different types of navigation have different effects on whether
|
|
105
|
+
loaders run:
|
|
106
|
+
</p>
|
|
107
|
+
|
|
108
|
+
<h4>Push and Replace</h4>
|
|
109
|
+
<p>
|
|
110
|
+
A <strong>push</strong> navigation (the default when clicking a link
|
|
111
|
+
or calling <code>navigate()</code>) creates a new navigation entry.
|
|
112
|
+
Since the entry is new, loaders always execute. A{" "}
|
|
113
|
+
<strong>replace</strong> navigation behaves the same way — it
|
|
114
|
+
creates a new entry that replaces the current one, so loaders execute
|
|
115
|
+
fresh.
|
|
116
|
+
</p>
|
|
117
|
+
|
|
118
|
+
<h4>Traverse (Back / Forward)</h4>
|
|
119
|
+
<p>
|
|
120
|
+
When the user goes back or forward in history, the browser revisits an{" "}
|
|
121
|
+
<strong>existing</strong> navigation entry. Because the entry ID is
|
|
122
|
+
the same as when the page was originally visited, the cached loader
|
|
123
|
+
results are returned <strong>without re-executing</strong> the
|
|
124
|
+
loaders. This makes back/forward navigation instant.
|
|
125
|
+
</p>
|
|
126
|
+
|
|
127
|
+
<h4>Reload</h4>
|
|
128
|
+
<p>
|
|
129
|
+
A reload navigation stays on the same navigation entry, but the Router
|
|
130
|
+
generates a <strong>fresh cache key</strong> so that all loaders{" "}
|
|
131
|
+
<strong>re-execute</strong>. This is useful when you want to refresh
|
|
132
|
+
data without navigating away from the current page.
|
|
133
|
+
</p>
|
|
134
|
+
<p>
|
|
135
|
+
You can trigger a reload programmatically using the Navigation
|
|
136
|
+
API’s <code>navigation.reload()</code> method:
|
|
137
|
+
</p>
|
|
138
|
+
<CodeBlock language="tsx">{`function RefreshButton() {
|
|
139
|
+
return (
|
|
140
|
+
<button onClick={() => navigation.reload()}>
|
|
141
|
+
Refresh Data
|
|
142
|
+
</button>
|
|
143
|
+
);
|
|
144
|
+
}`}</CodeBlock>
|
|
145
|
+
<p>
|
|
146
|
+
During a reload, the old cached data remains available for the{" "}
|
|
147
|
+
<strong>pending UI</strong>. Because the Router wraps navigations in a
|
|
148
|
+
React transition, the previous UI stays on screen while the new data
|
|
149
|
+
loads. Once the new loaders resolve, the UI updates. This means users
|
|
150
|
+
see the existing content while the refresh is in progress, rather than
|
|
151
|
+
a blank screen or loading spinner.
|
|
152
|
+
</p>
|
|
153
|
+
<p>
|
|
154
|
+
Consecutive reloads work correctly — each reload increments an
|
|
155
|
+
internal counter to produce a unique cache key, and stale caches are
|
|
156
|
+
pruned automatically.
|
|
157
|
+
</p>
|
|
158
|
+
|
|
159
|
+
<h4>Form Submissions</h4>
|
|
160
|
+
<p>
|
|
161
|
+
When a <code>{'<form method="post">'}</code> is submitted, the Router
|
|
162
|
+
runs the matched route’s <code>action</code> first, then clears
|
|
163
|
+
the loader cache for the current entry and re-executes all loaders.
|
|
164
|
+
The action’s return value is passed to each loader as{" "}
|
|
165
|
+
<code>actionResult</code>. See the{" "}
|
|
166
|
+
<a href="/learn/actions">Form Actions</a> page for details.
|
|
167
|
+
</p>
|
|
168
|
+
</section>
|
|
169
|
+
|
|
170
|
+
<section>
|
|
171
|
+
<h3>Nested Route Loaders</h3>
|
|
172
|
+
<p>
|
|
173
|
+
When routes are nested, each route in the matched stack can define its
|
|
174
|
+
own loader. All loaders in the stack execute in parallel, and each
|
|
175
|
+
component receives its own loader’s result:
|
|
176
|
+
</p>
|
|
177
|
+
<CodeBlock language="tsx">{`const routes = [
|
|
178
|
+
route({
|
|
179
|
+
path: "/dashboard",
|
|
180
|
+
loader: () => fetchDashboardLayout(),
|
|
181
|
+
component: DashboardLayout,
|
|
182
|
+
children: [
|
|
183
|
+
route({
|
|
184
|
+
path: "/stats",
|
|
185
|
+
loader: () => fetchStats(),
|
|
186
|
+
component: StatsPage,
|
|
187
|
+
}),
|
|
188
|
+
],
|
|
189
|
+
}),
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
// When navigating to /dashboard/stats:
|
|
193
|
+
// → fetchDashboardLayout() and fetchStats() run in parallel
|
|
194
|
+
// → DashboardLayout receives the layout data
|
|
195
|
+
// → StatsPage receives the stats data`}</CodeBlock>
|
|
196
|
+
<p>
|
|
197
|
+
On reload, <strong>all</strong> loaders in the matched stack
|
|
198
|
+
re-execute, not just the deepest one.
|
|
199
|
+
</p>
|
|
200
|
+
</section>
|
|
201
|
+
|
|
202
|
+
<section>
|
|
203
|
+
<h3>Cache Cleanup</h3>
|
|
204
|
+
<p>
|
|
205
|
+
Cached loader results are automatically cleaned up when a navigation
|
|
206
|
+
entry is <strong>disposed</strong>. The browser disposes entries when
|
|
207
|
+
they are removed from the history stack (for example, when the user
|
|
208
|
+
navigates forward from a point in the middle of the history stack, the
|
|
209
|
+
entries ahead are discarded). The Router listens for these dispose
|
|
210
|
+
events and removes the corresponding cached data.
|
|
211
|
+
</p>
|
|
212
|
+
</section>
|
|
213
|
+
|
|
214
|
+
<section>
|
|
215
|
+
<h3>Error Handling</h3>
|
|
216
|
+
<p>
|
|
217
|
+
When a loader throws an error, the router catches it and re-throws it
|
|
218
|
+
during rendering of that route’s component. This means the error
|
|
219
|
+
can be caught by a React{" "}
|
|
220
|
+
<a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary">
|
|
221
|
+
Error Boundary
|
|
222
|
+
</a>{" "}
|
|
223
|
+
placed above the route in the component tree. For async loaders that
|
|
224
|
+
return a rejected promise, the error is surfaced when{" "}
|
|
225
|
+
<code>use(data)</code> is called, which is also caught by Error
|
|
226
|
+
Boundaries.
|
|
227
|
+
</p>
|
|
228
|
+
<p>
|
|
229
|
+
The recommended pattern is to place an error boundary in your{" "}
|
|
230
|
+
<strong>root layout route</strong>, wrapping the{" "}
|
|
231
|
+
<code>{"<Outlet />"}</code>. This catches errors from any loader in
|
|
232
|
+
the route tree while keeping the root layout (header, navigation,
|
|
233
|
+
etc.) intact:
|
|
234
|
+
</p>
|
|
235
|
+
<CodeBlock language="tsx">{`import { Router, route, Outlet } from "@funstack/router";
|
|
236
|
+
import { ErrorBoundary } from "./ErrorBoundary";
|
|
237
|
+
|
|
238
|
+
function RootLayout() {
|
|
239
|
+
return (
|
|
240
|
+
<div>
|
|
241
|
+
<header>My App</header>
|
|
242
|
+
<ErrorBoundary fallback={<div>Something went wrong.</div>}>
|
|
243
|
+
<Outlet />
|
|
244
|
+
</ErrorBoundary>
|
|
245
|
+
</div>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const routes = [
|
|
250
|
+
route({
|
|
251
|
+
path: "/",
|
|
252
|
+
component: RootLayout,
|
|
253
|
+
children: [
|
|
254
|
+
route({
|
|
255
|
+
path: "/",
|
|
256
|
+
component: HomePage,
|
|
257
|
+
}),
|
|
258
|
+
route({
|
|
259
|
+
path: "/users/:id",
|
|
260
|
+
component: UserPage,
|
|
261
|
+
loader: async ({ params }) => {
|
|
262
|
+
const res = await fetch(\`/api/users/\${params.id}\`);
|
|
263
|
+
if (!res.ok) throw new Error("Failed to load user");
|
|
264
|
+
return res.json();
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
267
|
+
],
|
|
268
|
+
}),
|
|
269
|
+
];`}</CodeBlock>
|
|
270
|
+
<p>
|
|
271
|
+
This works for both synchronous and asynchronous loaders. For sync
|
|
272
|
+
loaders, the router catches the error and re-throws it during route
|
|
273
|
+
rendering. For async loaders, the rejected promise naturally surfaces
|
|
274
|
+
through <code>use()</code>. Either way, Error Boundaries catch the
|
|
275
|
+
error.
|
|
276
|
+
</p>
|
|
277
|
+
<p>
|
|
278
|
+
You can also place error boundaries at more granular levels (e.g.,
|
|
279
|
+
wrapping a specific route’s <code>{"<Outlet />"}</code> or{" "}
|
|
280
|
+
<code>{"<Suspense>"}</code> boundary) for fine-grained error handling.
|
|
281
|
+
</p>
|
|
282
|
+
</section>
|
|
283
|
+
|
|
284
|
+
<section>
|
|
285
|
+
<h3>Summary</h3>
|
|
286
|
+
<table className="summary-table">
|
|
287
|
+
<thead>
|
|
288
|
+
<tr>
|
|
289
|
+
<th>Navigation type</th>
|
|
290
|
+
<th>Loaders run?</th>
|
|
291
|
+
<th>Why</th>
|
|
292
|
+
</tr>
|
|
293
|
+
</thead>
|
|
294
|
+
<tbody>
|
|
295
|
+
<tr>
|
|
296
|
+
<td>Push / Replace</td>
|
|
297
|
+
<td>Yes</td>
|
|
298
|
+
<td>New navigation entry, no cache</td>
|
|
299
|
+
</tr>
|
|
300
|
+
<tr>
|
|
301
|
+
<td>Traverse (Back / Forward)</td>
|
|
302
|
+
<td>No</td>
|
|
303
|
+
<td>Existing entry, cached results returned</td>
|
|
304
|
+
</tr>
|
|
305
|
+
<tr>
|
|
306
|
+
<td>Reload</td>
|
|
307
|
+
<td>Yes</td>
|
|
308
|
+
<td>Fresh cache key generated</td>
|
|
309
|
+
</tr>
|
|
310
|
+
<tr>
|
|
311
|
+
<td>Form submission (POST)</td>
|
|
312
|
+
<td>Yes</td>
|
|
313
|
+
<td>Cache cleared after action runs</td>
|
|
314
|
+
</tr>
|
|
315
|
+
</tbody>
|
|
316
|
+
</table>
|
|
317
|
+
</section>
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -210,7 +210,7 @@ function App() {
|
|
|
210
210
|
Ephemeral <code>info</code>
|
|
211
211
|
</strong>{" "}
|
|
212
212
|
— Pass non-persisted context data during navigation via{" "}
|
|
213
|
-
<code>navigation.navigate(url, {"{ info }"})
|
|
213
|
+
<code>navigation.navigate(url, {"{ info }"})</code>
|
|
214
214
|
</li>
|
|
215
215
|
<li>
|
|
216
216
|
<strong>navigation.entries()</strong> — Access the full
|