@funstack/router 0.0.5 → 0.0.7-alpha.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,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 &mdash; 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> &mdash; Route definition helper (same API as the
79
+ main entry point)
80
+ </li>
81
+ <li>
82
+ <code>routeState</code> &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; layouts, headers, navigation
27
+ chrome, and other structural markup.
28
+ </p>
29
+ <p>
30
+ <strong>Stage 2 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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
+ &mdash; 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 &mdash; 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
+ }