@rangojs/router 0.0.0-experimental.110 → 0.0.0-experimental.111

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.
@@ -11,7 +11,11 @@ import {
11
11
  getContext,
12
12
  getNamePrefix,
13
13
  getUrlPrefix,
14
+ requireDslContext,
14
15
  type EntryData,
16
+ type EntryPropDatas,
17
+ type EntryPropSegments,
18
+ type HelperContext,
15
19
  type InterceptEntry,
16
20
  } from "../server/context";
17
21
  import { invariant } from "../errors";
@@ -38,6 +42,7 @@ import type {
38
42
  } from "../route-types.js";
39
43
  import type { RouteHelpers } from "./helpers-types.js";
40
44
  import { resolveHandlerUse, mergeHandlerUse } from "./resolve-handler-use.js";
45
+ import { ALL_USE_ITEM_TYPES } from "./use-item-types.js";
41
46
 
42
47
  /**
43
48
  * Check if an item contains routes (directly or inside nested structures like cache).
@@ -61,16 +66,105 @@ const hasRoutesInItem = (item: AllUseItems): boolean => {
61
66
  return false;
62
67
  };
63
68
 
69
+ /**
70
+ * Fresh empty collections shared by every from-scratch segment entry. Returns
71
+ * new arrays/objects per call so no two entries share mutable references.
72
+ * mountPath is intentionally NOT included here — each call site adds it from
73
+ * getUrlPrefix() where applicable: the route() and transition() helpers add
74
+ * none, while path() (which also builds a `type: "route"` entry) and the
75
+ * structural helpers (layout/cache/middleware/parallel) do.
76
+ */
77
+ const emptySegmentBase = (): EntryPropDatas &
78
+ EntryPropSegments & { loading: undefined } => ({
79
+ loading: undefined,
80
+ middleware: [],
81
+ revalidate: [],
82
+ errorBoundary: [],
83
+ notFoundBoundary: [],
84
+ layout: [],
85
+ parallel: {},
86
+ intercept: [],
87
+ loader: [],
88
+ });
89
+
90
+ /**
91
+ * Run a children/use callback as a nested scope, flatten the result, and assert
92
+ * every item is a valid use item. `kind` preserves the existing error wording
93
+ * ("use()" vs "children" callback).
94
+ */
95
+ function runAndValidateUseItems(
96
+ store: ReturnType<typeof getContext>,
97
+ namespace: string,
98
+ entry: EntryData,
99
+ cb: () => any,
100
+ label: string,
101
+ kind: "use" | "children",
102
+ ): AllUseItems[] {
103
+ const result = store.run(namespace, entry, cb)?.flat(3);
104
+ return validateUseItems(result, namespace, label, kind);
105
+ }
106
+
107
+ /** Assert an already-invoked, flattened callback result is a use-item array. */
108
+ function validateUseItems(
109
+ result: any,
110
+ namespace: string,
111
+ label: string,
112
+ kind: "use" | "children",
113
+ ): AllUseItems[] {
114
+ invariant(
115
+ Array.isArray(result) && result.every((item) => isValidUseItem(item)),
116
+ `${label}() ${kind === "use" ? "use()" : "children"} callback must return an array of use items [${namespace}]`,
117
+ );
118
+ return result as AllUseItems[];
119
+ }
120
+
121
+ /** True when a children/use result contains no routes (directly or nested). */
122
+ const isOrphan = (result: AllUseItems[]): boolean =>
123
+ !result.some((item) => item != null && hasRoutesInItem(item));
124
+
125
+ /**
126
+ * Register a routeless structural entry as an orphan sibling: clear its parent
127
+ * pointer so it leaves the middleware/parent-pointer chain (LOAD-BEARING — see
128
+ * docs/tree-structure.md) and push it onto the parent's layout[] so it renders
129
+ * as a wrapper. Used by cache()/middleware()/transition(); layout() runs extra
130
+ * validation and registers inline.
131
+ */
132
+ const attachOrphanSibling = (
133
+ parent: EntryData | null,
134
+ entry: EntryData,
135
+ ): void => {
136
+ entry.parent = null;
137
+ if (parent && "layout" in parent) parent.layout.push(entry);
138
+ };
139
+
140
+ /**
141
+ * Run `fn` with `ctx.parent` temporarily redirected to `temp` — a satellite
142
+ * entry that captures the attachments declared by a use() callback — restoring
143
+ * the original parent afterward, including on throw. loader()/intercept() each
144
+ * build their own tempParent shape (intercept keeps a loading get/set accessor
145
+ * and a captured-layouts array); this only centralizes the save/restore.
146
+ */
147
+ function withParent<T>(ctx: HelperContext, temp: EntryData, fn: () => T): T {
148
+ const original = ctx.parent;
149
+ ctx.parent = temp;
150
+ try {
151
+ return fn();
152
+ } finally {
153
+ ctx.parent = original;
154
+ }
155
+ }
156
+
64
157
  const revalidate: RouteHelpers<any, any>["revalidate"] = (fn) => {
65
- const ctx = getContext().getStore();
66
- if (!ctx) throw new Error("revalidate() must be called inside map()");
158
+ const { store, ctx } = requireDslContext(
159
+ "revalidate() must be called inside urls()",
160
+ );
67
161
 
68
162
  // Attach to last entry in stack
69
163
  const parent = ctx.parent;
70
164
  if (!parent || !("revalidate" in parent)) {
71
165
  invariant(false, "No parent entry available for revalidate()");
72
166
  }
73
- const name = `$${getContext().getNextIndex("revalidate")}`;
167
+ const name = `$${store.getNextIndex("revalidate")}`;
74
168
  parent.revalidate.push(fn);
75
169
  return { name, type: "revalidate" } as RevalidateItem;
76
170
  };
@@ -108,15 +202,16 @@ const revalidate: RouteHelpers<any, any>["revalidate"] = (fn) => {
108
202
  * ```
109
203
  */
110
204
  const errorBoundary: RouteHelpers<any, any>["errorBoundary"] = (fallback) => {
111
- const ctx = getContext().getStore();
112
- if (!ctx) throw new Error("errorBoundary() must be called inside map()");
205
+ const { store, ctx } = requireDslContext(
206
+ "errorBoundary() must be called inside urls()",
207
+ );
113
208
 
114
209
  // Attach to parent entry in stack
115
210
  const parent = ctx.parent;
116
211
  if (!parent || !("errorBoundary" in parent)) {
117
212
  invariant(false, "No parent entry available for errorBoundary()");
118
213
  }
119
- const name = `$${getContext().getNextIndex("errorBoundary")}`;
214
+ const name = `$${store.getNextIndex("errorBoundary")}`;
120
215
  parent.errorBoundary.push(fallback);
121
216
  return { name, type: "errorBoundary" } as ErrorBoundaryItem;
122
217
  };
@@ -155,15 +250,16 @@ const errorBoundary: RouteHelpers<any, any>["errorBoundary"] = (fallback) => {
155
250
  const notFoundBoundary: RouteHelpers<any, any>["notFoundBoundary"] = (
156
251
  fallback,
157
252
  ) => {
158
- const ctx = getContext().getStore();
159
- if (!ctx) throw new Error("notFoundBoundary() must be called inside map()");
253
+ const { store, ctx } = requireDslContext(
254
+ "notFoundBoundary() must be called inside urls()",
255
+ );
160
256
 
161
257
  // Attach to parent entry in stack
162
258
  const parent = ctx.parent;
163
259
  if (!parent || !("notFoundBoundary" in parent)) {
164
260
  invariant(false, "No parent entry available for notFoundBoundary()");
165
261
  }
166
- const name = `$${getContext().getNextIndex("notFoundBoundary")}`;
262
+ const name = `$${store.getNextIndex("notFoundBoundary")}`;
167
263
  parent.notFoundBoundary.push(fallback);
168
264
  return { name, type: "notFoundBoundary" } as NotFoundBoundaryItem;
169
265
  };
@@ -177,8 +273,9 @@ const notFoundBoundary: RouteHelpers<any, any>["notFoundBoundary"] = (
177
273
  * for the intercept to activate.
178
274
  */
179
275
  const when: RouteHelpers<any, any>["when"] = (fn) => {
180
- const ctx = getContext().getStore();
181
- if (!ctx) throw new Error("when() must be called inside intercept()");
276
+ const { store, ctx } = requireDslContext(
277
+ "when() must be called inside intercept()",
278
+ );
182
279
 
183
280
  // The when() function needs to be captured by the intercept's tempParent
184
281
  // which should have a `when` array. If not present, we're not inside intercept()
@@ -190,7 +287,7 @@ const when: RouteHelpers<any, any>["when"] = (fn) => {
190
287
  );
191
288
  }
192
289
 
193
- const name = `$${getContext().getNextIndex("when")}`;
290
+ const name = `$${store.getNextIndex("when")}`;
194
291
  parent.when.push(fn);
195
292
  return { name, type: "when" } as WhenItem;
196
293
  };
@@ -217,9 +314,9 @@ const cache: RouteHelpers<any, any>["cache"] = (
217
314
  | (() => UseItems<AllUseItems>),
218
315
  maybeChildren?: () => UseItems<AllUseItems>,
219
316
  ) => {
220
- const store = getContext();
221
- const ctx = store.getStore();
222
- if (!ctx) throw new Error("cache() must be called inside map()");
317
+ const { store, ctx } = requireDslContext(
318
+ "cache() must be called inside urls()",
319
+ );
223
320
 
224
321
  // Handle overloaded signature
225
322
  let options: PartialCacheOptions | false;
@@ -271,26 +368,18 @@ const cache: RouteHelpers<any, any>["cache"] = (
271
368
  // Create orphan cache entry (like orphan layout)
272
369
  // Subsequent siblings in the same array will attach to this entry
273
370
  const namespace = `${ctx.namespace}.${cacheIndex}`;
274
- const cacheUrlPrefix = getUrlPrefix();
371
+ const urlPrefix = getUrlPrefix();
275
372
 
276
373
  const entry = {
374
+ ...emptySegmentBase(),
277
375
  id: namespace,
278
376
  shortCode: store.getShortCode("cache"),
279
377
  type: "cache",
280
378
  parent: parent, // link to current parent for hierarchy
281
379
  cache: cacheConfig,
282
380
  handler: RootLayout,
283
- loading: undefined, // Allow loading() to attach loading state
284
- middleware: [],
285
- revalidate: [],
286
- errorBoundary: [],
287
- notFoundBoundary: [],
288
- layout: [],
289
- parallel: {},
290
- intercept: [],
291
- loader: [],
292
- ...(cacheUrlPrefix ? { mountPath: cacheUrlPrefix } : {}),
293
- } as EntryData;
381
+ ...(urlPrefix ? { mountPath: urlPrefix } : {}),
382
+ } satisfies EntryData;
294
383
 
295
384
  // Attach to parent's layout array (cache entries are structural like layouts)
296
385
  if (parent && "layout" in parent) {
@@ -317,9 +406,10 @@ const cache: RouteHelpers<any, any>["cache"] = (
317
406
  const namespace = `${ctx.namespace}.${cacheIndex}`;
318
407
  const cacheShortCode = store.getShortCode("cache");
319
408
 
320
- const cacheUrlPrefix2 = getUrlPrefix();
409
+ const urlPrefix = getUrlPrefix();
321
410
 
322
411
  const entry = {
412
+ ...emptySegmentBase(),
323
413
  id: namespace,
324
414
  shortCode: cacheShortCode,
325
415
  type: "cache",
@@ -327,40 +417,22 @@ const cache: RouteHelpers<any, any>["cache"] = (
327
417
  cache: cacheConfig,
328
418
  // Cache entries render like layouts (with Outlet as default handler)
329
419
  handler: RootLayout, // RootLayout just renders <Outlet />
330
- loading: undefined, // Allow loading() to attach loading state
331
- middleware: [],
332
- revalidate: [],
333
- errorBoundary: [],
334
- notFoundBoundary: [],
335
- layout: [],
336
- parallel: {},
337
- intercept: [],
338
- loader: [],
339
- ...(cacheUrlPrefix2 ? { mountPath: cacheUrlPrefix2 } : {}),
340
- } as EntryData;
420
+ ...(urlPrefix ? { mountPath: urlPrefix } : {}),
421
+ } satisfies EntryData;
341
422
 
342
423
  // Run children with cache entry as parent
343
- const result = store.run(namespace, entry, children)?.flat(3);
344
-
345
- invariant(
346
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
347
- `cache() children callback must return an array of use items [${namespace}]`,
424
+ const result = runAndValidateUseItems(
425
+ store,
426
+ namespace,
427
+ entry,
428
+ children,
429
+ "cache",
430
+ "children",
348
431
  );
349
432
 
350
- // Check if this cache has routes (including nested caches/layouts)
351
- const hasRoutes =
352
- result &&
353
- Array.isArray(result) &&
354
- result.some((item) => hasRoutesInItem(item));
355
-
356
- if (!hasRoutes) {
357
- const parent = ctx.parent;
358
- if (parent && "layout" in parent) {
359
- // Attach to parent's layout array (cache entries are structural like layouts)
360
- entry.parent = null;
361
- parent.layout.push(entry);
362
- }
363
- }
433
+ // Cache entries are structural like layouts: with no routes inside, register
434
+ // as an orphan sibling.
435
+ if (isOrphan(result)) attachOrphanSibling(ctx.parent, entry);
364
436
 
365
437
  return { name: namespace, type: "cache", uses: result } as CacheItem;
366
438
  };
@@ -406,9 +478,9 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
406
478
  }
407
479
  }
408
480
 
409
- const store = getContext();
410
- const ctx = store.getStore();
411
- if (!ctx) throw new Error("middleware() must be called inside map()");
481
+ const { store, ctx } = requireDslContext(
482
+ "middleware() must be called inside urls()",
483
+ );
412
484
 
413
485
  if (!children) {
414
486
  // Sibling mode: attach to parent entry
@@ -427,22 +499,15 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
427
499
 
428
500
  const urlPrefix = getUrlPrefix();
429
501
  const entry = {
502
+ ...emptySegmentBase(),
430
503
  id: namespace,
431
504
  shortCode: store.getShortCode("layout"),
432
505
  type: "layout",
433
506
  parent: ctx.parent,
434
507
  handler: RootLayout,
435
- loading: undefined,
436
508
  middleware: [...fns],
437
- revalidate: [],
438
- errorBoundary: [],
439
- notFoundBoundary: [],
440
- layout: [],
441
- parallel: {},
442
- intercept: [],
443
- loader: [],
444
509
  ...(urlPrefix ? { mountPath: urlPrefix } : {}),
445
- } as EntryData;
510
+ } satisfies EntryData;
446
511
 
447
512
  // Run children callback. If the second arg was actually a middleware fn
448
513
  // (old variadic form: middleware(mw1, mw2)), this will return a non-array
@@ -455,25 +520,14 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
455
520
  "To pass multiple middleware, use middleware([fn1, fn2]).",
456
521
  );
457
522
 
458
- const result = rawResult.flat(3);
459
-
460
- invariant(
461
- result.every((item: any) => isValidUseItem(item)),
462
- `middleware() children callback must return an array of use items [${namespace}]`,
523
+ const result = validateUseItems(
524
+ rawResult.flat(3),
525
+ namespace,
526
+ "middleware",
527
+ "children",
463
528
  );
464
529
 
465
- const hasRoutes =
466
- result &&
467
- Array.isArray(result) &&
468
- result.some((item) => item != null && hasRoutesInItem(item));
469
-
470
- if (!hasRoutes) {
471
- const parent = ctx.parent;
472
- if (parent && "layout" in parent) {
473
- entry.parent = null;
474
- parent.layout.push(entry);
475
- }
476
- }
530
+ if (isOrphan(result)) attachOrphanSibling(ctx.parent, entry);
477
531
 
478
532
  return {
479
533
  name: namespace,
@@ -483,9 +537,9 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
483
537
  };
484
538
 
485
539
  const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
486
- const store = getContext();
487
- const ctx = store.getStore();
488
- if (!ctx) throw new Error("parallel() must be called inside map()");
540
+ const { store, ctx } = requireDslContext(
541
+ "parallel() must be called inside urls()",
542
+ );
489
543
 
490
544
  if (!ctx.parent || !ctx.parent?.parallel) {
491
545
  invariant(false, "No parent entry available for parallel()");
@@ -537,20 +591,12 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
537
591
  // Create full EntryData for parallel with its own loaders/revalidate/loading
538
592
  const parallelUrlPrefix = getUrlPrefix();
539
593
  const entry = {
594
+ ...emptySegmentBase(),
540
595
  id: namespace,
541
596
  shortCode: store.getShortCode("parallel"),
542
597
  type: "parallel",
543
598
  parent: null, // Parallels don't participate in parent chain traversal
544
599
  handler: unwrappedSlots,
545
- loading: undefined, // Allow loading() to attach loading state
546
- middleware: [],
547
- revalidate: [],
548
- errorBoundary: [],
549
- notFoundBoundary: [],
550
- layout: [],
551
- parallel: {},
552
- intercept: [],
553
- loader: [],
554
600
  ...(parallelUrlPrefix ? { mountPath: parallelUrlPrefix } : {}),
555
601
  ...(hasStaticSlot
556
602
  ? {
@@ -605,10 +651,13 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
605
651
  "parallel",
606
652
  );
607
653
  if (slotMergedUse) {
608
- const result = store.run(namespace, slotEntry, slotMergedUse)?.flat(3);
609
- invariant(
610
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
611
- `parallel() use() callback must return an array of use items [${namespace}]`,
654
+ runAndValidateUseItems(
655
+ store,
656
+ namespace,
657
+ slotEntry,
658
+ slotMergedUse,
659
+ "parallel",
660
+ "use",
612
661
  );
613
662
  }
614
663
 
@@ -648,9 +697,9 @@ const intercept = (
648
697
  handler: any,
649
698
  use?: () => any[],
650
699
  ) => {
651
- const store = getContext();
652
- const ctx = store.getStore();
653
- if (!ctx) throw new Error("intercept() must be called inside map()");
700
+ const { store, ctx } = requireDslContext(
701
+ "intercept() must be called inside urls()",
702
+ );
654
703
 
655
704
  if (!ctx.parent || !ctx.parent?.intercept) {
656
705
  invariant(false, "No parent entry available for intercept()");
@@ -689,15 +738,13 @@ const intercept = (
689
738
 
690
739
  // Run merged use callback to collect loaders, revalidate, middleware, etc.
691
740
  if (mergedUse) {
692
- // Create a temporary parent context for the use() callback
693
- // so that middleware, loader, revalidate attach to the intercept entry
694
- const originalParent = ctx.parent;
695
-
696
- // Capture layouts in a temporary array
741
+ // Capture layout() calls into a temporary array
697
742
  const capturedLayouts: EntryData[] = [];
698
743
 
744
+ // Temporary parent so middleware/loader/revalidate/when attach to the
745
+ // intercept entry; the loading get/set accessor mirrors writes onto `entry`.
699
746
  const tempParent = {
700
- ...originalParent,
747
+ ...ctx.parent,
701
748
  middleware: entry.middleware,
702
749
  revalidate: entry.revalidate,
703
750
  errorBoundary: entry.errorBoundary,
@@ -705,7 +752,6 @@ const intercept = (
705
752
  loader: entry.loader,
706
753
  layout: capturedLayouts, // Capture layout() calls
707
754
  when: entry.when, // Capture when() conditions
708
- // Use getter/setter to capture loading on the entry
709
755
  get loading() {
710
756
  return entry.loading;
711
757
  },
@@ -713,12 +759,10 @@ const intercept = (
713
759
  entry.loading = value;
714
760
  },
715
761
  };
716
- ctx.parent = tempParent as EntryData;
717
-
718
- const result = mergedUse()?.flat(3);
719
762
 
720
- // Restore original parent
721
- ctx.parent = originalParent;
763
+ const result = withParent(ctx, tempParent as EntryData, () =>
764
+ mergedUse()?.flat(3),
765
+ );
722
766
 
723
767
  // Extract layout from captured layouts (use first one if multiple)
724
768
  // Layout inside intercept should always be ReactNode or Handler, not Record slots
@@ -728,10 +772,7 @@ const intercept = (
728
772
  | Handler<any, any, any>;
729
773
  }
730
774
 
731
- invariant(
732
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
733
- `intercept() use() callback must return an array of use items [${namespace}]`,
734
- );
775
+ validateUseItems(result, namespace, "intercept", "use");
735
776
  }
736
777
 
737
778
  ctx.parent.intercept.push(entry);
@@ -741,10 +782,10 @@ const intercept = (
741
782
  /**
742
783
  * Loader helper - attaches a loader to the current entry
743
784
  */
744
- const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
745
- const store = getContext();
746
- const ctx = store.getStore();
747
- if (!ctx) throw new Error("loader() must be called inside map()");
785
+ const loader: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
786
+ const { store, ctx } = requireDslContext(
787
+ "loader() must be called inside urls()",
788
+ );
748
789
 
749
790
  // Attach to last entry in stack
750
791
  if (!ctx.parent || !ctx.parent?.loader) {
@@ -765,23 +806,22 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
765
806
 
766
807
  // If any use callback is in effect, run it to collect revalidation rules and cache config
767
808
  if (mergedUse) {
768
- // Temporarily set context for revalidate()/cache() calls to target this loader
769
- const originalParent = ctx.parent;
770
809
  // Create a temporary "parent" with type "loader" so cache() can detect it.
771
810
  // Save existing .cache to distinguish inherited config from newly set config.
772
- const parentCache = (originalParent as any).cache;
811
+ const parentCache = (ctx.parent as any).cache;
773
812
  const tempParent = {
774
- ...originalParent,
813
+ ...ctx.parent,
775
814
  type: "loader",
776
815
  revalidate: loaderEntry.revalidate,
777
816
  };
778
- ctx.parent = tempParent as EntryData;
779
817
 
780
- const result = mergedUse()?.flat(3);
818
+ const result = withParent(ctx, tempParent as EntryData, () =>
819
+ mergedUse()?.flat(3),
820
+ );
781
821
 
782
822
  // Copy cache config only if cache() was called during the use() callback.
783
- // The spread from originalParent may carry an inherited .cache from
784
- // a parent cache() boundary — only copy if it was newly set.
823
+ // The spread may carry an inherited .cache from a parent cache() boundary —
824
+ // only copy if it was newly set.
785
825
  if (
786
826
  (tempParent as any).cache &&
787
827
  (tempParent as any).cache !== parentCache
@@ -789,13 +829,7 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
789
829
  (loaderEntry as any).cache = (tempParent as any).cache;
790
830
  }
791
831
 
792
- // Restore original parent
793
- ctx.parent = originalParent;
794
-
795
- invariant(
796
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
797
- `loader() use() callback must return an array of use items [${name}]`,
798
- );
832
+ validateUseItems(result, name, "loader", "use");
799
833
  }
800
834
 
801
835
  ctx.parent.loader.push(loaderEntry);
@@ -806,10 +840,10 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
806
840
  * Loading helper - attaches a loading component to the current entry
807
841
  * Loading components are static (no context) and shown during navigation
808
842
  */
809
- const loadingFn: RouteHelpers<any, any>["loading"] = (component, options) => {
810
- const store = getContext();
811
- const ctx = store.getStore();
812
- if (!ctx) throw new Error("loading() must be called inside map()");
843
+ const loading: RouteHelpers<any, any>["loading"] = (component, options) => {
844
+ const { store, ctx } = requireDslContext(
845
+ "loading() must be called inside urls()",
846
+ );
813
847
 
814
848
  const parent = ctx.parent;
815
849
  if (!parent || !("loading" in parent)) {
@@ -835,7 +869,7 @@ const loadingFn: RouteHelpers<any, any>["loading"] = (component, options) => {
835
869
  * Transition helper - attaches a ViewTransition config to the current entry
836
870
  * or wraps a group of routes in a transparent layout with ViewTransition
837
871
  */
838
- const transitionFn = (
872
+ const transition = (
839
873
  configOrChildren?: TransitionConfig | (() => UseItems<AllUseItems>),
840
874
  maybeChildren?: () => UseItems<AllUseItems>,
841
875
  ): TransitionItem => {
@@ -849,9 +883,9 @@ const transitionFn = (
849
883
  const children: (() => UseItems<AllUseItems>) | undefined =
850
884
  typeof configOrChildren === "function" ? configOrChildren : maybeChildren;
851
885
 
852
- const store = getContext();
853
- const ctx = store.getStore();
854
- if (!ctx) throw new Error("transition() must be called inside map()");
886
+ const { store, ctx } = requireDslContext(
887
+ "transition() must be called inside urls()",
888
+ );
855
889
 
856
890
  const name = `$${store.getNextIndex("transition")}`;
857
891
 
@@ -868,68 +902,43 @@ const transitionFn = (
868
902
  // Position 2: wrapper — create a transparent layout with transition config
869
903
  const namespace = `${ctx.namespace}.${store.getNextIndex("transition")}`;
870
904
  const entry = {
905
+ ...emptySegmentBase(),
871
906
  id: namespace,
872
907
  shortCode: store.getShortCode("layout"),
873
908
  type: "layout",
874
909
  parent: ctx.parent,
875
910
  handler: RootLayout,
876
- loading: undefined,
877
911
  transition: config,
878
- middleware: [],
879
- revalidate: [],
880
- errorBoundary: [],
881
- notFoundBoundary: [],
882
- layout: [],
883
- parallel: {},
884
- intercept: [],
885
- loader: [],
886
- } as EntryData;
887
-
888
- const result = store.run(namespace, entry, children)?.flat(3);
912
+ } satisfies EntryData;
889
913
 
890
- invariant(
891
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
892
- `transition() children callback must return an array of use items [${namespace}]`,
914
+ const result = runAndValidateUseItems(
915
+ store,
916
+ namespace,
917
+ entry,
918
+ children,
919
+ "transition",
920
+ "children",
893
921
  );
894
922
 
895
- const hasRoutes =
896
- result &&
897
- Array.isArray(result) &&
898
- result.some((item) => hasRoutesInItem(item));
899
-
900
- if (!hasRoutes) {
901
- const parent = ctx.parent;
902
- if (parent && "layout" in parent) {
903
- entry.parent = null;
904
- parent.layout.push(entry);
905
- }
906
- }
923
+ if (isOrphan(result)) attachOrphanSibling(ctx.parent, entry);
907
924
 
908
925
  return { name: namespace, type: "transition" } as TransitionItem;
909
926
  };
910
927
 
911
- const routeFn: RouteHelpers<any, any>["route"] = (name, handler, use) => {
912
- const store = getContext();
913
- const ctx = store.getStore();
914
- if (!ctx) throw new Error("route() must be called inside map()");
928
+ const route: RouteHelpers<any, any>["route"] = (name, handler, use) => {
929
+ const { store, ctx } = requireDslContext(
930
+ "route() must be called inside urls()",
931
+ );
915
932
 
916
933
  const namespace = `${ctx.namespace}.${store.getNextIndex("route")}.${name}`;
917
934
 
918
935
  const entry = {
936
+ ...emptySegmentBase(),
919
937
  id: namespace,
920
938
  shortCode: store.getShortCode("route"),
921
939
  type: "route",
922
940
  parent: ctx.parent,
923
941
  handler: handler as unknown as Handler<any, any, any>,
924
- loading: undefined, // Allow loading() to attach loading state
925
- middleware: [],
926
- revalidate: [],
927
- errorBoundary: [],
928
- notFoundBoundary: [],
929
- layout: [],
930
- parallel: {},
931
- intercept: [],
932
- loader: [],
933
942
  } satisfies EntryData;
934
943
 
935
944
  /* We will throw if user is registring same route name twice */
@@ -944,10 +953,13 @@ const routeFn: RouteHelpers<any, any>["route"] = (name, handler, use) => {
944
953
  const mergedUse = mergeHandlerUse(handlerUseFn, use, "route");
945
954
  /* Run use and attach handlers */
946
955
  if (mergedUse) {
947
- const result = store.run(namespace, entry, mergedUse)?.flat(3);
948
- invariant(
949
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
950
- `route() use() callback must return an array of use items [${namespace}]`,
956
+ const result = runAndValidateUseItems(
957
+ store,
958
+ namespace,
959
+ entry,
960
+ mergedUse,
961
+ "route",
962
+ "use",
951
963
  );
952
964
  return { name: namespace, type: "route", uses: result } as RouteItem;
953
965
  }
@@ -957,9 +969,9 @@ const routeFn: RouteHelpers<any, any>["route"] = (name, handler, use) => {
957
969
  };
958
970
 
959
971
  const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
960
- const store = getContext();
961
- const ctx = store.getStore();
962
- if (!ctx) throw new Error("layout() must be called inside map()");
972
+ const { store, ctx } = requireDslContext(
973
+ "layout() must be called inside urls()",
974
+ );
963
975
 
964
976
  invariant(
965
977
  !ctx.parent || ctx.parent.type !== "parallel",
@@ -977,20 +989,12 @@ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
977
989
 
978
990
  const urlPrefix = getUrlPrefix();
979
991
  const entry = {
992
+ ...emptySegmentBase(),
980
993
  id: namespace,
981
994
  shortCode,
982
995
  type: "layout",
983
996
  parent: ctx.parent,
984
997
  handler: unwrappedHandler,
985
- loading: undefined, // Allow loading() to attach loading state
986
- middleware: [],
987
- revalidate: [],
988
- errorBoundary: [],
989
- notFoundBoundary: [],
990
- parallel: {},
991
- intercept: [],
992
- layout: [],
993
- loader: [],
994
998
  ...(urlPrefix ? { mountPath: urlPrefix } : {}),
995
999
  ...(isStatic
996
1000
  ? {
@@ -1012,11 +1016,13 @@ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
1012
1016
  // Run merged use callback if present
1013
1017
  let result: AllUseItems[] | undefined;
1014
1018
  if (mergedUse) {
1015
- result = store.run(namespace, entry, mergedUse)?.flat(3);
1016
-
1017
- invariant(
1018
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
1019
- `layout() use() callback must return an array of use items [${namespace}]`,
1019
+ result = runAndValidateUseItems(
1020
+ store,
1021
+ namespace,
1022
+ entry,
1023
+ mergedUse,
1024
+ "layout",
1025
+ "use",
1020
1026
  );
1021
1027
  }
1022
1028
 
@@ -1058,9 +1064,7 @@ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
1058
1064
  `Orphan layouts can only be defined inside route or layout > check [${namespace}]`,
1059
1065
  );
1060
1066
 
1061
- // Clear parent pointer for orphan layouts to prevent duplicate processing
1062
- entry.parent = null;
1063
- parent.layout.push(entry);
1067
+ attachOrphanSibling(parent, entry);
1064
1068
  }
1065
1069
  }
1066
1070
 
@@ -1073,33 +1077,15 @@ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
1073
1077
  } as LayoutItem;
1074
1078
  };
1075
1079
 
1076
- const isValidUseItem = (item: any): item is AllUseItems | undefined | null => {
1077
- return (
1078
- typeof item === "undefined" ||
1079
- item === null ||
1080
- (item &&
1081
- typeof item === "object" &&
1082
- "type" in item &&
1083
- [
1084
- "layout",
1085
- "route",
1086
- "middleware",
1087
- "revalidate",
1088
- "parallel",
1089
- "intercept",
1090
- "loader",
1091
- "loading",
1092
- "errorBoundary",
1093
- "notFoundBoundary",
1094
- "when",
1095
- "cache",
1096
- "transition",
1097
- "include", // For urls() include() helper
1098
- ].includes(item.type))
1099
- );
1100
- };
1080
+ const isValidUseItem = (item: any): item is AllUseItems | undefined | null =>
1081
+ item == null ||
1082
+ (typeof item === "object" &&
1083
+ "type" in item &&
1084
+ ALL_USE_ITEM_TYPES.has(item.type));
1101
1085
 
1102
- // Global helper exports for direct import from @rangojs/router
1086
+ // DSL helpers exported for direct import from @rangojs/router and for
1087
+ // assembly into the RouteHelpers object in helper-factories.ts. The route-item
1088
+ // types are discriminated by their `type` literal, so the helpers carry no brand.
1103
1089
  export {
1104
1090
  layout,
1105
1091
  cache,
@@ -1110,25 +1096,11 @@ export {
1110
1096
  when,
1111
1097
  errorBoundary,
1112
1098
  notFoundBoundary,
1113
- loaderFn as loader,
1114
- loadingFn as loading,
1115
- transitionFn as transition,
1116
- };
1117
-
1118
- const isOrphanLayout = (item: AllUseItems): boolean => {
1119
- return (
1120
- item.type === "layout" &&
1121
- !item.uses?.some((child) => hasRoutesInItem(child))
1122
- );
1123
- };
1124
-
1125
- // Internal exports used by helper-factories.ts
1126
- export {
1127
- routeFn,
1128
- loaderFn,
1129
- loadingFn,
1130
- transitionFn,
1131
- hasRoutesInItem,
1099
+ route,
1100
+ loader,
1101
+ loading,
1102
+ transition,
1132
1103
  isValidUseItem,
1133
- isOrphanLayout,
1104
+ emptySegmentBase,
1105
+ runAndValidateUseItems,
1134
1106
  };