@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.
- package/dist/types/decode-loader-results.d.ts +4 -1
- package/dist/types/rsc/shell-capture.d.ts +0 -1
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/loader/SKILL.md +9 -0
- package/src/decode-loader-results.ts +4 -1
- package/src/rsc/shell-capture.ts +22 -0
- package/src/segment-system.tsx +57 -8
|
@@ -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
|
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
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
package/skills/loader/SKILL.md
CHANGED
|
@@ -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",
|
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -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;
|
package/src/segment-system.tsx
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import {
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
618
|
-
|
|
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())`,
|