@rangojs/router 0.0.0-experimental.75 → 0.0.0-experimental.76

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.
@@ -1864,7 +1864,7 @@ import { resolve } from "node:path";
1864
1864
  // package.json
1865
1865
  var package_default = {
1866
1866
  name: "@rangojs/router",
1867
- version: "0.0.0-experimental.75",
1867
+ version: "0.0.0-experimental.76",
1868
1868
  description: "Django-inspired RSC router with composable URL patterns",
1869
1869
  keywords: [
1870
1870
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.75",
3
+ "version": "0.0.0-experimental.76",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -0,0 +1,362 @@
1
+ ---
2
+ name: handler-use
3
+ description: Attach default loaders, middleware, parallels, and other use items directly to handlers via handler.use, and compose them with explicit use() at mount sites
4
+ argument-hint: "[handler]"
5
+ ---
6
+
7
+ # Handler-Attached `.use`
8
+
9
+ A handler function (or branded `Static`/`Prerender`/`Passthrough` definition) can carry its own defaults via a `.use` callback that returns an array of `use` items (loader, middleware, parallel, intercept, layout, loading, etc.). The mount-site DSL (`path()`, `layout()`, `parallel()`, `intercept()`) merges those defaults with any explicit `use()` callback supplied at the registration site.
10
+
11
+ This lets handlers be **self-contained, reusable units** — a page brings its own loader, a layout brings its own middleware, a parallel slot brings its own data + skeleton — without forcing every caller to wire the same items at every mount site.
12
+
13
+ Canonical implementation reference:
14
+ [src/route-definition/resolve-handler-use.ts](../../src/route-definition/resolve-handler-use.ts)
15
+
16
+ ## Defining a handler with `.use`
17
+
18
+ Attach `.use` to the function (or to the branded definition for `Static()`/`Prerender()`/`Passthrough()`):
19
+
20
+ ```typescript
21
+ import {
22
+ loader,
23
+ middleware,
24
+ loading,
25
+ createLoader,
26
+ type Handler,
27
+ } from "@rangojs/router";
28
+
29
+ export const ProductLoader = createLoader(async (ctx) =>
30
+ fetchProduct(ctx.params.slug),
31
+ );
32
+
33
+ const ProductPage: Handler<"/product/:slug"> = async (ctx) => {
34
+ const product = await ctx.use(ProductLoader);
35
+ return <ProductView product={product} />;
36
+ };
37
+
38
+ ProductPage.use = () => [
39
+ loader(ProductLoader),
40
+ loading(<ProductSkeleton />),
41
+ middleware(async (ctx, next) => {
42
+ await next();
43
+ ctx.header("Cache-Control", "private, max-age=60");
44
+ }),
45
+ ];
46
+ ```
47
+
48
+ Now `ProductPage` carries its loader, loading state, and response-header middleware regardless of where it is mounted.
49
+
50
+ ## Allowed items per mount site
51
+
52
+ `handler.use()` is the same callback shape regardless of where the handler runs, but the runtime validates that the items it returns are valid for the mount site. Driven by `MOUNT_SITE_ALLOWED_TYPES` in [resolve-handler-use.ts](../../src/route-definition/resolve-handler-use.ts):
53
+
54
+ | Mount site | Allowed item types |
55
+ | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
56
+ | `path()` / `route()` | `layout`, `parallel`, `intercept`, `middleware`, `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `cache`, `transition` |
57
+ | `layout()` | All of the above, plus `route`, `include` |
58
+ | `parallel()` (per slot) | `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `transition` |
59
+ | `intercept()` | `middleware`, `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `layout`, `route`, `when`, `transition` |
60
+ | Response routes (`path.json()`, `path.text()`, …) | `middleware`, `cache` |
61
+
62
+ If `handler.use()` returns a disallowed item for a mount site, registration throws:
63
+
64
+ ```
65
+ handler.use() returned middleware() which is not valid inside parallel().
66
+ Allowed types: revalidate, loader, loading, errorBoundary, notFoundBoundary, transition.
67
+ ```
68
+
69
+ The narrowest contract is `parallel()` — slots cannot bring their own middleware or layout; only data, loading, error/notFound boundaries, revalidation, and transitions.
70
+
71
+ ## Composition with explicit `use()`
72
+
73
+ Every mount site that takes a `use` callback merges in this order:
74
+
75
+ 1. **`handler.use()` items first** — the handler's defaults.
76
+ 2. **Explicit `use()` items second** — overrides specified at the mount site.
77
+
78
+ Items of the same kind from the explicit `use()` follow the existing override rules of that item type. The most important ones for composition:
79
+
80
+ - **`loading()`** — last definition wins, so explicit `loading()` replaces the handler's default.
81
+ - **`parallel({ "@slot": … })`** — the last `parallel()` call wins per slot name. Other slots from earlier calls are preserved (see `skills/parallel`).
82
+ - **`loader()`, `middleware()`, etc.** — accumulate; both the handler's and the explicit ones run.
83
+
84
+ Skip the boilerplate: if neither `handler.use` nor explicit `use()` is provided, no merge happens.
85
+
86
+ ```typescript
87
+ // Handler brings a loader + a (placeholder) loading; explicit use replaces loading.
88
+ const SidebarSlot: Handler = async (ctx) => {
89
+ const data = await ctx.use(SidebarLoader);
90
+ return <Sidebar data={data} />;
91
+ };
92
+ SidebarSlot.use = () => [
93
+ loader(SidebarLoader),
94
+ loading(<DefaultSidebarSkeleton />),
95
+ ];
96
+
97
+ parallel({ "@sidebar": SidebarSlot }, () => [
98
+ // Replaces the default skeleton; SidebarLoader from handler.use still runs.
99
+ loading(<SiteSpecificSidebarSkeleton />),
100
+ ]);
101
+ ```
102
+
103
+ ## Composable parallel slots (the main pay-off)
104
+
105
+ The parallel slot site is where `handler.use` shines. A slot handler that owns its data/loading lets a layout declare **just** the slot names — every loader, skeleton, and revalidation contract travels with the slot itself.
106
+
107
+ ### Without `handler.use` (every caller wires it up)
108
+
109
+ ```typescript
110
+ layout(<DashboardLayout />, () => [
111
+ parallel({ "@cart": CartSummary }, () => [
112
+ loader(CartLoader),
113
+ loading(<CartSkeleton />),
114
+ revalidate(revalidateCartData),
115
+ ]),
116
+ parallel({ "@notifs": NotificationPanel }, () => [
117
+ loader(NotificationsLoader),
118
+ loading(<NotifsSkeleton />),
119
+ revalidate(revalidateNotifs),
120
+ ]),
121
+ path("/dashboard", DashboardIndex, { name: "dashboard.index" }),
122
+ ]);
123
+ ```
124
+
125
+ Every layout that wants `@cart` must repeat the same loader/loading/revalidate triplet.
126
+
127
+ ### With `handler.use` (slot owns its dependencies)
128
+
129
+ ```typescript
130
+ const CartSummary: Handler = async (ctx) => {
131
+ const cart = await ctx.use(CartLoader);
132
+ return <CartSummaryView cart={cart} />;
133
+ };
134
+ CartSummary.use = () => [
135
+ loader(CartLoader),
136
+ loading(<CartSkeleton />),
137
+ revalidate(revalidateCartData),
138
+ ];
139
+
140
+ const NotificationPanel: Handler = async (ctx) => {
141
+ const items = await ctx.use(NotificationsLoader);
142
+ return <NotificationsView items={items} />;
143
+ };
144
+ NotificationPanel.use = () => [
145
+ loader(NotificationsLoader),
146
+ loading(<NotifsSkeleton />),
147
+ revalidate(revalidateNotifs),
148
+ ];
149
+
150
+ // Mount sites become declarative — no per-call data wiring.
151
+ layout(<DashboardLayout />, () => [
152
+ parallel({ "@cart": CartSummary, "@notifs": NotificationPanel }),
153
+ path("/dashboard", DashboardIndex, { name: "dashboard.index" }),
154
+ ]);
155
+
156
+ layout(<AccountLayout />, () => [
157
+ // Same slot, same defaults, zero re-wiring.
158
+ parallel({ "@cart": CartSummary }),
159
+ path("/account", AccountIndex, { name: "account.index" }),
160
+ ]);
161
+ ```
162
+
163
+ Each slot handler is now a portable, self-contained unit. Different layouts can use the same slot without copying data plumbing.
164
+
165
+ ### Streaming behavior is per-slot
166
+
167
+ A slot's `loading()` (whether from `handler.use` or explicit) makes that slot an independent streaming unit — its loader does not block the parent layout. Two slot handlers with their own loading skeletons stream independently.
168
+
169
+ ```typescript
170
+ parallel({
171
+ "@cart": CartSummary, // handler.use loading() → streams independently
172
+ "@cartBadge": CartBadge, // no loading() anywhere → awaited before paint
173
+ });
174
+ ```
175
+
176
+ ### Two scopes for explicit `use` at the mount site: shared (broadcast) and slot-local
177
+
178
+ `parallel()` accepts an explicit `use()` callback that **broadcasts** to every slot in the call ([dsl-helpers.ts](../../src/route-definition/dsl-helpers.ts)). That's the right behavior for the items the parallel allow-list permits and that accumulate (`loader`, `revalidate`, `errorBoundary`, `notFoundBoundary`, `transition`) — every slot gets them. (Note: `middleware` is not allowed inside `parallel()`; see the allowed-types table above.)
179
+
180
+ For single-assignment items like `loading()`, broadcasting overwrites every slot's `handler.use` default. Pass a **slot descriptor** `{ handler, use }` instead: items in the descriptor's `use` apply only to that slot.
181
+
182
+ ```typescript
183
+ parallel({
184
+ "@meta": MetaSlot,
185
+ "@sidebar": {
186
+ handler: SidebarSlot,
187
+ use: () => [loading(<SidebarSkeleton />)], // ← only @sidebar
188
+ },
189
+ });
190
+ ```
191
+
192
+ Per-slot merge order is **handler.use → shared use → slot-local use** (narrowest scope wins for last-write-wins items like `loading()`):
193
+
194
+ ```typescript
195
+ parallel(
196
+ {
197
+ "@cart": {
198
+ handler: Cart,
199
+ use: () => [loading(<CartSkeleton />)], // wins for @cart
200
+ },
201
+ "@notifs": Notifs, // gets <BroadcastSkeleton />
202
+ },
203
+ () => [
204
+ loader(SharedAnalyticsLoader), // accumulates on every slot
205
+ loading(<BroadcastSkeleton />), // applies to slots without slot-local
206
+ ],
207
+ );
208
+ ```
209
+
210
+ Use the descriptor's `use` for `loading(false)` too — opting one slot out of streaming without affecting siblings:
211
+
212
+ ```typescript
213
+ parallel(
214
+ {
215
+ "@cart": { handler: Cart, use: () => [loading(false)] }, // @cart awaits
216
+ "@notifs": Notifs, // @notifs still streams with broadcast skeleton
217
+ },
218
+ () => [loading(<BroadcastSkeleton />)],
219
+ );
220
+ ```
221
+
222
+ Rule of thumb: shared `use` is for items that legitimately apply to every slot. Slot-local `use` is for per-slot precision — especially `loading()` and `loading(false)`.
223
+
224
+ ### Replacing a whole slot from a parent's `handler.use`
225
+
226
+ A handler can publish a default `parallel({...})` set via its `.use`, and the mount site can replace any individual slot by re-declaring it. Last `parallel()` per slot name wins (see `skills/parallel` § Slot Override Semantics).
227
+
228
+ ```typescript
229
+ const ProductPage: Handler<"/product/:slug"> = (ctx) => (
230
+ <article>
231
+ <ProductHero slug={ctx.params.slug} />
232
+ <ParallelOutlet name="@related" />
233
+ <ParallelOutlet name="@reviews" />
234
+ </article>
235
+ );
236
+ ProductPage.use = () => [
237
+ parallel({
238
+ "@related": DefaultRelatedProducts,
239
+ "@reviews": DefaultReviews,
240
+ }),
241
+ ];
242
+
243
+ path("/product/:slug", ProductPage, { name: "product" }, () => [
244
+ // Override @related only; @reviews keeps the default from handler.use.
245
+ parallel({ "@related": SiteSpecificRelated }),
246
+ ]);
247
+ ```
248
+
249
+ ## Other mount sites
250
+
251
+ ### Pages (`path()`)
252
+
253
+ Page handlers can carry middleware, loaders, error boundaries, parallel slots, etc. — anything from the `path` row of the table above.
254
+
255
+ ```typescript
256
+ const CheckoutPage: Handler<"/checkout"> = async (ctx) => { /* … */ };
257
+ CheckoutPage.use = () => [
258
+ middleware(requireAuth),
259
+ loader(CartLoader),
260
+ errorBoundary(<CheckoutError />),
261
+ notFoundBoundary(<CheckoutNotFound />),
262
+ ];
263
+ ```
264
+
265
+ ### Layouts (`layout()`)
266
+
267
+ Layout handlers can carry middleware that runs for every child route, plus default parallels, includes, etc.
268
+
269
+ ```typescript
270
+ const AdminLayout: Handler = (ctx) => {
271
+ const user = ctx.get(CurrentUser);
272
+ return <Admin user={user} />;
273
+ };
274
+ AdminLayout.use = () => [
275
+ middleware(requireAdmin),
276
+ parallel({ "@adminNotifs": AdminNotifsSlot }),
277
+ ];
278
+ ```
279
+
280
+ ### Intercepts (`intercept()`)
281
+
282
+ Intercept handlers can carry their own middleware chain, loaders, and even nested layouts/routes for the modal shell.
283
+
284
+ ```typescript
285
+ const QuickViewModal: Handler = async (ctx) => {
286
+ const product = await ctx.use(ProductLoader);
287
+ return <QuickView product={product} />;
288
+ };
289
+ QuickViewModal.use = () => [
290
+ loader(ProductLoader),
291
+ loading(<QuickViewSkeleton />),
292
+ layout(<ModalChrome />),
293
+ ];
294
+ ```
295
+
296
+ ## `loading()` is a single-assignment item — scope it correctly
297
+
298
+ Most `use` items accumulate when merged: `handler.use` `middleware()` runs _and_ explicit `middleware()` runs; both `loader()` registrations apply. `loading()` is different — it mutates `entry.loading` directly, last call wins ([dsl-helpers.ts `loadingFn`](../../src/route-definition/dsl-helpers.ts)).
299
+
300
+ For pages, layouts, and intercepts that's straightforward: explicit `loading()` at the mount site replaces any `loading()` from `handler.use`. The merge order is `handler.use → explicit`, so the explicit one is the last writer and wins.
301
+
302
+ For parallel slots, the shared `parallel(..., () => [...])` callback is **broadcast** to every slot in the call. A single `loading()` placed there lands on every slot, overwriting each slot's `handler.use` default. To scope `loading()` to one slot, use the **slot descriptor** form:
303
+
304
+ ```typescript
305
+ const Cart: Handler = async (ctx) => { /* … */ };
306
+ Cart.use = () => [loader(CartLoader), loading(<CartSkeleton />)];
307
+
308
+ const Notifs: Handler = async (ctx) => { /* … */ };
309
+ Notifs.use = () => [loader(NotifsLoader), loading(<NotifsSkeleton />)];
310
+
311
+ // ✅ @cart gets a custom skeleton; @notifs keeps its handler.use default.
312
+ parallel({
313
+ "@cart": {
314
+ handler: Cart,
315
+ use: () => [loading(<CustomCartSkeleton />)],
316
+ },
317
+ "@notifs": Notifs,
318
+ });
319
+
320
+ // ✅ Opt one slot out of streaming while siblings still stream the broadcast.
321
+ parallel(
322
+ {
323
+ "@cart": { handler: Cart, use: () => [loading(false)] },
324
+ "@notifs": Notifs,
325
+ },
326
+ () => [loading(<BroadcastSkeleton />)],
327
+ );
328
+ ```
329
+
330
+ Per-slot merge order is **handler.use → shared use → slot-local use**. Slot-local is the narrowest scope, so it wins for last-write-wins items like `loading()`. Items that accumulate within the parallel allow-list (`loader`, `revalidate`, `errorBoundary`, `notFoundBoundary`, `transition`) compose across all three layers regardless.
331
+
332
+ Other things to keep in mind about `loading()`:
333
+
334
+ - Any `loading()` (regardless of source) makes the segment a streaming unit. A handler that includes `loading()` in its `.use` opts every mount site into streaming by default. To opt back out, pass `loading(false)` at the mount site (`loading: false` handling in [match-middleware/segment-resolution.ts](../../src/router/match-middleware/segment-resolution.ts)) — use the slot descriptor form for parallel slots so the opt-out doesn't broadcast.
335
+
336
+ Rule of thumb: only put `loading()` in `handler.use` if you genuinely want every mount site to stream by default. Use the slot descriptor's `use` for any per-slot intent at a `parallel()` call.
337
+
338
+ ## Edge cases & gotchas
339
+
340
+ - **ReactNode handlers cannot have `.use`.** A bare JSX element passed as a handler (e.g., `path("/about", <About />)`) has no function to attach properties to. Pass a function or branded definition instead.
341
+ - **Branded handlers** — `Static()`, `Prerender()`, and `Passthrough()` are positional constructors (not object-arg). Construct first, then attach `.use` to the returned definition:
342
+
343
+ ```typescript
344
+ const ProductPage = Prerender(async (ctx) => {
345
+ const product = await fetchProduct(ctx.params.slug);
346
+ return <ProductView product={product} />;
347
+ });
348
+ ProductPage.use = () => [loader(ProductLoader)];
349
+ ```
350
+
351
+ - **Items can be flat or nested arrays.** `handler.use()` results are flattened with `.flat(3)` before validation, so factory helpers that return arrays inline work the same as in regular `use()` callbacks.
352
+ - **Validation runs at registration / first match**, not at handler definition. A handler doesn't know its mount site at definition time — the same handler used in a `path()` and an `intercept()` is validated against each mount's allowed-types set when registered.
353
+ - **No silent shadowing.** If a disallowed item slips through (e.g., a layout factory returning `cache()` from a slot's `handler.use`), the runtime throws with the offending type and mount site named.
354
+
355
+ ## Cross-references
356
+
357
+ - `skills/route` — `path()` mount site basics
358
+ - `skills/layout` — `layout()` mount site basics
359
+ - `skills/parallel` — parallel slot semantics, slot override rules, streaming behavior
360
+ - `skills/intercept` — intercept mount site basics
361
+ - `skills/loader` — defining `createLoader` and reading via `ctx.use()`
362
+ - `skills/middleware` — middleware semantics and ordering
@@ -311,3 +311,23 @@ export const shopPatterns = urls(({
311
311
  ]),
312
312
  ]);
313
313
  ```
314
+
315
+ ## Handler-attached `.use`
316
+
317
+ Intercept handlers can carry their own middleware, loaders, loading state, error/notFound boundaries, and even nested `layout`/`route`/`when` defaults via `.use` — useful for self-contained modal components that travel with their own data and chrome.
318
+
319
+ ```typescript
320
+ const QuickViewModal: Handler = async (ctx) => {
321
+ const product = await ctx.use(ProductLoader);
322
+ return <QuickView product={product} />;
323
+ };
324
+ QuickViewModal.use = () => [
325
+ loader(ProductLoader),
326
+ loading(<QuickViewSkeleton />),
327
+ layout(<ModalChrome />),
328
+ ];
329
+
330
+ intercept("@modal", "product", QuickViewModal);
331
+ ```
332
+
333
+ Explicit `use()` at the mount site merges with `handler.use` (handler defaults first, explicit second). See [skills/handler-use](../handler-use/SKILL.md) for merge order and the per-mount-site allowed-types table.
@@ -308,3 +308,25 @@ export const shopPatterns = urls(({ path, layout, parallel, loader, revalidate }
308
308
  ]),
309
309
  ]);
310
310
  ```
311
+
312
+ ## Handler-attached `.use`
313
+
314
+ Layout handlers can carry their own middleware, default parallels, and includes via `.use` so a layout becomes a self-contained unit reusable across mount sites.
315
+
316
+ ```typescript
317
+ const AdminLayout: Handler = (ctx) => {
318
+ const user = ctx.get(CurrentUser);
319
+ return <Admin user={user} />;
320
+ };
321
+ AdminLayout.use = () => [
322
+ middleware(requireAdmin),
323
+ parallel({ "@adminNotifs": AdminNotifsSlot }),
324
+ ];
325
+
326
+ // Mount site declares structure only; defaults travel with the layout.
327
+ layout(AdminLayout, () => [
328
+ path("/admin", AdminIndex, { name: "admin.index" }),
329
+ ]);
330
+ ```
331
+
332
+ Allowed item types in a layout's `.use` mirror the layout `use()` callback (the broadest set). Explicit `use()` at the mount site merges with `handler.use` (handler defaults first, explicit second). See [skills/handler-use](../handler-use/SKILL.md) for merge order and per-mount-site allowed types.
@@ -206,6 +206,65 @@ parallel(
206
206
  )
207
207
  ```
208
208
 
209
+ ## Composable Slots via `handler.use`
210
+
211
+ Slot handlers can carry their own loader, loading, error/notFound boundaries, revalidation, and transition defaults via `.use`. The mount site then declares **just the slot names** — no per-call data wiring.
212
+
213
+ ```typescript
214
+ const CartSummary: Handler = async (ctx) => {
215
+ const cart = await ctx.use(CartLoader);
216
+ return <CartSummaryView cart={cart} />;
217
+ };
218
+ CartSummary.use = () => [
219
+ loader(CartLoader),
220
+ loading(<CartSkeleton />),
221
+ revalidate(revalidateCartData),
222
+ ];
223
+
224
+ // Same slot, no copy-pasted plumbing across layouts.
225
+ layout(<DashboardLayout />, () => [
226
+ parallel({ "@cart": CartSummary }),
227
+ path("/dashboard", DashboardIndex, { name: "dashboard.index" }),
228
+ ]);
229
+
230
+ layout(<AccountLayout />, () => [
231
+ parallel({ "@cart": CartSummary }),
232
+ path("/account", AccountIndex, { name: "account.index" }),
233
+ ]);
234
+ ```
235
+
236
+ A slot's `loading()` (whether from `handler.use` or explicit) makes that slot an independent streaming unit, exactly as in the **Streaming Behavior** section above.
237
+
238
+ The `parallel` mount site has the narrowest allow-list for `handler.use` items — slots cannot bring their own middleware or layout, only `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, and `transition`. See [skills/handler-use](../handler-use/SKILL.md) for the full table and merge rules.
239
+
240
+ ### Two scopes for explicit `use`: shared (broadcast) and slot-local
241
+
242
+ `parallel({...slots}, () => [...use])` runs the shared `use()` callback **once per slot** ([dsl-helpers.ts](../../src/route-definition/dsl-helpers.ts)) — items in that callback land on every slot's entry. That's the right behavior for the items the parallel allow-list permits and that accumulate (`loader`, `revalidate`, `errorBoundary`, `notFoundBoundary`, `transition`). (Slots cannot bring `middleware` or `layout` — see the allowed-types note above.)
243
+
244
+ For single-assignment items like `loading()`, broadcasting overwrites every slot's `handler.use` default. Pass a **slot descriptor** `{ handler, use }` instead — items in the descriptor's `use` apply only to that slot:
245
+
246
+ ```typescript
247
+ // @cart gets a custom skeleton; @notifs keeps its handler.use default.
248
+ parallel({
249
+ "@cart": {
250
+ handler: Cart,
251
+ use: () => [loading(<CustomCartSkeleton />)],
252
+ },
253
+ "@notifs": Notifs,
254
+ });
255
+
256
+ // Opt one slot out of streaming while siblings still stream the broadcast.
257
+ parallel(
258
+ {
259
+ "@cart": { handler: Cart, use: () => [loading(false)] },
260
+ "@notifs": Notifs,
261
+ },
262
+ () => [loading(<BroadcastSkeleton />)],
263
+ );
264
+ ```
265
+
266
+ Per-slot merge order is **handler.use → shared use → slot-local use**. Slot-local is the narrowest scope, so it wins for last-write-wins items. See [skills/handler-use § `loading()` is a single-assignment item — scope it correctly](../handler-use/SKILL.md#loading-is-a-single-assignment-item--scope-it-correctly) for the full reasoning.
267
+
209
268
  ## Slot Override Semantics
210
269
 
211
270
  When multiple `parallel()` calls define the same slot name, **the last
@@ -383,6 +383,30 @@ urls(({ path, layout }) => [
383
383
  ])
384
384
  ```
385
385
 
386
+ ## Handler-attached `.use`
387
+
388
+ Page handlers can carry their own loader, middleware, error boundaries, parallels, and other defaults via a `.use` callback — so the page is self-contained and reusable across mount sites without re-wiring the same items.
389
+
390
+ ```typescript
391
+ const ProductPage: Handler<"/product/:slug"> = async (ctx) => {
392
+ const product = await ctx.use(ProductLoader);
393
+ return <ProductView product={product} />;
394
+ };
395
+ ProductPage.use = () => [
396
+ loader(ProductLoader),
397
+ loading(<ProductSkeleton />),
398
+ middleware(async (ctx, next) => {
399
+ await next();
400
+ ctx.header("Cache-Control", "private, max-age=60");
401
+ }),
402
+ ];
403
+
404
+ // Mount site has no per-page wiring — defaults travel with the handler.
405
+ path("/product/:slug", ProductPage, { name: "product" });
406
+ ```
407
+
408
+ Explicit `use()` at the mount site merges with `handler.use` (handler defaults first, explicit second). See [skills/handler-use](../handler-use/SKILL.md) for the merge order, allowed item types per mount site, and override semantics.
409
+
386
410
  ## Complete Example
387
411
 
388
412
  ```typescript
@@ -491,13 +491,25 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
491
491
 
492
492
  const namespace = `${ctx.namespace}.$${store.getNextIndex("parallel")}`;
493
493
 
494
- // Unwrap any static handler definitions in parallel slots
494
+ // Unwrap slot values. A slot value can be:
495
+ // - a Handler / ReactNode (legacy form)
496
+ // - a Static() definition (build-time only)
497
+ // - a slot descriptor `{ handler, use? }` for slot-local overrides
498
+ // The descriptor's `use` runs after the broadcast `use` for that slot,
499
+ // so single-assignment items like `loading()` placed there win without
500
+ // affecting siblings.
495
501
  const unwrappedSlots: Record<string, any> = {};
502
+ const slotLocalUses: Record<string, (() => any[]) | undefined> = {};
496
503
  let hasStaticSlot = false;
497
504
  const staticSlotIds: Record<string, string> = {};
498
- for (const [slotName, slotHandler] of Object.entries(
505
+ for (const [slotName, rawSlot] of Object.entries(
499
506
  slots as Record<string, any>,
500
507
  )) {
508
+ let slotHandler: any = rawSlot;
509
+ if (isSlotDescriptor(rawSlot)) {
510
+ slotHandler = rawSlot.handler;
511
+ slotLocalUses[slotName] = rawSlot.use;
512
+ }
501
513
  if (isStaticHandler(slotHandler)) {
502
514
  hasStaticSlot = true;
503
515
  unwrappedSlots[slotName] = slotHandler.handler;
@@ -564,13 +576,25 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
564
576
  }),
565
577
  } satisfies EntryData;
566
578
 
567
- // Per-slot: handler.use defaults first, then explicit use second.
568
- // This matches the "defaults first, overrides second" rule used by
569
- // path(), layout(), and intercept(). Each slot's handler.use is
570
- // scoped to its own entry (no cross-slot bleed).
571
- const slotHandler = (slots as Record<string, any>)[slotName];
572
- const slotHandlerUse = resolveHandlerUse(slotHandler);
573
- const slotMergedUse = mergeHandlerUse(slotHandlerUse, use, "parallel");
579
+ // Per-slot merge order (narrowest-scope-wins for single-assignment items
580
+ // like loading()):
581
+ // 1. handler.use — defaults baked into the handler
582
+ // 2. shared `use` — broadcast at the parallel() call site
583
+ // 3. slot-local `use` per-slot override via `{ handler, use }` descriptor
584
+ // Items that accumulate (loader, middleware, revalidate, …) compose
585
+ // across all three layers regardless of order.
586
+ const rawSlot = (slots as Record<string, any>)[slotName];
587
+ const slotHandlerForUse = isSlotDescriptor(rawSlot)
588
+ ? rawSlot.handler
589
+ : rawSlot;
590
+ const slotHandlerUse = resolveHandlerUse(slotHandlerForUse);
591
+ const slotLocalUse = slotLocalUses[slotName];
592
+ const explicitUse = combineExplicitUses(use, slotLocalUse);
593
+ const slotMergedUse = mergeHandlerUse(
594
+ slotHandlerUse,
595
+ explicitUse,
596
+ "parallel",
597
+ );
574
598
  if (slotMergedUse) {
575
599
  const result = store.run(namespace, slotEntry, slotMergedUse)?.flat(3);
576
600
  invariant(
@@ -584,6 +608,28 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
584
608
  return { name: namespace, type: "parallel" } as ParallelItem;
585
609
  };
586
610
 
611
+ function isSlotDescriptor(
612
+ value: unknown,
613
+ ): value is { handler: unknown; use?: () => any[] } {
614
+ return (
615
+ typeof value === "object" &&
616
+ value !== null &&
617
+ !("__brand" in value) &&
618
+ "handler" in value &&
619
+ typeof (value as any).handler !== "undefined"
620
+ );
621
+ }
622
+
623
+ function combineExplicitUses(
624
+ sharedUse: (() => any[]) | undefined,
625
+ slotLocalUse: (() => any[]) | undefined,
626
+ ): (() => any[]) | undefined {
627
+ if (!sharedUse && !slotLocalUse) return undefined;
628
+ if (!slotLocalUse) return sharedUse;
629
+ if (!sharedUse) return slotLocalUse;
630
+ return () => [...sharedUse(), ...slotLocalUse()];
631
+ }
632
+
587
633
  /**
588
634
  * Intercept helper - defines an intercepting route for soft navigation
589
635
  */
@@ -123,7 +123,7 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
123
123
  * "@main": async (ctx) => <MainContent data={ctx.use(DataLoader)} />,
124
124
  * })
125
125
  *
126
- * // With loaders and loading states
126
+ * // With loaders and loading states (broadcast to every slot)
127
127
  * parallel({
128
128
  * "@analytics": AnalyticsPanel,
129
129
  * "@metrics": MetricsPanel,
@@ -131,12 +131,36 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
131
131
  * loader(DashboardLoader),
132
132
  * loading(<DashboardSkeleton />),
133
133
  * ])
134
+ *
135
+ * // Per-slot scoped use via slot descriptor — for single-assignment items
136
+ * // like loading() that should not broadcast to siblings.
137
+ * parallel({
138
+ * "@meta": MetaSlot,
139
+ * "@sidebar": {
140
+ * handler: SidebarSlot,
141
+ * use: () => [loading(<SidebarSkeleton />)],
142
+ * },
143
+ * })
134
144
  * ```
135
145
  * @param slots - Object with slot names (prefixed with @) mapped to handlers
146
+ * or `{ handler, use? }` slot descriptors.
136
147
  * @param use - Optional callback for loaders, loading, revalidate, etc.
148
+ * Items here apply to every slot in the call (broadcast).
149
+ * For per-slot single-assignment items, use the slot descriptor's
150
+ * own `use` callback — slot-local items run after the broadcast,
151
+ * so they take precedence on `loading()` and other last-write-wins
152
+ * fields.
137
153
  */
138
154
  parallel: <
139
- TSlots extends Record<`@${string}`, Handler<any, any, TEnv> | ReactNode>,
155
+ TSlots extends Record<
156
+ `@${string}`,
157
+ | Handler<any, any, TEnv>
158
+ | ReactNode
159
+ | {
160
+ handler: Handler<any, any, TEnv> | ReactNode;
161
+ use?: () => UseItems<ParallelUseItem>;
162
+ }
163
+ >,
140
164
  >(
141
165
  slots: TSlots,
142
166
  use?: () => UseItems<ParallelUseItem>,
@@ -233,12 +233,27 @@ export type PathHelpers<TEnv> = {
233
233
  include: IncludeFn<TEnv>;
234
234
 
235
235
  /**
236
- * Define parallel routes that render simultaneously in named slots
236
+ * Define parallel routes that render simultaneously in named slots.
237
+ *
238
+ * A slot value can be a Handler / ReactNode / StaticHandlerDefinition
239
+ * (legacy form, broadcast use applies to every slot) or a slot descriptor
240
+ * `{ handler, use? }` whose `use` is scoped to that slot only. Per-slot
241
+ * merge order is `handler.use` → shared `use` → slot-local `use`, with
242
+ * narrowest scope winning for last-write-wins items like `loading()`.
237
243
  */
238
244
  parallel: <
239
245
  TSlots extends Record<
240
246
  `@${string}`,
241
- Handler<any, any, TEnv> | ReactNode | StaticHandlerDefinition
247
+ | Handler<any, any, TEnv>
248
+ | ReactNode
249
+ | StaticHandlerDefinition
250
+ | {
251
+ handler:
252
+ | Handler<any, any, TEnv>
253
+ | ReactNode
254
+ | StaticHandlerDefinition;
255
+ use?: () => ParallelUseItem[];
256
+ }
242
257
  >,
243
258
  >(
244
259
  slots: TSlots,