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

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.
Files changed (65) hide show
  1. package/dist/bin/rango.js +41 -37
  2. package/dist/vite/index.js +144 -191
  3. package/package.json +17 -14
  4. package/skills/handler-use/SKILL.md +1 -1
  5. package/skills/rango/SKILL.md +20 -0
  6. package/src/browser/action-coordinator.ts +53 -36
  7. package/src/browser/event-controller.ts +42 -66
  8. package/src/browser/navigation-bridge.ts +4 -0
  9. package/src/browser/navigation-client.ts +12 -15
  10. package/src/browser/navigation-store.ts +7 -8
  11. package/src/browser/navigation-transaction.ts +7 -21
  12. package/src/browser/partial-update.ts +8 -16
  13. package/src/browser/react/NavigationProvider.tsx +29 -40
  14. package/src/browser/react/use-params.ts +3 -4
  15. package/src/browser/response-adapter.ts +25 -0
  16. package/src/browser/rsc-router.tsx +16 -2
  17. package/src/browser/server-action-bridge.ts +23 -30
  18. package/src/browser/types.ts +2 -0
  19. package/src/build/generate-manifest.ts +29 -31
  20. package/src/build/generate-route-types.ts +2 -0
  21. package/src/build/route-types/router-processing.ts +37 -9
  22. package/src/build/runtime-discovery.ts +9 -20
  23. package/src/decode-loader-results.ts +36 -0
  24. package/src/errors.ts +29 -0
  25. package/src/index.rsc.ts +1 -0
  26. package/src/index.ts +1 -0
  27. package/src/response-utils.ts +9 -0
  28. package/src/route-content-wrapper.tsx +6 -28
  29. package/src/route-definition/dsl-helpers.ts +231 -259
  30. package/src/route-definition/helper-factories.ts +29 -139
  31. package/src/route-definition/use-item-types.ts +32 -0
  32. package/src/route-types.ts +19 -41
  33. package/src/router/content-negotiation.ts +15 -2
  34. package/src/router/intercept-resolution.ts +4 -18
  35. package/src/router/match-result.ts +32 -30
  36. package/src/router/middleware.ts +46 -78
  37. package/src/router/preview-match.ts +3 -1
  38. package/src/router/request-classification.ts +4 -28
  39. package/src/rsc/handler.ts +20 -65
  40. package/src/rsc/helpers.ts +3 -2
  41. package/src/rsc/origin-guard.ts +28 -10
  42. package/src/rsc/response-route-handler.ts +32 -52
  43. package/src/rsc/rsc-rendering.ts +27 -53
  44. package/src/rsc/runtime-warnings.ts +9 -10
  45. package/src/rsc/server-action.ts +13 -37
  46. package/src/rsc/ssr-setup.ts +16 -0
  47. package/src/segment-system.tsx +5 -39
  48. package/src/server/context.ts +76 -35
  49. package/src/urls/include-helper.ts +10 -53
  50. package/src/urls/index.ts +0 -3
  51. package/src/urls/path-helper.ts +17 -52
  52. package/src/urls/pattern-types.ts +2 -19
  53. package/src/urls/response-types.ts +20 -19
  54. package/src/urls/type-extraction.ts +20 -115
  55. package/src/urls/urls-function.ts +1 -5
  56. package/src/vite/discovery/discover-routers.ts +10 -22
  57. package/src/vite/discovery/route-types-writer.ts +38 -82
  58. package/src/vite/plugins/cjs-to-esm.ts +3 -7
  59. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  60. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  61. package/src/vite/plugins/expose-internal-ids.ts +34 -62
  62. package/src/vite/plugins/version-injector.ts +2 -12
  63. package/src/vite/router-discovery.ts +71 -26
  64. package/src/vite/utils/shared-utils.ts +13 -1
  65. package/src/browser/action-response-classifier.ts +0 -99
@@ -4,6 +4,8 @@ import { useContext, useState, useEffect, useRef } from "react";
4
4
  import { NavigationStoreContext } from "./context.js";
5
5
  import { shallowEqual } from "./shallow-equal.js";
6
6
 
7
+ const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
8
+
7
9
  /**
8
10
  * Hook to access the current route params.
9
11
  *
@@ -43,10 +45,7 @@ export function useParams<T>(
43
45
  const ctx = useContext(NavigationStoreContext);
44
46
 
45
47
  const [value, setValue] = useState<T | Record<string, string>>(() => {
46
- if (!ctx) {
47
- return selector ? selector({}) : {};
48
- }
49
- const params = ctx.eventController.getParams();
48
+ const params = ctx ? ctx.eventController.getParams() : EMPTY_PARAMS;
50
49
  return selector ? selector(params) : params;
51
50
  });
52
51
 
@@ -24,6 +24,31 @@ export function emptyResponse(): Response {
24
24
  return new Response(null, { status: 200 });
25
25
  }
26
26
 
27
+ /**
28
+ * Handle the X-RSC-Reload control header (server requests a full page reload on
29
+ * a version mismatch). Returns a short-circuit response when the header is
30
+ * present -- emptyResponse() if the URL was blocked by origin validation, or a
31
+ * never-resolving promise while the page reloads -- and null when absent, so
32
+ * the caller continues processing (e.g. the X-RSC-Redirect check). Scoped to
33
+ * X-RSC-Reload only; redirect handling differs between callers.
34
+ */
35
+ export function handleReloadHeader(
36
+ response: Response,
37
+ opts: { onBlocked: () => void; onReload: (url: string) => void },
38
+ ): Response | Promise<Response> | null {
39
+ const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
40
+ if (reload === "blocked") {
41
+ opts.onBlocked();
42
+ return emptyResponse();
43
+ }
44
+ if (reload) {
45
+ opts.onReload(reload.url);
46
+ window.location.href = reload.url;
47
+ return new Promise<Response>(() => {});
48
+ }
49
+ return null;
50
+ }
51
+
27
52
  /**
28
53
  * Tee a response body for RSC parsing and stream completion tracking.
29
54
  * Returns a new Response with one branch; the other is consumed to detect
@@ -364,11 +364,18 @@ export async function initBrowserApp(
364
364
  // Update version BEFORE rebuilding state so that
365
365
  // clearHistoryCache() runs first, then the fresh segment
366
366
  // cache entry we create below survives.
367
+ //
368
+ // Compare against the bridge's live version, not the init-time
369
+ // `version` const: after the first HMR bump the const is stale, so a
370
+ // later update with an unchanged version would otherwise re-clear the
371
+ // cache and re-broadcast across tabs/apps. The live read fires only
372
+ // on a genuine version change.
367
373
  const newVersion = payload.metadata.version;
368
- if (newVersion && newVersion !== version) {
374
+ const currentVersion = navigationBridge.getVersion();
375
+ if (newVersion && newVersion !== currentVersion) {
369
376
  console.log(
370
377
  "[Rango] HMR: version changed",
371
- version,
378
+ currentVersion,
372
379
  "→",
373
380
  newVersion,
374
381
  "clearing caches",
@@ -376,6 +383,13 @@ export async function initBrowserApp(
376
383
  navigationBridge.updateVersion(newVersion);
377
384
  }
378
385
 
386
+ // Apply only partial segment updates. A non-partial payload during
387
+ // HMR is transient: the worker route table is still rebuilding after
388
+ // the edit, so the URL momentarily resolves to not-found/catch-all.
389
+ // Skip it -- the debounced follow-up refetch returns the settled
390
+ // route's partial payload and renders it below. We never reload here:
391
+ // a paramless document GET would run the SSR path and surface the
392
+ // not-found page during that same transient.
379
393
  if (payload.metadata?.isPartial) {
380
394
  const segments = payload.metadata.segments || [];
381
395
  const matched = payload.metadata.matched || [];
@@ -25,6 +25,7 @@ import { validateRedirectOrigin } from "./validate-redirect-origin.js";
25
25
  import {
26
26
  extractRscHeaderUrl,
27
27
  emptyResponse,
28
+ handleReloadHeader,
28
29
  teeWithCompletion,
29
30
  } from "./response-adapter.js";
30
31
  import { mergeLocationState } from "./history-state.js";
@@ -77,6 +78,20 @@ export function createServerActionBridge(
77
78
  onNavigate,
78
79
  } = config;
79
80
 
81
+ // SPA-navigate when onNavigate is set, else hard-reload. state is omitted (not
82
+ // passed as undefined) to match the header path's prior call shape.
83
+ async function dispatchRedirect(url: string, state?: unknown): Promise<void> {
84
+ if (onNavigate) {
85
+ await onNavigate(url, {
86
+ ...(state !== undefined ? { state } : {}),
87
+ replace: true,
88
+ _skipCache: true,
89
+ });
90
+ } else {
91
+ window.location.href = url;
92
+ }
93
+ }
94
+
80
95
  let isRegistered = false;
81
96
 
82
97
  const fetchPartialUpdate = createPartialUpdater({
@@ -222,18 +237,12 @@ export function createServerActionBridge(
222
237
  handle.signal.removeEventListener("abort", onHandleAbort);
223
238
 
224
239
  // Check for version mismatch - server wants us to reload
225
- const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
226
- if (reload === "blocked") {
227
- resolveStreamComplete();
228
- return emptyResponse();
229
- }
230
- if (reload) {
231
- log("version mismatch on action, reloading", {
232
- reloadUrl: reload.url,
233
- });
234
- window.location.href = reload.url;
235
- return new Promise<Response>(() => {});
236
- }
240
+ const reloadResult = handleReloadHeader(response, {
241
+ onBlocked: resolveStreamComplete,
242
+ onReload: (url) =>
243
+ log("version mismatch on action, reloading", { reloadUrl: url }),
244
+ });
245
+ if (reloadResult) return reloadResult;
237
246
 
238
247
  // Simple redirect from action (no state, no RSC payload).
239
248
  // Short-circuits before createFromFetch — no Flight deserialization needed.
@@ -243,14 +252,7 @@ export function createServerActionBridge(
243
252
  if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
244
253
  log("action simple redirect", { url: redirect.url });
245
254
  handle.complete(undefined);
246
- if (onNavigate) {
247
- await onNavigate(redirect.url, {
248
- replace: true,
249
- _skipCache: true,
250
- });
251
- } else {
252
- window.location.href = redirect.url;
253
- }
255
+ await dispatchRedirect(redirect.url);
254
256
  return new Promise<Response>(() => {});
255
257
  }
256
258
  if (redirect === "blocked") {
@@ -339,18 +341,9 @@ export function createServerActionBridge(
339
341
  handle.complete(returnValue?.data);
340
342
  return returnValue?.data;
341
343
  }
342
- const redirectState = metadata.locationState;
343
344
  log("action redirect", { url: redirectUrl });
344
345
  handle.complete(returnValue?.data);
345
- if (onNavigate) {
346
- await onNavigate(redirectUrl, {
347
- state: redirectState,
348
- replace: true,
349
- _skipCache: true,
350
- });
351
- } else {
352
- window.location.href = redirectUrl;
353
- }
346
+ await dispatchRedirect(redirectUrl, metadata.locationState);
354
347
  return returnValue?.data;
355
348
  }
356
349
 
@@ -552,6 +552,8 @@ export interface NavigationBridge {
552
552
  refresh(): Promise<void>;
553
553
  handlePopstate(): Promise<void>;
554
554
  registerLinkInterception(): () => void;
555
+ /** Current RSC version (live, reflects the latest updateVersion). */
556
+ getVersion(): string | undefined;
555
557
  /** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
556
558
  updateVersion(newVersion: string): void;
557
559
  /**
@@ -57,6 +57,26 @@ export interface GeneratedManifest {
57
57
  * Build prefix tree node by running the patterns with proper context.
58
58
  * Uses a visited set to detect circular includes and prevent infinite recursion.
59
59
  */
60
+ // Merge tracked nested includes into `target`. Multiple includes can share a
61
+ // fullPrefix (e.g. include("/", a), include("/", b)) — concat their routes and
62
+ // Object.assign children rather than overwrite.
63
+ function mergeIncludeNodes(
64
+ target: Record<string, PrefixTreeNode>,
65
+ includes: TrackedInclude[],
66
+ buildChild: (include: TrackedInclude) => PrefixTreeNode,
67
+ ): void {
68
+ for (const include of includes) {
69
+ const node = buildChild(include);
70
+ const existing = target[include.fullPrefix];
71
+ if (existing) {
72
+ existing.routes.push(...node.routes);
73
+ Object.assign(existing.children, node.children);
74
+ } else {
75
+ target[include.fullPrefix] = node;
76
+ }
77
+ }
78
+ }
79
+
60
80
  function buildPrefixTreeNode(
61
81
  urlPrefix: string,
62
82
  namePrefix: string | undefined,
@@ -166,13 +186,9 @@ function buildPrefixTreeNode(
166
186
  }
167
187
  }
168
188
 
169
- // Build children from tracked nested includes.
170
- // Multiple includes can share the same fullPrefix (e.g., include("/", patternsA),
171
- // include("/", patternsB)). Merge their routes instead of overwriting.
172
189
  const children: Record<string, PrefixTreeNode> = {};
173
-
174
- for (const include of trackedIncludes) {
175
- const childNode = buildPrefixTreeNode(
190
+ mergeIncludeNodes(children, trackedIncludes, (include) =>
191
+ buildPrefixTreeNode(
176
192
  include.fullPrefix,
177
193
  include.namePrefix,
178
194
  include.patterns as UrlPatterns<any>,
@@ -186,16 +202,8 @@ function buildPrefixTreeNode(
186
202
  passthroughRoutes,
187
203
  responseTypeRoutes,
188
204
  routeSearchSchemas,
189
- );
190
-
191
- const existing = children[include.fullPrefix];
192
- if (existing) {
193
- existing.routes.push(...childNode.routes);
194
- Object.assign(existing.children, childNode.children);
195
- } else {
196
- children[include.fullPrefix] = childNode;
197
- }
198
- }
205
+ ),
206
+ );
199
207
 
200
208
  // Remove from visited so sibling branches can reuse the same patterns
201
209
  // without false circular-include detection. Only ancestors in the current
@@ -356,12 +364,10 @@ export function generateManifestFull<TEnv>(
356
364
  }
357
365
  }
358
366
 
359
- // Build prefix tree from tracked includes (shared visited set for cycle detection).
360
- // Multiple includes can share the same fullPrefix (e.g., include("/", patternsA),
361
- // include("/", patternsB)). Merge their routes instead of overwriting.
367
+ // Shared visited set for cycle detection across all root-level includes.
362
368
  const visited = new Set<unknown>();
363
- for (const include of trackedIncludes) {
364
- const node = buildPrefixTreeNode(
369
+ mergeIncludeNodes(prefixTree, trackedIncludes, (include) =>
370
+ buildPrefixTreeNode(
365
371
  include.fullPrefix,
366
372
  include.namePrefix,
367
373
  include.patterns as UrlPatterns<any>,
@@ -375,16 +381,8 @@ export function generateManifestFull<TEnv>(
375
381
  passthroughRoutes,
376
382
  responseTypeRoutes,
377
383
  routeSearchSchemas,
378
- );
379
-
380
- const existing = prefixTree[include.fullPrefix];
381
- if (existing) {
382
- existing.routes.push(...node.routes);
383
- Object.assign(existing.children, node.children);
384
- } else {
385
- prefixTree[include.fullPrefix] = node;
386
- }
387
- }
384
+ ),
385
+ );
388
386
 
389
387
  return {
390
388
  prefixTree,
@@ -35,5 +35,7 @@ export {
35
35
  formatNestedRouterConflictError,
36
36
  findRouterFiles,
37
37
  writeCombinedRouteTypes,
38
+ genFileTsPath,
39
+ resolveSearchSchemas,
38
40
  } from "./route-types/router-processing.js";
39
41
  export { findUrlsVariableNames } from "./route-types/per-module-writer.js";
@@ -339,6 +339,36 @@ function applyBasenameToRoutes(
339
339
  return { routes: prefixed, searchSchemas: result.searchSchemas };
340
340
  }
341
341
 
342
+ // Filesystem path of the generated route-types file for a router source file.
343
+ // Native separators — matches the self-gen-tracking Map key the watcher compares.
344
+ export function genFileTsPath(sourceFile: string): string {
345
+ const base = pathBasename(sourceFile).replace(/\.(tsx?|jsx?)$/, "");
346
+ return join(dirname(sourceFile), `${base}.named-routes.gen.ts`);
347
+ }
348
+
349
+ // Search schemas for the gen file: prefer the runtime manifest's; when it omits
350
+ // them (some module-runner flows) fall back to static parsing filtered to the
351
+ // public route-name set. Returns the runtime value unchanged otherwise.
352
+ export function resolveSearchSchemas(
353
+ publicRouteNames: string[],
354
+ runtimeSchemas: Record<string, Record<string, string>> | undefined,
355
+ sourceFile: string,
356
+ ): Record<string, Record<string, string>> | undefined {
357
+ if (runtimeSchemas && Object.keys(runtimeSchemas).length > 0) {
358
+ return runtimeSchemas;
359
+ }
360
+ const staticParsed = buildCombinedRouteMapForRouterFile(sourceFile);
361
+ if (Object.keys(staticParsed.searchSchemas).length === 0) {
362
+ return runtimeSchemas;
363
+ }
364
+ const filtered: Record<string, Record<string, string>> = {};
365
+ for (const name of publicRouteNames) {
366
+ const schema = staticParsed.searchSchemas[name];
367
+ if (schema) filtered[name] = schema;
368
+ }
369
+ return Object.keys(filtered).length > 0 ? filtered : runtimeSchemas;
370
+ }
371
+
342
372
  /**
343
373
  * Resolve routes and search schemas from a router source file by following the
344
374
  * variable passed to `.routes(...)` or `urls: ...` in createRouter options,
@@ -528,7 +558,10 @@ export function findRouterFiles(root: string, filter?: ScanFilter): string[] {
528
558
  export function writeCombinedRouteTypes(
529
559
  root: string,
530
560
  knownRouterFiles?: string[],
531
- opts?: { preserveIfLarger?: boolean },
561
+ opts?: {
562
+ preserveIfLarger?: boolean;
563
+ onWrite?: (outPath: string, content: string) => void;
564
+ },
532
565
  ): void {
533
566
  // Delete old combined named-routes.gen.ts if it exists (stale from older versions)
534
567
  try {
@@ -566,14 +599,7 @@ export function writeCombinedRouteTypes(
566
599
  if (!extractUrlsFromRouter(routerSource)) continue;
567
600
  }
568
601
 
569
- const routerBasename = pathBasename(routerFilePath).replace(
570
- /\.(tsx?|jsx?)$/,
571
- "",
572
- );
573
- const outPath = join(
574
- dirname(routerFilePath),
575
- `${routerBasename}.named-routes.gen.ts`,
576
- );
602
+ const outPath = genFileTsPath(routerFilePath);
577
603
  const existing = existsSync(outPath)
578
604
  ? readFileSync(outPath, "utf-8")
579
605
  : null;
@@ -584,6 +610,7 @@ export function writeCombinedRouteTypes(
584
610
  if (Object.keys(result.routes).length === 0) {
585
611
  if (!existing) {
586
612
  const emptySource = generateRouteTypesSource({});
613
+ opts?.onWrite?.(outPath, emptySource);
587
614
  writeFileSync(outPath, emptySource);
588
615
  }
589
616
  continue;
@@ -609,6 +636,7 @@ export function writeCombinedRouteTypes(
609
636
  continue;
610
637
  }
611
638
  }
639
+ opts?.onWrite?.(outPath, source);
612
640
  writeFileSync(outPath, source);
613
641
  console.log(
614
642
  `[rango] Generated route types (${Object.keys(result.routes).length} routes) -> ${outPath}`,
@@ -1,8 +1,9 @@
1
- import { dirname, join, basename, resolve } from "node:path";
1
+ import { resolve } from "node:path";
2
2
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import {
4
4
  generateRouteTypesSource,
5
- buildCombinedRouteMapForRouterFile,
5
+ genFileTsPath,
6
+ resolveSearchSchemas,
6
7
  } from "./generate-route-types.ts";
7
8
  import { isAutoGeneratedRouteName } from "../route-name.js";
8
9
 
@@ -175,25 +176,13 @@ export async function discoverAndWriteRouteTypes(
175
176
  );
176
177
  }
177
178
 
178
- // Search schema fallback: runtime manifest may omit search schema metadata
179
- // in some module-runner flows. Fall back to static source parsing.
180
- if (!routeSearchSchemas || Object.keys(routeSearchSchemas).length === 0) {
181
- const staticParsed = buildCombinedRouteMapForRouterFile(sourceFile);
182
- if (Object.keys(staticParsed.searchSchemas).length > 0) {
183
- const filtered: Record<string, Record<string, string>> = {};
184
- for (const name of Object.keys(routeManifest)) {
185
- const schema = staticParsed.searchSchemas[name];
186
- if (schema) filtered[name] = schema;
187
- }
188
- if (Object.keys(filtered).length > 0) {
189
- routeSearchSchemas = filtered;
190
- }
191
- }
192
- }
179
+ routeSearchSchemas = resolveSearchSchemas(
180
+ Object.keys(routeManifest),
181
+ routeSearchSchemas,
182
+ sourceFile,
183
+ );
193
184
 
194
- const routerDir = dirname(sourceFile);
195
- const routerBasename = basename(sourceFile).replace(/\.(tsx?|jsx?)$/, "");
196
- const outPath = join(routerDir, `${routerBasename}.named-routes.gen.ts`);
185
+ const outPath = genFileTsPath(sourceFile);
197
186
 
198
187
  const source = generateRouteTypesSource(
199
188
  routeManifest,
@@ -0,0 +1,36 @@
1
+ import type { ReactNode } from "react";
2
+ import { isLoaderDataResult } from "./types.js";
3
+
4
+ // Shared by segment-system (server) and LoaderResolver (client) so the
5
+ // legacy/ok/error-fallback/throw decode of resolved loader values lives once.
6
+ // Last failing loader wins errorFallback; an error without a fallback throws.
7
+ export function decodeLoaderResults(
8
+ resolvedData: any[],
9
+ loaderIds: string[],
10
+ ): { loaderData: Record<string, any>; errorFallback: ReactNode } {
11
+ const loaderData: Record<string, any> = {};
12
+ let errorFallback: ReactNode = null;
13
+
14
+ for (let i = 0; i < loaderIds.length; i++) {
15
+ const id = loaderIds[i];
16
+ const result = resolvedData[i];
17
+
18
+ if (!isLoaderDataResult(result)) {
19
+ loaderData[id] = result;
20
+ continue;
21
+ }
22
+
23
+ if (result.ok) {
24
+ loaderData[id] = result.data;
25
+ continue;
26
+ }
27
+
28
+ if (result.fallback) {
29
+ errorFallback = result.fallback;
30
+ } else {
31
+ throw new Error(result.error.message);
32
+ }
33
+ }
34
+
35
+ return { loaderData, errorFallback };
36
+ }
package/src/errors.ts CHANGED
@@ -27,6 +27,17 @@ export class RouteNotFoundError extends Error {
27
27
  }
28
28
  }
29
29
 
30
+ // name fallback covers cross-realm errors (Vite dev dupes, RSC serialization)
31
+ // where instanceof fails.
32
+ export function isRouteNotFoundError(
33
+ error: unknown,
34
+ ): error is RouteNotFoundError {
35
+ return (
36
+ error instanceof RouteNotFoundError ||
37
+ (error instanceof Error && error.name === "RouteNotFoundError")
38
+ );
39
+ }
40
+
30
41
  /**
31
42
  * Thrown when data is not found (e.g., product with ID doesn't exist)
32
43
  * Use this in handlers/loaders to trigger the nearest notFoundBoundary
@@ -109,6 +120,24 @@ export class BuildError extends Error {
109
120
  }
110
121
  }
111
122
 
123
+ /**
124
+ * Thrown when a route-definition DSL helper (route/layout/loader/cache/…) is
125
+ * called outside an active urls()/map() builder, so there is no
126
+ * AsyncLocalStorage build context to attach to. The message names the specific
127
+ * helper and how to fix it; the `cause` records the mechanical reason so the
128
+ * failure mode is identifiable (not conflated with an unrelated throw).
129
+ */
130
+ export class DslContextError extends Error {
131
+ name = "DslContextError" as const;
132
+ cause?: unknown;
133
+
134
+ constructor(message: string, options?: ErrorOptions) {
135
+ super(message);
136
+ Object.setPrototypeOf(this, DslContextError.prototype);
137
+ this.cause = options?.cause;
138
+ }
139
+ }
140
+
112
141
  /**
113
142
  * Thrown when a network request fails (server unreachable, no internet, etc.)
114
143
  * This error triggers the root error boundary with retry capability.
package/src/index.rsc.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  MiddlewareError,
19
19
  HandlerError,
20
20
  BuildError,
21
+ DslContextError,
21
22
  InvalidHandlerError,
22
23
  RouterError,
23
24
  Skip,
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  MiddlewareError,
19
19
  HandlerError,
20
20
  BuildError,
21
+ DslContextError,
21
22
  InvalidHandlerError,
22
23
  RouterError,
23
24
  Skip,
@@ -26,3 +26,12 @@ export function isWebSocketUpgradeResponse(response: Response): boolean {
26
26
  (response as unknown as { webSocket?: unknown }).webSocket != null
27
27
  );
28
28
  }
29
+
30
+ // Location truthiness (not presence) so an empty `Location: ""` is not a redirect.
31
+ export function isRedirectResponse(response: Response): boolean {
32
+ return (
33
+ response.status >= 300 &&
34
+ response.status < 400 &&
35
+ Boolean(response.headers.get("Location"))
36
+ );
37
+ }
@@ -4,7 +4,7 @@ import { Suspense, use, useId } from "react";
4
4
  import { invariant } from "./errors";
5
5
  import { OutletProvider } from "./outlet-provider.js";
6
6
  import type { ResolvedSegment } from "./types.js";
7
- import { isLoaderDataResult } from "./types.js";
7
+ import { decodeLoaderResults } from "./decode-loader-results.js";
8
8
 
9
9
  /**
10
10
  * Stable async wrapper component for route content
@@ -26,10 +26,6 @@ export function RouteContentWrapper({
26
26
  fallback?: ReactNode;
27
27
  segmentId?: string;
28
28
  }): ReactNode {
29
- if (!content) {
30
- // Already resolved
31
- return content as ReactNode;
32
- }
33
29
  return (
34
30
  <Suspense
35
31
  fallback={fallback ?? null}
@@ -159,28 +155,10 @@ function LoaderResolver({
159
155
  ? use(loaderDataPromise)
160
156
  : loaderDataPromise;
161
157
 
162
- // Build loaderData record from resolved values
163
- const loaderData: Record<string, any> = {};
164
- let loaderErrorFallback: ReactNode = null;
165
-
166
- loaderIds.forEach((id, i) => {
167
- const result = resolvedData[i];
168
-
169
- if (isLoaderDataResult(result)) {
170
- if (result.ok) {
171
- loaderData[id] = result.data;
172
- } else {
173
- if (result.fallback) {
174
- loaderErrorFallback = result.fallback;
175
- } else {
176
- throw new Error(result.error.message);
177
- }
178
- }
179
- } else {
180
- // Legacy format - direct data
181
- loaderData[id] = result;
182
- }
183
- });
158
+ const { loaderData, errorFallback } = decodeLoaderResults(
159
+ resolvedData,
160
+ loaderIds,
161
+ );
184
162
 
185
163
  return (
186
164
  <OutletProvider
@@ -190,7 +168,7 @@ function LoaderResolver({
190
168
  parallel={parallel}
191
169
  loaderData={Object.keys(loaderData).length > 0 ? loaderData : undefined}
192
170
  >
193
- {loaderErrorFallback ?? children}
171
+ {errorFallback ?? children}
194
172
  </OutletProvider>
195
173
  );
196
174
  }