@funstack/router 0.0.6 → 0.0.7
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.d.mts +1 -0
- package/dist/bin/skill-installer.mjs +13 -0
- package/dist/bin/skill-installer.mjs.map +1 -0
- package/dist/docs/ApiComponentsPage.tsx +85 -0
- package/dist/docs/ApiHooksPage.tsx +323 -0
- package/dist/docs/ApiTypesPage.tsx +310 -0
- package/dist/docs/ApiUtilitiesPage.tsx +298 -0
- package/dist/docs/GettingStartedPage.tsx +201 -0
- package/dist/docs/LearnNavigationApiPage.tsx +255 -0
- package/dist/docs/LearnNestedRoutesPage.tsx +601 -0
- package/dist/docs/LearnRscPage.tsx +293 -0
- package/dist/docs/LearnSsrPage.tsx +180 -0
- package/dist/docs/LearnTransitionsPage.tsx +146 -0
- package/dist/docs/LearnTypeSafetyPage.tsx +522 -0
- package/dist/docs/index.md +21 -0
- package/dist/index.d.mts +1 -1
- package/dist/{route-Bc8BUlhv.d.mts → route-ClVnhrQD.d.mts} +1 -1
- package/dist/{route-Bc8BUlhv.d.mts.map → route-ClVnhrQD.d.mts.map} +1 -1
- package/dist/server.d.mts +1 -1
- package/package.json +13 -4
- package/skills/funstack-router-knowledge/SKILL.md +21 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { CodeBlock } from "../components/CodeBlock.js";
|
|
2
|
+
|
|
3
|
+
export function LearnRscPage() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="learn-content">
|
|
6
|
+
<h2>React Server Components</h2>
|
|
7
|
+
|
|
8
|
+
<p className="page-intro">
|
|
9
|
+
FUNSTACK Router is designed to work with React Server Components (RSC).
|
|
10
|
+
The package provides a dedicated server entry point so that route
|
|
11
|
+
definitions can live in server modules, keeping client bundle sizes
|
|
12
|
+
small.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<section>
|
|
16
|
+
<h3>Why RSC Compatibility Matters</h3>
|
|
17
|
+
<p>
|
|
18
|
+
In an RSC architecture, the module graph is split into{" "}
|
|
19
|
+
<strong>server modules</strong> and <strong>client modules</strong>.
|
|
20
|
+
Server modules run at build time (or on the server) and are never sent
|
|
21
|
+
to the browser. Client modules are marked with the{" "}
|
|
22
|
+
<code>"use client"</code> directive and are included in the browser
|
|
23
|
+
bundle.
|
|
24
|
+
</p>
|
|
25
|
+
<p>
|
|
26
|
+
The main <code>@funstack/router</code> entry point is marked{" "}
|
|
27
|
+
<code>"use client"</code> because it exports components and hooks that
|
|
28
|
+
depend on browser APIs (the Navigation API, React context, etc.). This
|
|
29
|
+
means importing from <code>@funstack/router</code> in a server module
|
|
30
|
+
would pull the entire router into the client bundle — defeating
|
|
31
|
+
the purpose of RSC.
|
|
32
|
+
</p>
|
|
33
|
+
<p>
|
|
34
|
+
To solve this, the package provides a separate entry point:{" "}
|
|
35
|
+
<code>@funstack/router/server</code>.
|
|
36
|
+
</p>
|
|
37
|
+
</section>
|
|
38
|
+
|
|
39
|
+
<section>
|
|
40
|
+
<h3>
|
|
41
|
+
The <code>@funstack/router/server</code> Entry Point
|
|
42
|
+
</h3>
|
|
43
|
+
<p>
|
|
44
|
+
The server entry point exports the <code>route()</code> and{" "}
|
|
45
|
+
<code>routeState()</code> helper functions <em>without</em> the{" "}
|
|
46
|
+
<code>"use client"</code> directive. This lets you define your route
|
|
47
|
+
tree in a server module:
|
|
48
|
+
</p>
|
|
49
|
+
<CodeBlock language="tsx">{`// App.tsx — a Server Component (no "use client" directive)
|
|
50
|
+
import { Router } from "@funstack/router";
|
|
51
|
+
import { route } from "@funstack/router/server";
|
|
52
|
+
|
|
53
|
+
const routes = [
|
|
54
|
+
route({
|
|
55
|
+
path: "/",
|
|
56
|
+
component: Layout,
|
|
57
|
+
children: [
|
|
58
|
+
route({ path: "/", component: HomePage }),
|
|
59
|
+
route({ path: "/about", component: AboutPage }),
|
|
60
|
+
],
|
|
61
|
+
}),
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
export default function App() {
|
|
65
|
+
return <Router routes={routes} />;
|
|
66
|
+
}`}</CodeBlock>
|
|
67
|
+
<p>
|
|
68
|
+
In this example, <code>App</code> is a server component. It builds the
|
|
69
|
+
route array using <code>route()</code> from{" "}
|
|
70
|
+
<code>@funstack/router/server</code> and renders the{" "}
|
|
71
|
+
<code>Router</code> component from <code>@funstack/router</code>.
|
|
72
|
+
Since <code>Router</code> is a client component, the RSC bundler
|
|
73
|
+
handles the client boundary automatically.
|
|
74
|
+
</p>
|
|
75
|
+
<h4>What the server entry point exports</h4>
|
|
76
|
+
<ul>
|
|
77
|
+
<li>
|
|
78
|
+
<code>route</code> — Route definition helper (same API as the
|
|
79
|
+
main entry point)
|
|
80
|
+
</li>
|
|
81
|
+
<li>
|
|
82
|
+
<code>routeState</code> — Route definition helper with typed
|
|
83
|
+
navigation state
|
|
84
|
+
</li>
|
|
85
|
+
<li>
|
|
86
|
+
Types: <code>LoaderArgs</code>, <code>RouteDefinition</code>,{" "}
|
|
87
|
+
<code>PathParams</code>, <code>RouteComponentProps</code>,{" "}
|
|
88
|
+
<code>RouteComponentPropsWithData</code>
|
|
89
|
+
</li>
|
|
90
|
+
</ul>
|
|
91
|
+
</section>
|
|
92
|
+
|
|
93
|
+
<section>
|
|
94
|
+
<h3>The Client Boundary</h3>
|
|
95
|
+
<p>
|
|
96
|
+
The <code>Router</code> component subscribes to the Navigation API and
|
|
97
|
+
manages React state, so it is a client component. It serves as the
|
|
98
|
+
client boundary in your component tree — server components above
|
|
99
|
+
it construct the route definitions, while <code>Router</code> and its
|
|
100
|
+
runtime dependencies run in the browser.
|
|
101
|
+
</p>
|
|
102
|
+
<p>
|
|
103
|
+
Route definitions (paths, component references, children) are plain
|
|
104
|
+
serializable data, so they can be passed from a server component into{" "}
|
|
105
|
+
<code>Router</code> as props.
|
|
106
|
+
</p>
|
|
107
|
+
</section>
|
|
108
|
+
|
|
109
|
+
<section>
|
|
110
|
+
<h3>Defining Routes in the Server Context</h3>
|
|
111
|
+
<p>
|
|
112
|
+
Because route definitions are plain data (paths, component references,
|
|
113
|
+
and children), they can be constructed entirely on the server. The{" "}
|
|
114
|
+
<code>route()</code> helper from <code>@funstack/router/server</code>{" "}
|
|
115
|
+
produces the same <code>RouteDefinition</code> objects as the one from
|
|
116
|
+
the main entry point — the only difference is that it does not
|
|
117
|
+
pull in client-side code.
|
|
118
|
+
</p>
|
|
119
|
+
<CodeBlock language="tsx">{`// App.tsx — Server Component
|
|
120
|
+
import { Router } from "@funstack/router";
|
|
121
|
+
import { route } from "@funstack/router/server";
|
|
122
|
+
import { lazy } from "react";
|
|
123
|
+
|
|
124
|
+
// Lazy-load page components — these will be code-split
|
|
125
|
+
const HomePage = lazy(() => import("./pages/HomePage.js"));
|
|
126
|
+
const DashboardPage = lazy(() => import("./pages/DashboardPage.js"));
|
|
127
|
+
const SettingsPage = lazy(() => import("./pages/SettingsPage.js"));
|
|
128
|
+
|
|
129
|
+
const routes = [
|
|
130
|
+
route({
|
|
131
|
+
component: <Layout />,
|
|
132
|
+
children: [
|
|
133
|
+
route({ path: "/", component: HomePage }),
|
|
134
|
+
route({ path: "/dashboard", component: DashboardPage }),
|
|
135
|
+
route({ path: "/settings", component: SettingsPage }),
|
|
136
|
+
],
|
|
137
|
+
}),
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
export default function App() {
|
|
141
|
+
return <Router routes={routes} />;
|
|
142
|
+
}`}</CodeBlock>
|
|
143
|
+
<p>
|
|
144
|
+
Note that page components referenced in route definitions can be
|
|
145
|
+
either server components or client components. When a page component
|
|
146
|
+
uses hooks or browser APIs, it should have the{" "}
|
|
147
|
+
<code>"use client"</code> directive. Otherwise, it can remain a server
|
|
148
|
+
component for optimal performance.
|
|
149
|
+
</p>
|
|
150
|
+
|
|
151
|
+
<h4>Loaders in an RSC Context</h4>
|
|
152
|
+
<p>
|
|
153
|
+
Loaders run client-side — they execute in the browser when a
|
|
154
|
+
route is matched. This means a loader function cannot be defined
|
|
155
|
+
inline within a server module. Instead, define the loader in a client
|
|
156
|
+
module and import it:
|
|
157
|
+
</p>
|
|
158
|
+
<CodeBlock language="tsx">{`// loaders/dashboard.ts — a Client Module
|
|
159
|
+
"use client";
|
|
160
|
+
|
|
161
|
+
export async function dashboardLoader({ params }: LoaderArgs) {
|
|
162
|
+
const res = await fetch(\`/api/dashboard/\${params.id}\`);
|
|
163
|
+
return res.json();
|
|
164
|
+
}`}</CodeBlock>
|
|
165
|
+
<CodeBlock language="tsx">{`// App.tsx — Server Component
|
|
166
|
+
import { Router } from "@funstack/router";
|
|
167
|
+
import { route } from "@funstack/router/server";
|
|
168
|
+
import { dashboardLoader } from "./loaders/dashboard.js";
|
|
169
|
+
|
|
170
|
+
const routes = [
|
|
171
|
+
route({
|
|
172
|
+
component: <Layout />,
|
|
173
|
+
children: [
|
|
174
|
+
route({ path: "/", component: HomePage }),
|
|
175
|
+
route({
|
|
176
|
+
path: "/dashboard/:id",
|
|
177
|
+
component: DashboardPage,
|
|
178
|
+
loader: dashboardLoader,
|
|
179
|
+
}),
|
|
180
|
+
],
|
|
181
|
+
}),
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
export default function App() {
|
|
185
|
+
return <Router routes={routes} />;
|
|
186
|
+
}`}</CodeBlock>
|
|
187
|
+
<p>
|
|
188
|
+
By placing the loader in a <code>"use client"</code> module, it is
|
|
189
|
+
included in the client bundle where it can access browser APIs. The
|
|
190
|
+
server component imports the reference and passes it as part of the
|
|
191
|
+
route definition.
|
|
192
|
+
</p>
|
|
193
|
+
</section>
|
|
194
|
+
|
|
195
|
+
<section>
|
|
196
|
+
<h3>A Complete Example</h3>
|
|
197
|
+
<p>
|
|
198
|
+
This documentation site itself uses this pattern. Here is a simplified
|
|
199
|
+
version of how it is structured:
|
|
200
|
+
</p>
|
|
201
|
+
<CodeBlock language="tsx">{`// vite.config.ts
|
|
202
|
+
import funstackStatic from "@funstack/static";
|
|
203
|
+
import react from "@vitejs/plugin-react";
|
|
204
|
+
import { defineConfig } from "vite";
|
|
205
|
+
|
|
206
|
+
export default defineConfig({
|
|
207
|
+
plugins: [
|
|
208
|
+
funstackStatic({
|
|
209
|
+
root: "./src/Root.tsx", // Server Component — HTML shell
|
|
210
|
+
app: "./src/App.tsx", // Server Component — route definitions
|
|
211
|
+
ssr: true,
|
|
212
|
+
}),
|
|
213
|
+
react(),
|
|
214
|
+
],
|
|
215
|
+
});`}</CodeBlock>
|
|
216
|
+
<CodeBlock language="tsx">{`// Root.tsx — Server Component (HTML shell)
|
|
217
|
+
import type { ReactNode } from "react";
|
|
218
|
+
|
|
219
|
+
export default function Root({ children }: { children: ReactNode }) {
|
|
220
|
+
return (
|
|
221
|
+
<html lang="en">
|
|
222
|
+
<head>
|
|
223
|
+
<meta charSet="UTF-8" />
|
|
224
|
+
<title>My App</title>
|
|
225
|
+
</head>
|
|
226
|
+
<body>{children}</body>
|
|
227
|
+
</html>
|
|
228
|
+
);
|
|
229
|
+
}`}</CodeBlock>
|
|
230
|
+
<CodeBlock language="tsx">{`// App.tsx — Server Component (route definitions)
|
|
231
|
+
import { Router } from "@funstack/router";
|
|
232
|
+
import { route } from "@funstack/router/server";
|
|
233
|
+
import { Layout } from "./components/Layout.js";
|
|
234
|
+
import { HomePage } from "./pages/HomePage.js";
|
|
235
|
+
import { AboutPage } from "./pages/AboutPage.js";
|
|
236
|
+
|
|
237
|
+
const routes = [
|
|
238
|
+
route({
|
|
239
|
+
component: <Layout />,
|
|
240
|
+
children: [
|
|
241
|
+
route({ path: "/", component: HomePage }),
|
|
242
|
+
route({ path: "/about", component: AboutPage }),
|
|
243
|
+
],
|
|
244
|
+
}),
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
export default function App() {
|
|
248
|
+
return <Router routes={routes} fallback="static" />;
|
|
249
|
+
}`}</CodeBlock>
|
|
250
|
+
<p>
|
|
251
|
+
In this setup, <code>Root</code> and <code>App</code> are server
|
|
252
|
+
components. The route definitions are constructed on the server and
|
|
253
|
+
passed into <code>Router</code>, which acts as the client boundary.
|
|
254
|
+
</p>
|
|
255
|
+
</section>
|
|
256
|
+
|
|
257
|
+
<section>
|
|
258
|
+
<h3>Key Takeaways</h3>
|
|
259
|
+
<ul>
|
|
260
|
+
<li>
|
|
261
|
+
Import <code>route</code> and <code>routeState</code> from{" "}
|
|
262
|
+
<code>@funstack/router/server</code> in server modules to avoid
|
|
263
|
+
pulling client code into the server module graph
|
|
264
|
+
</li>
|
|
265
|
+
<li>
|
|
266
|
+
<code>Router</code> is a client component and serves as the client
|
|
267
|
+
boundary — render it directly from your server component
|
|
268
|
+
</li>
|
|
269
|
+
<li>
|
|
270
|
+
Route definitions are plain data and can be constructed entirely on
|
|
271
|
+
the server
|
|
272
|
+
</li>
|
|
273
|
+
<li>
|
|
274
|
+
Loaders run client-side — define them in{" "}
|
|
275
|
+
<code>"use client"</code> modules and import them into your route
|
|
276
|
+
definitions
|
|
277
|
+
</li>
|
|
278
|
+
<li>
|
|
279
|
+
Page components can be either server components or client components
|
|
280
|
+
depending on whether they need browser APIs or hooks
|
|
281
|
+
</li>
|
|
282
|
+
<li>
|
|
283
|
+
See also the{" "}
|
|
284
|
+
<a href="/funstack-router/learn/server-side-rendering">
|
|
285
|
+
Server-Side Rendering
|
|
286
|
+
</a>{" "}
|
|
287
|
+
guide for how the router handles SSR and hydration
|
|
288
|
+
</li>
|
|
289
|
+
</ul>
|
|
290
|
+
</section>
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { CodeBlock } from "../components/CodeBlock.js";
|
|
2
|
+
|
|
3
|
+
export function LearnSsrPage() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="learn-content">
|
|
6
|
+
<h2>Server-Side Rendering</h2>
|
|
7
|
+
|
|
8
|
+
<p className="page-intro">
|
|
9
|
+
FUNSTACK Router supports server-side rendering with a two-stage model.
|
|
10
|
+
During SSR, pathless (layout) routes without loaders render to produce
|
|
11
|
+
an app shell, while path-based routes and loaders activate only after
|
|
12
|
+
client hydration.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<section>
|
|
16
|
+
<h3>How SSR Works</h3>
|
|
17
|
+
<p>
|
|
18
|
+
FUNSTACK Router uses a two-stage rendering model that separates what
|
|
19
|
+
renders on the server from what renders on the client:
|
|
20
|
+
</p>
|
|
21
|
+
<p>
|
|
22
|
+
<strong>Stage 1 — Server:</strong> No URL is available on the
|
|
23
|
+
server. The router matches only pathless routes (routes without a{" "}
|
|
24
|
+
<code>path</code> property) that do not have a loader. Pathless routes
|
|
25
|
+
with loaders are skipped because there is no request context to run
|
|
26
|
+
them. This produces the app shell — layouts, headers, navigation
|
|
27
|
+
chrome, and other structural markup.
|
|
28
|
+
</p>
|
|
29
|
+
<p>
|
|
30
|
+
<strong>Stage 2 — Client hydration:</strong> Once the browser
|
|
31
|
+
hydrates the page, the actual URL becomes available via the Navigation
|
|
32
|
+
API. Path-based routes now match, loaders execute, and page-specific
|
|
33
|
+
content renders.
|
|
34
|
+
</p>
|
|
35
|
+
<CodeBlock language="tsx">{`// What renders at each stage:
|
|
36
|
+
|
|
37
|
+
// Stage 1 (Server) Stage 2 (Client)
|
|
38
|
+
// ───────────────── ─────────────────
|
|
39
|
+
// App shell (pathless App shell (pathless)
|
|
40
|
+
// without loader)
|
|
41
|
+
// ✗ No path routes ✓ Path routes match
|
|
42
|
+
// ✗ No loaders ✓ Loaders execute
|
|
43
|
+
// ✗ No URL available ✓ URL from Navigation API`}</CodeBlock>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<section>
|
|
47
|
+
<h3>Pathless Routes as the App Shell</h3>
|
|
48
|
+
<p>
|
|
49
|
+
Pathless routes (routes without a <code>path</code> property) always
|
|
50
|
+
match regardless of the current URL. This makes them ideal for
|
|
51
|
+
defining the SSR app shell — the parts of your UI that should be
|
|
52
|
+
visible immediately while the rest of the page loads.
|
|
53
|
+
</p>
|
|
54
|
+
<p>
|
|
55
|
+
Consider the following route tree. During SSR, only the pathless{" "}
|
|
56
|
+
<code>AppShell</code> route renders. The page routes require a URL to
|
|
57
|
+
match, so they are skipped:
|
|
58
|
+
</p>
|
|
59
|
+
<CodeBlock language="tsx">{`const routes = [
|
|
60
|
+
route({
|
|
61
|
+
component: AppShell, // Pathless — renders during SSR ✓
|
|
62
|
+
children: [
|
|
63
|
+
route({ path: "/", component: HomePage }), // Has path — skipped during SSR
|
|
64
|
+
route({ path: "/about", component: AboutPage }), // Has path — skipped during SSR
|
|
65
|
+
],
|
|
66
|
+
}),
|
|
67
|
+
];`}</CodeBlock>
|
|
68
|
+
<p>
|
|
69
|
+
In this example, <code>AppShell</code> might render a header, sidebar,
|
|
70
|
+
and footer — the structural parts of your application. After
|
|
71
|
+
hydration, the router matches the actual URL and renders{" "}
|
|
72
|
+
<code>HomePage</code> or <code>AboutPage</code> inside the shell.
|
|
73
|
+
</p>
|
|
74
|
+
</section>
|
|
75
|
+
|
|
76
|
+
<section>
|
|
77
|
+
<h3>Hooks and SSR</h3>
|
|
78
|
+
<p>
|
|
79
|
+
Because no URL is available during SSR, hooks that depend on the
|
|
80
|
+
current URL will throw errors if called during server rendering. The
|
|
81
|
+
affected hooks are <code>useLocation</code> and{" "}
|
|
82
|
+
<code>useSearchParams</code>.
|
|
83
|
+
</p>
|
|
84
|
+
<CodeBlock language="tsx">{`// These hooks throw during SSR:
|
|
85
|
+
useLocation();
|
|
86
|
+
// Error: "useLocation: URL is not available during SSR."
|
|
87
|
+
|
|
88
|
+
useSearchParams();
|
|
89
|
+
// Error: "useSearchParams: URL is not available during SSR."`}</CodeBlock>
|
|
90
|
+
<p>
|
|
91
|
+
To avoid these errors, either use URL-dependent hooks only in
|
|
92
|
+
components rendered by path-based routes, or read the current path
|
|
93
|
+
inside a client-side effect (e.g., <code>useLayoutEffect</code> +{" "}
|
|
94
|
+
<code>navigation.currentEntry</code>) so the value is only accessed
|
|
95
|
+
after hydration:
|
|
96
|
+
</p>
|
|
97
|
+
<CodeBlock language="tsx">{`// ✗ Bad: AppShell renders during SSR, useLocation will throw
|
|
98
|
+
function AppShell() {
|
|
99
|
+
const location = useLocation(); // Throws during SSR!
|
|
100
|
+
return <div>{/* ... */}</div>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ✓ Good: Read the path in a client-side effect
|
|
104
|
+
function useCurrentPath() {
|
|
105
|
+
const [path, setPath] = useState<string | undefined>(undefined);
|
|
106
|
+
useLayoutEffect(() => {
|
|
107
|
+
setPath(navigation.currentEntry?.url
|
|
108
|
+
? new URL(navigation.currentEntry.url).pathname
|
|
109
|
+
: undefined);
|
|
110
|
+
}, []);
|
|
111
|
+
return path;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function AppShell() {
|
|
115
|
+
const path = useCurrentPath(); // undefined during SSR, string after hydration
|
|
116
|
+
const isActive = (p: string) => path === p;
|
|
117
|
+
return <nav>{/* ... */}</nav>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ✓ Good: HomePage only renders after hydration (has a path)
|
|
121
|
+
function HomePage() {
|
|
122
|
+
const location = useLocation(); // Safe — URL is available
|
|
123
|
+
return <div>Current path: {location.pathname}</div>;
|
|
124
|
+
}`}</CodeBlock>
|
|
125
|
+
</section>
|
|
126
|
+
|
|
127
|
+
<section>
|
|
128
|
+
<h3>
|
|
129
|
+
The <code>fallback="static"</code> Mode
|
|
130
|
+
</h3>
|
|
131
|
+
<p>
|
|
132
|
+
When the Navigation API is unavailable (e.g., in older browsers), the
|
|
133
|
+
router's <code>fallback</code> prop controls what happens. With{" "}
|
|
134
|
+
<code>fallback="static"</code>, the router reads the current URL from{" "}
|
|
135
|
+
<code>window.location</code> and renders matched routes without
|
|
136
|
+
navigation interception. Links cause full page reloads (MPA behavior).
|
|
137
|
+
</p>
|
|
138
|
+
<p>
|
|
139
|
+
This is different from SSR: in static fallback mode, a URL <em>is</em>{" "}
|
|
140
|
+
available (from <code>window.location</code>), so path-based routes
|
|
141
|
+
match and loaders execute normally. During SSR, no URL is available at
|
|
142
|
+
all.
|
|
143
|
+
</p>
|
|
144
|
+
<CodeBlock language="tsx">{`import { Router } from "@funstack/router";
|
|
145
|
+
|
|
146
|
+
// Static fallback: renders routes using window.location
|
|
147
|
+
// when Navigation API is unavailable
|
|
148
|
+
<Router routes={routes} fallback="static" />`}</CodeBlock>
|
|
149
|
+
</section>
|
|
150
|
+
|
|
151
|
+
<section>
|
|
152
|
+
<h3>Key Takeaways</h3>
|
|
153
|
+
<ul>
|
|
154
|
+
<li>
|
|
155
|
+
During SSR, only pathless routes without loaders render (no URL or
|
|
156
|
+
request context is available on the server)
|
|
157
|
+
</li>
|
|
158
|
+
<li>
|
|
159
|
+
Path-based routes, loaders, and pathless routes with loaders
|
|
160
|
+
activate after client hydration
|
|
161
|
+
</li>
|
|
162
|
+
<li>
|
|
163
|
+
Pathless routes are ideal for app shell markup (headers, footers,
|
|
164
|
+
layout structure)
|
|
165
|
+
</li>
|
|
166
|
+
<li>
|
|
167
|
+
Avoid <code>useLocation</code> and <code>useSearchParams</code> in
|
|
168
|
+
components that render during SSR; use a client-side effect (e.g.,{" "}
|
|
169
|
+
<code>useLayoutEffect</code>) to read location information in the
|
|
170
|
+
app shell
|
|
171
|
+
</li>
|
|
172
|
+
<li>
|
|
173
|
+
This two-stage model keeps SSR output lightweight while enabling
|
|
174
|
+
full interactivity on the client
|
|
175
|
+
</li>
|
|
176
|
+
</ul>
|
|
177
|
+
</section>
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { CodeBlock } from "../components/CodeBlock.js";
|
|
2
|
+
|
|
3
|
+
export function LearnTransitionsPage() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="learn-content">
|
|
6
|
+
<h2>Controlling Transitions</h2>
|
|
7
|
+
|
|
8
|
+
<p className="page-intro">
|
|
9
|
+
FUNSTACK Router wraps every navigation in React's{" "}
|
|
10
|
+
<code>startTransition</code>, which means the old UI may stay visible
|
|
11
|
+
while the new route loads. This page explains how this works and how to
|
|
12
|
+
control it.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<section>
|
|
16
|
+
<h3>Navigations as Transitions</h3>
|
|
17
|
+
<p>
|
|
18
|
+
When the user navigates, the Router updates its location state inside{" "}
|
|
19
|
+
<code>startTransition()</code>. This means React treats every
|
|
20
|
+
navigation as a transition: if an existing Suspense boundary suspends
|
|
21
|
+
(e.g., a component loading data with <code>use()</code>), React keeps
|
|
22
|
+
the old UI visible instead of immediately showing the fallback. This
|
|
23
|
+
behavior is{" "}
|
|
24
|
+
<a href="https://react.dev/reference/react/useTransition#building-a-suspense-enabled-router">
|
|
25
|
+
what React recommends for Suspense-enabled routers
|
|
26
|
+
</a>
|
|
27
|
+
.
|
|
28
|
+
</p>
|
|
29
|
+
<p>
|
|
30
|
+
Consider a route with a loader that fetches data. The component uses{" "}
|
|
31
|
+
<code>use()</code> to read the promise. Because the navigation happens
|
|
32
|
+
inside a transition, the previous page remains on screen until the
|
|
33
|
+
data is ready:
|
|
34
|
+
</p>
|
|
35
|
+
<CodeBlock language="tsx">{`const routes = [
|
|
36
|
+
route({
|
|
37
|
+
path: "/user/:id",
|
|
38
|
+
loader: ({ params }) => fetchUser(params.id),
|
|
39
|
+
component: UserDetailPage,
|
|
40
|
+
}),
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// The route component receives the Promise and provides a Suspense boundary
|
|
44
|
+
function UserDetailPage({ data }: { data: Promise<User> }) {
|
|
45
|
+
return (
|
|
46
|
+
<Suspense fallback={<p>Loading...</p>}>
|
|
47
|
+
<UserDetail data={data} />
|
|
48
|
+
</Suspense>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A child component uses use() to read the data
|
|
53
|
+
function UserDetail({ data }: { data: Promise<User> }) {
|
|
54
|
+
const user = use(data);
|
|
55
|
+
return <div>{user.name}</div>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// When navigating from /user/1 to /user/2:
|
|
59
|
+
// → The /user/1 page stays visible while /user/2 data loads
|
|
60
|
+
// → Once loaded, the UI swaps to /user/2 instantly`}</CodeBlock>
|
|
61
|
+
</section>
|
|
62
|
+
|
|
63
|
+
<section>
|
|
64
|
+
<h3>
|
|
65
|
+
Showing Pending UI with <code>useIsPending</code>
|
|
66
|
+
</h3>
|
|
67
|
+
<p>
|
|
68
|
+
While a transition is in progress, the <code>useIsPending()</code>{" "}
|
|
69
|
+
hook returns <code>true</code>. Use it to give users visual feedback
|
|
70
|
+
that something is loading — for example, dimming the current
|
|
71
|
+
page or showing a loading bar.
|
|
72
|
+
</p>
|
|
73
|
+
<CodeBlock language="tsx">{`import { useIsPending, Outlet } from "@funstack/router";
|
|
74
|
+
|
|
75
|
+
function Layout() {
|
|
76
|
+
const isPending = useIsPending();
|
|
77
|
+
return (
|
|
78
|
+
<div>
|
|
79
|
+
{isPending && <div className="loading-bar" />}
|
|
80
|
+
<div style={{ opacity: isPending ? 0.6 : 1 }}>
|
|
81
|
+
<Outlet />
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}`}</CodeBlock>
|
|
86
|
+
<p>
|
|
87
|
+
The <code>isPending</code> flag is also available as a prop on route
|
|
88
|
+
components, so you can use it without calling the hook:
|
|
89
|
+
</p>
|
|
90
|
+
<CodeBlock language="tsx">{`function Layout({ isPending }: { isPending: boolean }) {
|
|
91
|
+
return (
|
|
92
|
+
<div style={{ opacity: isPending ? 0.6 : 1 }}>
|
|
93
|
+
<Outlet />
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}`}</CodeBlock>
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
<section>
|
|
100
|
+
<h3>Opting Out of Transitions</h3>
|
|
101
|
+
<p>
|
|
102
|
+
Sometimes you want to show a loading fallback immediately instead of
|
|
103
|
+
keeping the old UI visible. This is especially useful when navigating
|
|
104
|
+
between pages that share the same route but with different params
|
|
105
|
+
— for example, going from <code>/users/1</code> to{" "}
|
|
106
|
+
<code>/users/2</code>, where showing stale data from user 1 while user
|
|
107
|
+
2 loads would be confusing.
|
|
108
|
+
</p>
|
|
109
|
+
<p>
|
|
110
|
+
The technique is to add a <code>key</code> prop to a{" "}
|
|
111
|
+
<code>{"<Suspense>"}</code> boundary that changes with the route
|
|
112
|
+
params. When the key changes, React unmounts the old Suspense boundary
|
|
113
|
+
and mounts a new one, immediately showing the fallback:
|
|
114
|
+
</p>
|
|
115
|
+
<CodeBlock language="tsx">{`import { Suspense } from "react";
|
|
116
|
+
|
|
117
|
+
function UserDetailPage({
|
|
118
|
+
params,
|
|
119
|
+
data,
|
|
120
|
+
}: {
|
|
121
|
+
params: { id: string };
|
|
122
|
+
data: Promise<User>;
|
|
123
|
+
}) {
|
|
124
|
+
return (
|
|
125
|
+
<Suspense key={params.id} fallback={<LoadingSpinner />}>
|
|
126
|
+
<UserDetail data={data} />
|
|
127
|
+
</Suspense>
|
|
128
|
+
);
|
|
129
|
+
}`}</CodeBlock>
|
|
130
|
+
<p>
|
|
131
|
+
Because the <code>key</code> changes from <code>"1"</code> to{" "}
|
|
132
|
+
<code>"2"</code> when navigating between users, React discards the old
|
|
133
|
+
Suspense boundary entirely. The new boundary has no resolved content
|
|
134
|
+
yet, so it shows the fallback right away — bypassing the
|
|
135
|
+
transition behavior.
|
|
136
|
+
</p>
|
|
137
|
+
<p>
|
|
138
|
+
Use this pattern when stale content would be misleading. For
|
|
139
|
+
navigations where the old page is still a reasonable placeholder
|
|
140
|
+
(e.g., navigating between completely different pages), the default
|
|
141
|
+
transition behavior is usually the better experience.
|
|
142
|
+
</p>
|
|
143
|
+
</section>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|