@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.
@@ -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&rsquo;s <code>action</code> function
22
+ is a <strong>client-side shortcut</strong> that runs only after
23
+ hydration &mdash; 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&rsquo;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&rsquo;s return value is passed to the route&rsquo;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
+ &mdash; 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&rsquo;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&rsquo;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> &mdash; 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> &mdash; 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&rsquo;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&rsquo;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&rsquo;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&rsquo;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&rsquo;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 &mdash; 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&rsquo;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&rsquo;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> &mdash; 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> &mdash; 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&rsquo;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 &mdash; 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 &mdash; 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&rsquo;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 &mdash; 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&rsquo;s <code>action</code> first, then clears
163
+ the loader cache for the current entry and re-executes all loaders.
164
+ The action&rsquo;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&rsquo;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&rsquo;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&rsquo;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
  &mdash; Pass non-persisted context data during navigation via{" "}
213
- <code>navigation.navigate(url, {"{ info }"}))</code>
213
+ <code>navigation.navigate(url, {"{ info }"})</code>
214
214
  </li>
215
215
  <li>
216
216
  <strong>navigation.entries()</strong> &mdash; Access the full