@rangojs/router 0.15.0 → 0.15.1

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.
@@ -14,7 +14,10 @@ import type { ReactNode } from "react";
14
14
  * decodeLoaderResults runs during the SERVER tree build on forceAwait/action
15
15
  * lanes, where a throw would collapse the whole payload — it routes signals
16
16
  * through the errorFallback slot instead (same visual: the slot replaces the
17
- * children under OutletProvider).
17
+ * children under OutletProvider). The document lane's `{ ssr: false }`
18
+ * results take the same non-throwing route (segment-system
19
+ * buildLoaderStreams): they are settled before the tree is built, and the
20
+ * read-site throw would land in the Fizz shell with no boundary to absorb it.
18
21
  */
19
22
  export declare const LOADER_NOT_FOUND_FALLBACK: unique symbol;
20
23
  export declare const LOADER_REDIRECT: unique symbol;
@@ -78,7 +78,6 @@ declare const REFUSED_CAPTURE_MAX_MS = 60000;
78
78
  * docs/design/ppr-shell-resume.md ("Refused-capture backoff").
79
79
  */
80
80
  declare const REFUSED_CAPTURE_DEV_MAX_MS = 2000;
81
- /** True iff `key` is still inside its (exponential) backoff window. */
82
81
  declare function isCaptureBackedOff(key: string): boolean;
83
82
  /**
84
83
  * Record a refused/failed capture, escalating the backoff window exponentially up
@@ -3795,7 +3795,7 @@ import { resolve } from "node:path";
3795
3795
  // package.json
3796
3796
  var package_default = {
3797
3797
  name: "@rangojs/router",
3798
- version: "0.15.0",
3798
+ version: "0.15.1",
3799
3799
  description: "Django-inspired RSC router with composable URL patterns",
3800
3800
  keywords: [
3801
3801
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -695,6 +695,15 @@ Semantics by lane:
695
695
  | `notFound()` | Not-found UI resolves server-side (nearest `notFoundBoundary` → router option → default) and rides the envelope; the 404 STATUS is **opportunistic** — real only if the rejection beats Response construction. `ssr: false` (below) makes it deterministic. | 404 UI swaps in, URL preserved, payload stays 200 |
696
696
  | `redirect()` | 200 document, then a client-side replace to the target — **no document-lane 302 from loaders**; pre-stream redirect authority belongs to middleware | Redirect envelope navigates to the target |
697
697
 
698
+ With `loader(Def, { ssr: false })` the result is settled BEFORE the document
699
+ flushes, so both signals are resolved server-side while the tree is built:
700
+ `redirect()` replaces the whole page with the redirect carrier (still a 200
701
+ document, the client replaces to the target on hydration) and `notFound()`
702
+ renders the not-found UI at the owning segment with a real 404. No read site
703
+ runs, so a `useLoader` in a layout above every Suspense boundary is safe.
704
+ (Before this was fixed, the flagged read threw inside the Fizz shell and
705
+ 500ed the document.)
706
+
698
707
  Session/auth gates belong in middleware (they are request-shaped, not
699
708
  data-shaped, and middleware CAN emit a real pre-stream 302). Data-dependent
700
709
  "this slug moved / does not exist" belongs in the loader.
@@ -18,7 +18,10 @@ import { LoaderRedirect } from "./loader-redirect.js";
18
18
  * decodeLoaderResults runs during the SERVER tree build on forceAwait/action
19
19
  * lanes, where a throw would collapse the whole payload — it routes signals
20
20
  * through the errorFallback slot instead (same visual: the slot replaces the
21
- * children under OutletProvider).
21
+ * children under OutletProvider). The document lane's `{ ssr: false }`
22
+ * results take the same non-throwing route (segment-system
23
+ * buildLoaderStreams): they are settled before the tree is built, and the
24
+ * read-site throw would land in the Fizz shell with no boundary to absorb it.
22
25
  */
23
26
  export const LOADER_NOT_FOUND_FALLBACK: unique symbol = Symbol(
24
27
  "rango.loaderNotFound",
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import React from "react";
20
+ import { isLoaderDataResult } from "../types.js";
20
21
  import { bufferToBase64 } from "../cache/cf/cf-base64.js";
21
22
  import { reportCacheError } from "../cache/cache-error.js";
22
23
  import { runBackground } from "../cache/background-task.js";
@@ -311,6 +312,14 @@ function refusedCaptureCeilingMs(): number {
311
312
  const refusedCaptures = new Map<string, { failures: number; until: number }>();
312
313
 
313
314
  /** True iff `key` is still inside its (exponential) backoff window. */
315
+ function isSettledLoaderSignal(value: unknown): boolean {
316
+ return (
317
+ isLoaderDataResult(value) &&
318
+ !value.ok &&
319
+ (value.redirect !== undefined || value.notFound === true)
320
+ );
321
+ }
322
+
314
323
  function isCaptureBackedOff(key: string): boolean {
315
324
  const entry = refusedCaptures.get(key);
316
325
  if (entry === undefined) return false;
@@ -1936,6 +1945,19 @@ async function captureAndStoreShell(
1936
1945
  // The container itself never settled: it is a hole (under an ancestor
1937
1946
  // boundary) or the trivial-prelude gate already fired. Omit — no pin.
1938
1947
  if (isLoaderHoleMarker(elided.value)) continue;
1948
+ // redirect()/notFound() RESOLVE as ok:false envelopes
1949
+ // (wrapLoaderWithErrorHandling), so the rejection check above never
1950
+ // sees them; the tree builder resolves them to LoaderRedirect / the
1951
+ // not-found UI (segment-system buildLoaderStreams), which must not
1952
+ // bake into a shell every visitor shares.
1953
+ if (isSettledLoaderSignal(elided.value)) {
1954
+ warnCaptureRefusedOnce(
1955
+ capture.key,
1956
+ `the loader for segment "${segmentKey}" settled with redirect()/notFound() during capture; a request-specific signal must not bake into the shared shell. ` +
1957
+ "Give its entry a loading() boundary so it stays on the live lane, or move the decision into middleware.",
1958
+ );
1959
+ return "refused";
1960
+ }
1939
1961
  // Past the hole check: this container settled with real material that
1940
1962
  // bakes into the shell prelude (independent of the snapshot pin below).
1941
1963
  bakedLoaderMaterial = true;
@@ -1,10 +1,16 @@
1
1
  import * as React from "react";
2
- import { createElement, type ReactNode, type ComponentType } from "react";
2
+ import {
3
+ createElement,
4
+ type ReactNode,
5
+ type ComponentType,
6
+ isValidElement,
7
+ } from "react";
3
8
  import { OutletProvider } from "./outlet-provider.js";
4
9
  import { withOptimisticCommitNone } from "./browser/optimistic-commit.js";
5
10
  import { MountContextProvider } from "./browser/react/mount-context.js";
6
11
  import type { ResolvedSegment, RootLayoutProps } from "./types.js";
7
12
  import { decodeLoaderResults } from "./decode-loader-results.js";
13
+ import { LoaderRedirect } from "./loader-redirect.js";
8
14
  import { invariant } from "./errors.js";
9
15
  import {
10
16
  RouteContentWrapper,
@@ -203,20 +209,43 @@ function wrapDefaultOutletContent(
203
209
  * SSR-completeness contract must not depend on. Unflagged siblings keep the
204
210
  * promise (deliberate streaming). Flagged ids are collected as input for the
205
211
  * dev SSR-suspension diagnostic (ssr-suspension-warning.ts).
212
+ *
213
+ * The settled results also go through decodeLoaderResults here: a settled
214
+ * redirect()/notFound() (or an error with an errorBoundary() fallback) must
215
+ * be resolved to its node while the tree is built, exactly as the
216
+ * forceAwait/action lanes do, because the read-site throw has no boundary on
217
+ * the document lane — flagged content renders without Suspense and a layout
218
+ * reader sits above every boundary, so the throw is a Fizz shell error (500).
219
+ * `settledFallback` is that node; the caller plants it and, for a redirect,
220
+ * replaces the whole page (an ancestor reading the loader would still throw).
206
221
  */
207
222
  async function buildLoaderStreams(loaders: ResolvedSegment[]): Promise<{
208
223
  streams: Record<string, unknown>;
209
224
  awaitedIds: string[] | undefined;
225
+ settledFallback: ReactNode | undefined;
210
226
  }> {
211
227
  const streams: Record<string, unknown> = {};
212
228
  let awaitedIds: string[] | undefined;
229
+ let awaitedValues: unknown[] | undefined;
213
230
  for (const l of loaders) {
214
- streams[l.loaderId!] = l.awaitBeforeFlush
215
- ? await l.loaderData
216
- : l.loaderData;
217
- if (l.awaitBeforeFlush) (awaitedIds ??= []).push(l.loaderId!);
231
+ if (l.awaitBeforeFlush) {
232
+ const value = await l.loaderData;
233
+ streams[l.loaderId!] = value;
234
+ (awaitedIds ??= []).push(l.loaderId!);
235
+ (awaitedValues ??= []).push(value);
236
+ } else {
237
+ streams[l.loaderId!] = l.loaderData;
238
+ }
218
239
  }
219
- return { streams, awaitedIds };
240
+ const settledFallback = awaitedIds
241
+ ? (decodeLoaderResults(awaitedValues!, awaitedIds).errorFallback ??
242
+ undefined)
243
+ : undefined;
244
+ return { streams, awaitedIds, settledFallback };
245
+ }
246
+
247
+ function isLoaderRedirectNode(node: ReactNode): boolean {
248
+ return isValidElement(node) && node.type === LoaderRedirect;
220
249
  }
221
250
 
222
251
  /**
@@ -539,10 +568,19 @@ export async function renderSegments(
539
568
  });
540
569
  }
541
570
  } else if (loaderEntries.length > 0) {
571
+ let settledFallback: ReactNode | undefined;
542
572
  ({
543
573
  streams: boundaryLoaderStreams,
544
574
  awaitedIds: boundaryAwaitedLoaderIds,
575
+ settledFallback,
545
576
  } = await buildLoaderStreams(loaderEntries));
577
+ if (settledFallback !== undefined) {
578
+ if (isLoaderRedirectNode(settledFallback)) {
579
+ content = settledFallback;
580
+ break;
581
+ }
582
+ nodeContent = settledFallback;
583
+ }
546
584
  if (segDebug) {
547
585
  segDebugLog(
548
586
  `segment ${id}: per-loader streams via LoaderBoundary (read-site suspense)`,
@@ -614,8 +652,19 @@ export async function renderSegments(
614
652
  }
615
653
  }
616
654
  } else if (layoutLoaders.length > 0) {
617
- ({ streams: loaderStreams, awaitedIds: awaitedLoaderIds } =
618
- await buildLoaderStreams(layoutLoaders));
655
+ let settledFallback: ReactNode | undefined;
656
+ ({
657
+ streams: loaderStreams,
658
+ awaitedIds: awaitedLoaderIds,
659
+ settledFallback,
660
+ } = await buildLoaderStreams(layoutLoaders));
661
+ if (settledFallback !== undefined) {
662
+ if (isLoaderRedirectNode(settledFallback)) {
663
+ content = settledFallback;
664
+ break;
665
+ }
666
+ errorFallback = settledFallback;
667
+ }
619
668
  if (segDebug) {
620
669
  segDebugLog(
621
670
  `segment ${id}: layout loaders streaming to read sites (no loading())`,