@rangojs/router 0.0.0-experimental.2
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/CLAUDE.md +7 -0
- package/README.md +19 -0
- package/dist/vite/index.js +1298 -0
- package/package.json +140 -0
- package/skills/caching/SKILL.md +319 -0
- package/skills/document-cache/SKILL.md +152 -0
- package/skills/hooks/SKILL.md +359 -0
- package/skills/intercept/SKILL.md +292 -0
- package/skills/layout/SKILL.md +216 -0
- package/skills/loader/SKILL.md +365 -0
- package/skills/middleware/SKILL.md +442 -0
- package/skills/parallel/SKILL.md +255 -0
- package/skills/route/SKILL.md +141 -0
- package/skills/router-setup/SKILL.md +403 -0
- package/skills/theme/SKILL.md +54 -0
- package/skills/typesafety/SKILL.md +352 -0
- package/src/__mocks__/version.ts +6 -0
- package/src/__tests__/component-utils.test.ts +76 -0
- package/src/__tests__/route-definition.test.ts +63 -0
- package/src/__tests__/urls.test.tsx +436 -0
- package/src/browser/event-controller.ts +876 -0
- package/src/browser/index.ts +18 -0
- package/src/browser/link-interceptor.ts +121 -0
- package/src/browser/lru-cache.ts +69 -0
- package/src/browser/merge-segment-loaders.ts +126 -0
- package/src/browser/navigation-bridge.ts +893 -0
- package/src/browser/navigation-client.ts +162 -0
- package/src/browser/navigation-store.ts +823 -0
- package/src/browser/partial-update.ts +559 -0
- package/src/browser/react/Link.tsx +248 -0
- package/src/browser/react/NavigationProvider.tsx +275 -0
- package/src/browser/react/ScrollRestoration.tsx +94 -0
- package/src/browser/react/context.ts +53 -0
- package/src/browser/react/index.ts +52 -0
- package/src/browser/react/location-state-shared.ts +120 -0
- package/src/browser/react/location-state.ts +62 -0
- package/src/browser/react/use-action.ts +240 -0
- package/src/browser/react/use-client-cache.ts +56 -0
- package/src/browser/react/use-handle.ts +178 -0
- package/src/browser/react/use-href.tsx +208 -0
- package/src/browser/react/use-link-status.ts +134 -0
- package/src/browser/react/use-navigation.ts +150 -0
- package/src/browser/react/use-segments.ts +188 -0
- package/src/browser/request-controller.ts +164 -0
- package/src/browser/rsc-router.tsx +353 -0
- package/src/browser/scroll-restoration.ts +324 -0
- package/src/browser/server-action-bridge.ts +747 -0
- package/src/browser/shallow.ts +35 -0
- package/src/browser/types.ts +464 -0
- package/src/cache/__tests__/document-cache.test.ts +522 -0
- package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
- package/src/cache/__tests__/memory-store.test.ts +484 -0
- package/src/cache/cache-scope.ts +565 -0
- package/src/cache/cf/__tests__/cf-cache-store.test.ts +428 -0
- package/src/cache/cf/cf-cache-store.ts +428 -0
- package/src/cache/cf/index.ts +19 -0
- package/src/cache/document-cache.ts +340 -0
- package/src/cache/index.ts +58 -0
- package/src/cache/memory-segment-store.ts +150 -0
- package/src/cache/memory-store.ts +253 -0
- package/src/cache/types.ts +387 -0
- package/src/client.rsc.tsx +88 -0
- package/src/client.tsx +621 -0
- package/src/component-utils.ts +76 -0
- package/src/components/DefaultDocument.tsx +23 -0
- package/src/default-error-boundary.tsx +88 -0
- package/src/deps/browser.ts +8 -0
- package/src/deps/html-stream-client.ts +2 -0
- package/src/deps/html-stream-server.ts +2 -0
- package/src/deps/rsc.ts +10 -0
- package/src/deps/ssr.ts +2 -0
- package/src/errors.ts +259 -0
- package/src/handle.ts +120 -0
- package/src/handles/MetaTags.tsx +193 -0
- package/src/handles/index.ts +6 -0
- package/src/handles/meta.ts +247 -0
- package/src/href-client.ts +128 -0
- package/src/href-context.ts +33 -0
- package/src/href.ts +177 -0
- package/src/index.rsc.ts +79 -0
- package/src/index.ts +87 -0
- package/src/loader.rsc.ts +204 -0
- package/src/loader.ts +47 -0
- package/src/network-error-thrower.tsx +21 -0
- package/src/outlet-context.ts +15 -0
- package/src/root-error-boundary.tsx +277 -0
- package/src/route-content-wrapper.tsx +198 -0
- package/src/route-definition.ts +1371 -0
- package/src/route-map-builder.ts +146 -0
- package/src/route-types.ts +198 -0
- package/src/route-utils.ts +89 -0
- package/src/router/__tests__/match-context.test.ts +104 -0
- package/src/router/__tests__/match-pipelines.test.ts +537 -0
- package/src/router/__tests__/match-result.test.ts +566 -0
- package/src/router/__tests__/on-error.test.ts +935 -0
- package/src/router/__tests__/pattern-matching.test.ts +577 -0
- package/src/router/error-handling.ts +287 -0
- package/src/router/handler-context.ts +158 -0
- package/src/router/loader-resolution.ts +326 -0
- package/src/router/manifest.ts +138 -0
- package/src/router/match-context.ts +264 -0
- package/src/router/match-middleware/background-revalidation.ts +236 -0
- package/src/router/match-middleware/cache-lookup.ts +261 -0
- package/src/router/match-middleware/cache-store.ts +266 -0
- package/src/router/match-middleware/index.ts +81 -0
- package/src/router/match-middleware/intercept-resolution.ts +268 -0
- package/src/router/match-middleware/segment-resolution.ts +174 -0
- package/src/router/match-pipelines.ts +214 -0
- package/src/router/match-result.ts +214 -0
- package/src/router/metrics.ts +62 -0
- package/src/router/middleware.test.ts +1355 -0
- package/src/router/middleware.ts +748 -0
- package/src/router/pattern-matching.ts +272 -0
- package/src/router/revalidation.ts +190 -0
- package/src/router/router-context.ts +299 -0
- package/src/router/types.ts +96 -0
- package/src/router.ts +3876 -0
- package/src/rsc/__tests__/helpers.test.ts +175 -0
- package/src/rsc/handler.ts +1060 -0
- package/src/rsc/helpers.ts +64 -0
- package/src/rsc/index.ts +56 -0
- package/src/rsc/nonce.ts +18 -0
- package/src/rsc/types.ts +237 -0
- package/src/segment-system.tsx +456 -0
- package/src/server/__tests__/request-context.test.ts +171 -0
- package/src/server/context.ts +417 -0
- package/src/server/handle-store.ts +230 -0
- package/src/server/loader-registry.ts +174 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/root-layout.tsx +10 -0
- package/src/server/tsconfig.json +14 -0
- package/src/server.ts +146 -0
- package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
- package/src/ssr/index.tsx +234 -0
- package/src/theme/ThemeProvider.tsx +291 -0
- package/src/theme/ThemeScript.tsx +61 -0
- package/src/theme/__tests__/theme.test.ts +120 -0
- package/src/theme/constants.ts +55 -0
- package/src/theme/index.ts +58 -0
- package/src/theme/theme-context.ts +70 -0
- package/src/theme/theme-script.ts +152 -0
- package/src/theme/types.ts +182 -0
- package/src/theme/use-theme.ts +44 -0
- package/src/types.ts +1561 -0
- package/src/urls.ts +726 -0
- package/src/use-loader.tsx +346 -0
- package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
- package/src/vite/expose-action-id.ts +344 -0
- package/src/vite/expose-handle-id.ts +209 -0
- package/src/vite/expose-loader-id.ts +357 -0
- package/src/vite/expose-location-state-id.ts +177 -0
- package/src/vite/index.ts +787 -0
- package/src/vite/package-resolution.ts +125 -0
- package/src/vite/version.d.ts +12 -0
- package/src/vite/virtual-entries.ts +109 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: layout
|
|
3
|
+
description: Define layout routes that wrap child routes in rsc-router
|
|
4
|
+
argument-hint: [layout-name]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Layout Routes
|
|
8
|
+
|
|
9
|
+
Layouts wrap child routes and stack vertically from parent to child.
|
|
10
|
+
|
|
11
|
+
## Basic Layout
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { map } from "rsc-router/server";
|
|
15
|
+
import { Outlet } from "rsc-router";
|
|
16
|
+
|
|
17
|
+
// Layout component
|
|
18
|
+
function ShopLayout({ children }: { children: React.ReactNode }) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="shop">
|
|
21
|
+
<ShopHeader />
|
|
22
|
+
<div className="shop-content">
|
|
23
|
+
{children}
|
|
24
|
+
</div>
|
|
25
|
+
<ShopFooter />
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Handler definition
|
|
31
|
+
export default map<typeof routes>(({ route, layout }) => [
|
|
32
|
+
layout(<ShopLayout />, () => [
|
|
33
|
+
route("index", ShopIndex),
|
|
34
|
+
route("products", ProductList),
|
|
35
|
+
route("cart", Cart),
|
|
36
|
+
]),
|
|
37
|
+
]);
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Nested Layouts
|
|
41
|
+
|
|
42
|
+
Layouts can be nested - they stack from parent to child:
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
export default map<typeof routes>(({ route, layout }) => [
|
|
46
|
+
// Root layout wraps everything
|
|
47
|
+
layout(<RootLayout />, () => [
|
|
48
|
+
route("index", HomePage),
|
|
49
|
+
|
|
50
|
+
// Shop layout nested inside root
|
|
51
|
+
layout(<ShopLayout />, () => [
|
|
52
|
+
route("shop.index", ShopIndex),
|
|
53
|
+
route("shop.products", ProductList),
|
|
54
|
+
|
|
55
|
+
// Account layout nested inside shop
|
|
56
|
+
layout(<AccountLayout />, () => [
|
|
57
|
+
route("shop.account.index", AccountIndex),
|
|
58
|
+
route("shop.account.orders", OrderList),
|
|
59
|
+
]),
|
|
60
|
+
]),
|
|
61
|
+
|
|
62
|
+
// Blog layout also inside root (sibling to shop)
|
|
63
|
+
layout(<BlogLayout />, () => [
|
|
64
|
+
route("blog.index", BlogIndex),
|
|
65
|
+
route("blog.post", BlogPost),
|
|
66
|
+
]),
|
|
67
|
+
]),
|
|
68
|
+
]);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Result for `/shop/account/orders`:
|
|
72
|
+
```
|
|
73
|
+
<RootLayout>
|
|
74
|
+
<ShopLayout>
|
|
75
|
+
<AccountLayout>
|
|
76
|
+
<OrderList />
|
|
77
|
+
</AccountLayout>
|
|
78
|
+
</ShopLayout>
|
|
79
|
+
</RootLayout>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Layout with Data Loading
|
|
83
|
+
|
|
84
|
+
Layouts can fetch data and use middleware:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
export default map<typeof routes>(({ route, layout, loader, middleware }) => [
|
|
88
|
+
layout(
|
|
89
|
+
async (ctx) => {
|
|
90
|
+
const user = await ctx.use(UserLoader);
|
|
91
|
+
return (
|
|
92
|
+
<DashboardLayout user={user}>
|
|
93
|
+
<Outlet />
|
|
94
|
+
</DashboardLayout>
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
() => [
|
|
98
|
+
loader(UserLoader),
|
|
99
|
+
middleware(authMiddleware),
|
|
100
|
+
|
|
101
|
+
route("dashboard.index", DashboardIndex),
|
|
102
|
+
route("dashboard.settings", Settings),
|
|
103
|
+
]
|
|
104
|
+
),
|
|
105
|
+
]);
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Orphan Layouts
|
|
109
|
+
|
|
110
|
+
A layout without children extends the parent layout:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
layout(<RootLayout />, () => [
|
|
114
|
+
// This route uses RootLayout + ShopLayout
|
|
115
|
+
layout(<ShopLayout />),
|
|
116
|
+
route("shop.index", ShopIndex),
|
|
117
|
+
|
|
118
|
+
// This route only uses RootLayout
|
|
119
|
+
route("about", About),
|
|
120
|
+
]);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Layout with Parallel Slots
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
layout(
|
|
127
|
+
(ctx) => (
|
|
128
|
+
<DashboardLayout>
|
|
129
|
+
<ParallelOutlet name="@sidebar" />
|
|
130
|
+
<main>
|
|
131
|
+
<Outlet />
|
|
132
|
+
</main>
|
|
133
|
+
<ParallelOutlet name="@notifications" />
|
|
134
|
+
</DashboardLayout>
|
|
135
|
+
),
|
|
136
|
+
() => [
|
|
137
|
+
parallel({
|
|
138
|
+
"@sidebar": () => <Sidebar />,
|
|
139
|
+
"@notifications": () => <NotificationPanel />,
|
|
140
|
+
}),
|
|
141
|
+
route("dashboard.index", DashboardIndex),
|
|
142
|
+
]
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Layout with Handle Accumulation
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import { createHandle } from "rsc-router";
|
|
150
|
+
|
|
151
|
+
export const Breadcrumbs = createHandle<{ label: string; href: string }>();
|
|
152
|
+
|
|
153
|
+
export default map<typeof routes>(({ route, layout }) => [
|
|
154
|
+
layout(
|
|
155
|
+
(ctx) => {
|
|
156
|
+
const push = ctx.use(Breadcrumbs);
|
|
157
|
+
push({ label: "Home", href: "/" });
|
|
158
|
+
return <RootLayout><Outlet /></RootLayout>;
|
|
159
|
+
},
|
|
160
|
+
() => [
|
|
161
|
+
layout(
|
|
162
|
+
(ctx) => {
|
|
163
|
+
const push = ctx.use(Breadcrumbs);
|
|
164
|
+
push({ label: "Shop", href: "/shop" });
|
|
165
|
+
return <ShopLayout><Outlet /></ShopLayout>;
|
|
166
|
+
},
|
|
167
|
+
() => [
|
|
168
|
+
route("shop.index", ShopIndex),
|
|
169
|
+
]
|
|
170
|
+
),
|
|
171
|
+
]
|
|
172
|
+
),
|
|
173
|
+
]);
|
|
174
|
+
|
|
175
|
+
// In client component
|
|
176
|
+
function BreadcrumbNav() {
|
|
177
|
+
const crumbs = useHandle(Breadcrumbs);
|
|
178
|
+
// crumbs = [{ label: "Home", href: "/" }, { label: "Shop", href: "/shop" }]
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Layout Configuration Options
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
layout(<MyLayout />, () => [
|
|
186
|
+
loader(LayoutLoader), // Data for layout
|
|
187
|
+
middleware(authMiddleware), // Auth check before layout
|
|
188
|
+
loading(<LayoutSkeleton />), // Loading state
|
|
189
|
+
errorBoundary(<LayoutError />), // Error boundary
|
|
190
|
+
revalidate(shouldRevalidate), // Cache control
|
|
191
|
+
|
|
192
|
+
// Child routes
|
|
193
|
+
route("index", IndexHandler),
|
|
194
|
+
])
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Layout Component Pattern
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
// layouts/ShopLayout.tsx
|
|
201
|
+
import { Outlet } from "rsc-router";
|
|
202
|
+
|
|
203
|
+
export function ShopLayout({ children }: { children?: React.ReactNode }) {
|
|
204
|
+
return (
|
|
205
|
+
<div className="shop-layout">
|
|
206
|
+
<ShopNav />
|
|
207
|
+
<main>
|
|
208
|
+
{children ?? <Outlet />}
|
|
209
|
+
</main>
|
|
210
|
+
<ShopFooter />
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Use `children` for direct children or `<Outlet />` for route content.
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: loader
|
|
3
|
+
description: Define data loaders for fetching data in routes with createLoader
|
|
4
|
+
argument-hint: [loader-name]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Data Loaders
|
|
8
|
+
|
|
9
|
+
Loaders fetch data for routes and make it available to handlers and client components.
|
|
10
|
+
|
|
11
|
+
## Creating a Loader
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { createLoader, notFound } from "rsc-router/server";
|
|
15
|
+
|
|
16
|
+
export const ProductLoader = createLoader(async (ctx) => {
|
|
17
|
+
const product = await db.products.findUnique({
|
|
18
|
+
where: { slug: ctx.params.slug },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (!product) {
|
|
22
|
+
throw notFound("Product not found");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return product;
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Loader Context
|
|
30
|
+
|
|
31
|
+
The loader receives a context with:
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
export const MyLoader = createLoader(async (ctx) => {
|
|
35
|
+
ctx.params; // Route parameters { id: string, slug: string }
|
|
36
|
+
ctx.query; // Query string parameters
|
|
37
|
+
ctx.url; // Full URL object
|
|
38
|
+
ctx.pathname; // Current path
|
|
39
|
+
ctx.method; // HTTP method
|
|
40
|
+
ctx.request; // Raw Request object
|
|
41
|
+
|
|
42
|
+
// Variables from middleware
|
|
43
|
+
const user = ctx.get("user");
|
|
44
|
+
|
|
45
|
+
// Use other loaders
|
|
46
|
+
const related = await ctx.use(RelatedLoader);
|
|
47
|
+
|
|
48
|
+
return { user, related };
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Registering Loaders on Routes
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import { map } from "rsc-router/server";
|
|
56
|
+
import { ProductLoader } from "../loaders/product";
|
|
57
|
+
|
|
58
|
+
export default map<typeof routes>(({ route, loader, loading }) => [
|
|
59
|
+
route("products.detail", async (ctx) => {
|
|
60
|
+
const product = await ctx.use(ProductLoader);
|
|
61
|
+
return <ProductPage product={product} />;
|
|
62
|
+
}, () => [
|
|
63
|
+
loader(ProductLoader),
|
|
64
|
+
loading(<ProductSkeleton />),
|
|
65
|
+
]),
|
|
66
|
+
]);
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Loader with Middleware
|
|
70
|
+
|
|
71
|
+
Add middleware specific to a loader:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
export const UserProfileLoader = createLoader(
|
|
75
|
+
async (ctx) => {
|
|
76
|
+
const userId = ctx.get("validatedUserId");
|
|
77
|
+
return db.users.findUnique({ where: { id: userId } });
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
middleware: [
|
|
81
|
+
async (ctx, next) => {
|
|
82
|
+
const userId = ctx.params.id;
|
|
83
|
+
|
|
84
|
+
// Validate user ID
|
|
85
|
+
if (!isValidUUID(userId)) {
|
|
86
|
+
throw new Error("Invalid user ID");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
ctx.set("validatedUserId", userId);
|
|
90
|
+
await next();
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
}
|
|
94
|
+
);
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Using Loaders in Handlers
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
route("products.detail", async (ctx) => {
|
|
101
|
+
// Fetch loader data
|
|
102
|
+
const product = await ctx.use(ProductLoader);
|
|
103
|
+
|
|
104
|
+
// Multiple loaders
|
|
105
|
+
const [product, reviews, related] = await Promise.all([
|
|
106
|
+
ctx.use(ProductLoader),
|
|
107
|
+
ctx.use(ReviewsLoader),
|
|
108
|
+
ctx.use(RelatedProductsLoader),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<ProductPage
|
|
113
|
+
product={product}
|
|
114
|
+
reviews={reviews}
|
|
115
|
+
related={related}
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
}, () => [
|
|
119
|
+
loader(ProductLoader),
|
|
120
|
+
loader(ReviewsLoader),
|
|
121
|
+
loader(RelatedProductsLoader),
|
|
122
|
+
])
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Loader Revalidation
|
|
126
|
+
|
|
127
|
+
Control when loaders refetch:
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
route("products.detail", ProductHandler, () => [
|
|
131
|
+
loader(ProductLoader),
|
|
132
|
+
|
|
133
|
+
// Revalidate when params change
|
|
134
|
+
revalidate(({ currentParams, nextParams }) =>
|
|
135
|
+
currentParams.slug !== nextParams.slug
|
|
136
|
+
),
|
|
137
|
+
|
|
138
|
+
// Revalidate on specific actions
|
|
139
|
+
revalidate(({ actionId }) =>
|
|
140
|
+
actionId?.includes("updateProduct") ?? false
|
|
141
|
+
),
|
|
142
|
+
])
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Soft vs Hard Revalidation
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
// Hard decision - stops evaluation
|
|
149
|
+
revalidate(({ currentParams, nextParams }) => {
|
|
150
|
+
return currentParams.id !== nextParams.id; // boolean
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Soft decision - continues to next revalidator
|
|
154
|
+
revalidate(({ actionId, defaultShouldRevalidate }) => {
|
|
155
|
+
if (actionId?.includes("cart")) {
|
|
156
|
+
return true; // Hard: must revalidate
|
|
157
|
+
}
|
|
158
|
+
// Soft: defer to next revalidator
|
|
159
|
+
return { defaultShouldRevalidate };
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Client-Side Loader Access
|
|
164
|
+
|
|
165
|
+
### useLoader() - Strict access
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
"use client";
|
|
169
|
+
import { useLoader } from "rsc-router";
|
|
170
|
+
import { ProductLoader } from "../loaders/product";
|
|
171
|
+
|
|
172
|
+
function ProductPrice() {
|
|
173
|
+
// Data guaranteed (throws if not in context)
|
|
174
|
+
const { data } = useLoader(ProductLoader);
|
|
175
|
+
return <span>${data.price}</span>;
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### useFetchLoader() - Flexible access
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
"use client";
|
|
183
|
+
import { useFetchLoader } from "rsc-router";
|
|
184
|
+
import { SearchLoader } from "../loaders/search";
|
|
185
|
+
|
|
186
|
+
function SearchResults() {
|
|
187
|
+
const { data, load, isLoading } = useFetchLoader(SearchLoader);
|
|
188
|
+
|
|
189
|
+
const handleSearch = async (query: string) => {
|
|
190
|
+
await load({ params: { query } });
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div>
|
|
195
|
+
<input onChange={(e) => handleSearch(e.target.value)} />
|
|
196
|
+
{isLoading && <Spinner />}
|
|
197
|
+
{data?.results.map(r => <Result key={r.id} {...r} />)}
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Async/Streaming Loaders
|
|
204
|
+
|
|
205
|
+
Return promises for streaming:
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
export const RecommendationsLoader = createLoader(async (ctx) => {
|
|
209
|
+
return {
|
|
210
|
+
// Immediate data
|
|
211
|
+
product: await db.products.findUnique({ where: { id: ctx.params.id } }),
|
|
212
|
+
|
|
213
|
+
// Streams while other content renders
|
|
214
|
+
recommendations: db.recommendations.findAsync(ctx.params.id),
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Loader Caching
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
route("products.detail", ProductHandler, () => [
|
|
223
|
+
loader(ProductLoader, () => [
|
|
224
|
+
// Cache this loader's results
|
|
225
|
+
revalidate(({ currentParams, nextParams }) =>
|
|
226
|
+
currentParams.slug !== nextParams.slug
|
|
227
|
+
),
|
|
228
|
+
]),
|
|
229
|
+
])
|
|
230
|
+
|
|
231
|
+
// Or use cache boundaries
|
|
232
|
+
cache({ ttl: 60, swr: 300 }, () => [
|
|
233
|
+
loader(ProductLoader),
|
|
234
|
+
route("products.detail", ProductHandler),
|
|
235
|
+
])
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Loader Error Handling
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
export const ProductLoader = createLoader(async (ctx) => {
|
|
242
|
+
try {
|
|
243
|
+
const product = await db.products.findUnique({
|
|
244
|
+
where: { slug: ctx.params.slug },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
if (!product) {
|
|
248
|
+
throw notFound("Product not found");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return product;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (error instanceof DatabaseError) {
|
|
254
|
+
throw new Error("Failed to load product");
|
|
255
|
+
}
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Handle in route
|
|
261
|
+
route("products.detail", ProductHandler, () => [
|
|
262
|
+
loader(ProductLoader),
|
|
263
|
+
errorBoundary(({ error, reset }) => (
|
|
264
|
+
<div>
|
|
265
|
+
<p>Error loading product: {error.message}</p>
|
|
266
|
+
<button onClick={reset}>Retry</button>
|
|
267
|
+
</div>
|
|
268
|
+
)),
|
|
269
|
+
])
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Parallel Slot Loaders
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
parallel(
|
|
276
|
+
{
|
|
277
|
+
"@sidebar": async (ctx) => {
|
|
278
|
+
const categories = await ctx.use(CategoriesLoader);
|
|
279
|
+
return <CategorySidebar categories={categories} />;
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
() => [
|
|
283
|
+
loader(CategoriesLoader),
|
|
284
|
+
loading(<SidebarSkeleton />),
|
|
285
|
+
revalidate(({ actionId }) => actionId?.includes("category") ?? false),
|
|
286
|
+
]
|
|
287
|
+
)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
## Loader Composition
|
|
291
|
+
|
|
292
|
+
```typescript
|
|
293
|
+
// Base loader
|
|
294
|
+
export const UserLoader = createLoader(async (ctx) => {
|
|
295
|
+
return db.users.findUnique({ where: { id: ctx.get("userId") } });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Composed loader
|
|
299
|
+
export const UserWithOrdersLoader = createLoader(async (ctx) => {
|
|
300
|
+
const user = await ctx.use(UserLoader);
|
|
301
|
+
const orders = await db.orders.findMany({
|
|
302
|
+
where: { userId: user.id },
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return { user, orders };
|
|
306
|
+
});
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Loader Type Safety
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
// Loader type is inferred
|
|
313
|
+
export const ProductLoader = createLoader(async (ctx) => {
|
|
314
|
+
return { id: "1", name: "Widget", price: 99 };
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// In handler - type is { id: string; name: string; price: number }
|
|
318
|
+
const product = await ctx.use(ProductLoader);
|
|
319
|
+
|
|
320
|
+
// In client - same type
|
|
321
|
+
const { data } = useLoader(ProductLoader);
|
|
322
|
+
// data: { id: string; name: string; price: number }
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Common Patterns
|
|
326
|
+
|
|
327
|
+
### List with pagination
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
export const ProductListLoader = createLoader(async (ctx) => {
|
|
331
|
+
const page = parseInt(ctx.query.page ?? "1");
|
|
332
|
+
const limit = 20;
|
|
333
|
+
|
|
334
|
+
const [products, total] = await Promise.all([
|
|
335
|
+
db.products.findMany({
|
|
336
|
+
skip: (page - 1) * limit,
|
|
337
|
+
take: limit,
|
|
338
|
+
}),
|
|
339
|
+
db.products.count(),
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
products,
|
|
344
|
+
pagination: {
|
|
345
|
+
page,
|
|
346
|
+
totalPages: Math.ceil(total / limit),
|
|
347
|
+
total,
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
});
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Conditional loading
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
export const AdminDataLoader = createLoader(async (ctx) => {
|
|
357
|
+
const user = ctx.get("user");
|
|
358
|
+
|
|
359
|
+
if (!user || user.role !== "admin") {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return db.adminStats.get();
|
|
364
|
+
});
|
|
365
|
+
```
|