@rangojs/router 0.0.0-experimental.139 → 0.0.0-experimental.140

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 (45) hide show
  1. package/dist/bin/rango.js +27 -2
  2. package/dist/vite/index.js +147 -30
  3. package/package.json +1 -1
  4. package/skills/breadcrumbs/SKILL.md +1 -1
  5. package/skills/cache-guide/SKILL.md +1 -0
  6. package/skills/caching/SKILL.md +1 -1
  7. package/skills/migrate-nextjs/SKILL.md +15 -0
  8. package/skills/migrate-react-router/SKILL.md +15 -2
  9. package/skills/ppr/SKILL.md +426 -0
  10. package/skills/rango/SKILL.md +28 -25
  11. package/skills/route/SKILL.md +43 -0
  12. package/src/build/route-trie.ts +35 -7
  13. package/src/cache/cf/cf-cache-store.ts +155 -0
  14. package/src/cache/index.ts +6 -0
  15. package/src/cache/memory-segment-store.ts +57 -1
  16. package/src/cache/shell-cache.ts +386 -0
  17. package/src/cache/types.ts +58 -0
  18. package/src/cache/vercel/vercel-cache-store.ts +159 -5
  19. package/src/index.rsc.ts +5 -0
  20. package/src/index.ts +17 -0
  21. package/src/router/middleware.ts +14 -5
  22. package/src/router/parse-pattern.ts +115 -0
  23. package/src/router/pattern-matching.ts +53 -64
  24. package/src/router/segment-resolution/fresh.ts +12 -1
  25. package/src/router/segment-resolution/loader-cache.ts +14 -0
  26. package/src/router/segment-resolution/loader-mask.ts +44 -0
  27. package/src/router/substitute-pattern-params.ts +54 -35
  28. package/src/router/trie-matching.ts +19 -11
  29. package/src/router/url-params.ts +13 -0
  30. package/src/rsc/full-payload.ts +70 -0
  31. package/src/rsc/rsc-rendering.ts +105 -51
  32. package/src/rsc/shell-capture.ts +439 -0
  33. package/src/rsc/types.ts +26 -0
  34. package/src/server/cookie-store.ts +45 -0
  35. package/src/server/live.ts +130 -0
  36. package/src/server/request-context.ts +49 -0
  37. package/src/ssr/index.tsx +377 -180
  38. package/src/ssr/ssr-root.tsx +228 -0
  39. package/src/testing/render-route.tsx +7 -9
  40. package/src/types/route-config.ts +19 -7
  41. package/src/urls/type-extraction.ts +43 -18
  42. package/src/vite/discovery/discovery-errors.ts +61 -0
  43. package/src/vite/plugins/virtual-entries.ts +27 -2
  44. package/src/vite/router-discovery.ts +69 -15
  45. package/src/vite/utils/prerender-utils.ts +17 -4
@@ -0,0 +1,228 @@
1
+ import React from "react";
2
+ import { renderSegments } from "../segment-system.js";
3
+ import {
4
+ filterSegmentOrder,
5
+ filterRouteSegmentIds,
6
+ } from "../browser/react/filter-segment-order.js";
7
+ import { ThemeProvider } from "../theme/ThemeProvider.js";
8
+ import { NonceContext } from "../browser/react/nonce-context.js";
9
+ import { NavigationStoreContext } from "../browser/react/context.js";
10
+ import type { NavigationStoreContextValue } from "../browser/react/context.js";
11
+ import type { HandleData } from "../browser/types.js";
12
+ import type { ResolvedSegment } from "../types.js";
13
+ import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
14
+ import type {
15
+ EventController,
16
+ DerivedNavigationState,
17
+ } from "../browser/event-controller.js";
18
+
19
+ /**
20
+ * createFromReadableStream from @rangojs/router/internal/deps/ssr.
21
+ * Deserializes the Flight branch used to build the SSR VDOM.
22
+ */
23
+ export type CreateFromReadableStream = <T>(
24
+ stream: ReadableStream<Uint8Array>,
25
+ ) => Promise<T>;
26
+
27
+ /**
28
+ * RSC payload type (minimal interface for SSR)
29
+ */
30
+ export interface RscPayload {
31
+ metadata?: {
32
+ segments?: ResolvedSegment[];
33
+ rootLayout?: React.ComponentType<{ children: React.ReactNode }>;
34
+ handles?: AsyncGenerator<HandleData, void, unknown>;
35
+ matched?: string[];
36
+ pathname?: string;
37
+ params?: Record<string, string>;
38
+ basename?: string;
39
+ themeConfig?: ResolvedThemeConfig | null;
40
+ initialTheme?: Theme;
41
+ version?: string;
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Consume an async generator and return a Promise that resolves with the final value.
47
+ * Used for SSR where we need to await all handle data before rendering.
48
+ */
49
+ async function consumeAsyncGenerator(
50
+ generator: AsyncGenerator<HandleData, void, unknown>,
51
+ ): Promise<HandleData> {
52
+ let lastData: HandleData = {};
53
+ for await (const data of generator) {
54
+ lastData = data;
55
+ }
56
+ return lastData;
57
+ }
58
+
59
+ /**
60
+ * Create a minimal event controller for SSR.
61
+ * This provides the correct pathname so useNavigation returns the right value during SSR.
62
+ */
63
+ function createSsrEventController(opts: {
64
+ pathname: string;
65
+ params?: Record<string, string>;
66
+ handleData?: HandleData;
67
+ matched?: string[];
68
+ }): EventController {
69
+ const location = new URL(opts.pathname, "http://localhost");
70
+ let params = opts.params ?? {};
71
+ const rawMatched = opts.matched ?? [];
72
+ const handleState = {
73
+ data: opts.handleData ?? {},
74
+ segmentOrder: filterSegmentOrder(rawMatched),
75
+ routeSegmentIds: filterRouteSegmentIds(rawMatched),
76
+ };
77
+ const state: DerivedNavigationState = {
78
+ state: "idle",
79
+ isStreaming: false,
80
+ isNavigating: false,
81
+ location,
82
+ pendingUrl: null,
83
+ inflightActions: [],
84
+ };
85
+
86
+ return {
87
+ getState: () => state,
88
+ getLocation: () => location,
89
+ subscribe: () => () => {},
90
+ getActionState: () => ({
91
+ state: "idle",
92
+ actionId: null,
93
+ payload: null,
94
+ error: null,
95
+ result: null,
96
+ }),
97
+ subscribeToAction: () => () => {},
98
+ subscribeToHandles: () => () => {},
99
+ setHandleData: () => {},
100
+ getHandleState: () => handleState,
101
+ setRouteSegmentIds: () => {},
102
+ setParams: (nextParams) => {
103
+ params = nextParams;
104
+ },
105
+ getParams: () => params,
106
+ setLocation: () => {},
107
+ startNavigation: () => {
108
+ throw new Error("Navigation not supported during SSR");
109
+ },
110
+ abortNavigation: () => {},
111
+ startAction: () => {
112
+ throw new Error("Actions not supported during SSR");
113
+ },
114
+ abortAllActions: () => {},
115
+ getCurrentNavigation: () => null,
116
+ getInflightActions: () => new Map(),
117
+ hadAnyConcurrentActions: () => false,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Options for {@link createSsrRootComponent}.
123
+ */
124
+ export interface SsrRootOptions {
125
+ /** createFromReadableStream for the SSR branch of the Flight stream. */
126
+ createFromReadableStream: CreateFromReadableStream;
127
+ /** The Flight stream branch to deserialize into the SSR VDOM. */
128
+ rscStream: ReadableStream<Uint8Array>;
129
+ /** Nonce for CSP; propagated to NonceContext. */
130
+ nonce?: string;
131
+ }
132
+
133
+ /**
134
+ * Build the closure component that deserializes the Flight payload, consumes
135
+ * handles to completion, builds the segment tree, and wraps it in the
136
+ * NavigationStore / Nonce / Theme providers.
137
+ *
138
+ * The full-fizz path (renderHTML), the shell prerender pass (capture), and the
139
+ * shell resume pass all render this identical tree. `resume` requires the tree
140
+ * above the postponed holes to match the prerendered tree; rendering the same
141
+ * builder over the same replayed segments is what makes that hold. A fresh
142
+ * component instance per pass is fine — replay matches structure, not function
143
+ * identity.
144
+ *
145
+ * The memo slots (payload/handles/context/root) are closure-scoped to the
146
+ * returned instance, so each render pass memoizes independently. renderSegments
147
+ * is async: React.use() on a fresh promise would suspend and replay SsrRoot,
148
+ * re-running the whole segment-tree build unless the promise is memoized.
149
+ */
150
+ export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
151
+ const { createFromReadableStream, rscStream, nonce } = opts;
152
+
153
+ let payload: Promise<RscPayload> | undefined;
154
+ let handlesPromise: Promise<HandleData> | undefined;
155
+ let ssrContextValue: NavigationStoreContextValue | undefined;
156
+ let rootPromise: Promise<React.ReactNode> | undefined;
157
+
158
+ return function SsrRoot() {
159
+ payload ??= createFromReadableStream<RscPayload>(rscStream);
160
+ const resolved = React.use(payload);
161
+
162
+ const themeConfig = resolved.metadata?.themeConfig ?? null;
163
+ const pathname = resolved.metadata?.pathname ?? "/";
164
+
165
+ // Await handles before creating SSR event controller so hooks can
166
+ // read request-local handle data via NavigationStoreContext.
167
+ // The handles property is an async generator that yields on each push
168
+ // Memoize the promise since async generators can only be iterated once
169
+ let handleData: HandleData = {};
170
+ if (resolved.metadata?.handles) {
171
+ handlesPromise ??= consumeAsyncGenerator(resolved.metadata.handles);
172
+ handleData = React.use(handlesPromise);
173
+ }
174
+
175
+ // Create SSR context with request-local pathname/params/handles.
176
+ ssrContextValue ??= {
177
+ store: null as any,
178
+ eventController: createSsrEventController({
179
+ pathname,
180
+ params: resolved.metadata?.params,
181
+ handleData,
182
+ matched: resolved.metadata?.matched,
183
+ }),
184
+ navigate: async () => {},
185
+ refresh: async () => {},
186
+ version: resolved.metadata?.version,
187
+ basename: resolved.metadata?.basename,
188
+ };
189
+
190
+ // Build content tree from segments.
191
+ // Order must match NavigationProvider: NavigationStoreContext > NonceContext > ThemeProvider > content
192
+ // Memoize like payload/handles above: renderSegments is async, so
193
+ // React.use() on a fresh promise suspends and replays SsrRoot, which
194
+ // would re-run the entire segment-tree build on every initial render.
195
+ rootPromise ??= Promise.resolve(
196
+ renderSegments(resolved.metadata?.segments ?? [], {
197
+ rootLayout: resolved.metadata?.rootLayout,
198
+ }),
199
+ );
200
+ let content: React.ReactNode = React.use(rootPromise);
201
+
202
+ // Wrap content with ThemeProvider if theme is enabled
203
+ if (themeConfig) {
204
+ content = (
205
+ <ThemeProvider
206
+ config={themeConfig}
207
+ initialTheme={resolved.metadata?.initialTheme}
208
+ >
209
+ {content}
210
+ </ThemeProvider>
211
+ );
212
+ }
213
+
214
+ // Wrap with NonceContext so client components (e.g. MetaTags) can
215
+ // apply CSP nonces to inline scripts during SSR. Always present to
216
+ // match the browser-side NavigationProvider tree shape for hydration.
217
+ content = (
218
+ <NonceContext.Provider value={nonce}>{content}</NonceContext.Provider>
219
+ );
220
+
221
+ // Wrap with NavigationStoreContext for useNavigation hook
222
+ return (
223
+ <NavigationStoreContext.Provider value={ssrContextValue!}>
224
+ {content}
225
+ </NavigationStoreContext.Provider>
226
+ );
227
+ };
228
+ }
@@ -51,7 +51,10 @@ import type { NavigationStore, NavigationBridge } from "../browser/types.js";
51
51
  import type { EventController } from "../browser/event-controller.js";
52
52
  import type { ResolvedSegment, RscMetadata } from "../browser/types.js";
53
53
  import { NavigationProvider } from "../browser/react/NavigationProvider.js";
54
- import { compilePattern } from "../router/pattern-matching.js";
54
+ import {
55
+ compilePattern,
56
+ buildParamsFromMatch,
57
+ } from "../router/pattern-matching.js";
55
58
  import { normalizeBasename } from "../router/basename.js";
56
59
  import type { LoaderDefinition } from "../types.js";
57
60
  import type { LocationStateDefinition } from "../browser/react/location-state-shared.js";
@@ -303,14 +306,9 @@ function matchLeaf(
303
306
  const compiled = compilePattern(pattern);
304
307
  const match = compiled.regex.exec(pathname);
305
308
  if (!match) return null;
306
- const params: Record<string, string> = {};
307
- compiled.paramNames.forEach((name, index) => {
308
- const value = match[index + 1];
309
- if (value !== undefined) {
310
- params[name] = decodeURIComponent(value);
311
- }
312
- });
313
- return params;
309
+ // Reuse the production param builder so the harness matches the real matcher
310
+ // exactly (named catch-all "" binding, decoding) instead of forking it.
311
+ return buildParamsFromMatch(match, compiled.paramNames, compiled.catchAll);
314
312
  }
315
313
 
316
314
  function staticPrefix(pattern: string): string {
@@ -10,20 +10,29 @@ export type DocumentProps = {
10
10
  type ParseConstraint<T extends string> =
11
11
  T extends `${infer First}|${infer Rest}` ? First | ParseConstraint<Rest> : T;
12
12
 
13
+ // Named catch-all (`:name*` / `:name+`) is matched BEFORE the `?`/suffix
14
+ // branches. Its modifier is anchored to the END of the token (no trailing
15
+ // `${string}`) so it is a true suffix and never mis-splits a constraint body
16
+ // such as `id(\d+)`. Both are a required `string`: a matched catch-all always
17
+ // binds a value (possibly ""), so the key is always present.
13
18
  type ExtractParamInfo<T extends string> =
14
19
  T extends `${infer Name}(${infer Constraint})?${string}`
15
20
  ? { name: Name; optional: true; type: ParseConstraint<Constraint> }
16
21
  : T extends `${infer Name}(${infer Constraint})${string}`
17
22
  ? { name: Name; optional: false; type: ParseConstraint<Constraint> }
18
- : T extends `${infer Name}?${string}`
19
- ? { name: Name; optional: true; type: string }
20
- : T extends `${infer Name}.${string}`
23
+ : T extends `${infer Name}*`
24
+ ? { name: Name; optional: false; type: string }
25
+ : T extends `${infer Name}+`
21
26
  ? { name: Name; optional: false; type: string }
22
- : T extends `${infer Name}-${string}`
23
- ? { name: Name; optional: false; type: string }
24
- : T extends `${infer Name}~${string}`
27
+ : T extends `${infer Name}?${string}`
28
+ ? { name: Name; optional: true; type: string }
29
+ : T extends `${infer Name}.${string}`
25
30
  ? { name: Name; optional: false; type: string }
26
- : { name: T; optional: false; type: string };
31
+ : T extends `${infer Name}-${string}`
32
+ ? { name: Name; optional: false; type: string }
33
+ : T extends `${infer Name}~${string}`
34
+ ? { name: Name; optional: false; type: string }
35
+ : { name: T; optional: false; type: string };
27
36
 
28
37
  type ParamFromInfo<Info> = Info extends {
29
38
  name: infer N extends string;
@@ -51,12 +60,15 @@ type MergeParams<A, B> = Pick<A, keyof A> & Pick<B, keyof B> extends infer O
51
60
  * - Optional params: /:locale? -> { locale?: string }
52
61
  * - Constrained params: /:locale(en|gb) -> { locale: "en" | "gb" }
53
62
  * - Optional + constrained: /:locale(en|gb)? -> { locale?: "en" | "gb" }
63
+ * - Named catch-all: /:path+ (one-or-more), /:slug* (zero-or-more) -> string
54
64
  *
55
65
  * @example
56
66
  * ExtractParams<"/products/:id"> // { id: string }
57
67
  * ExtractParams<"/:locale?/blog/:slug"> // { locale?: string; slug: string }
58
68
  * ExtractParams<"/:locale(en|gb)/blog"> // { locale: "en" | "gb" }
59
69
  * ExtractParams<"/:locale(en|gb)?/blog/:slug"> // { locale?: "en" | "gb"; slug: string }
70
+ * ExtractParams<"/docs/:slug*"> // { slug: string }
71
+ * ExtractParams<"/shop/:path+"> // { path: string }
60
72
  */
61
73
  export type ExtractParams<
62
74
  T extends string,
@@ -81,16 +81,26 @@ type ExtractRoutesFromItem<T> =
81
81
  // When search schema is non-empty, value becomes { path, search } object
82
82
  T extends TypedRouteItem<infer TName, infer TPattern, any, infer TSearch>
83
83
  ? TName extends string
84
- ? TName extends UnnamedRoute
85
- ? {} // Exclude unnamed routes from type map
86
- : {} extends TSearch
87
- ? { [K in TName]: TPattern }
88
- : {
89
- [K in TName]: {
90
- readonly path: TPattern;
91
- readonly search: TSearch;
92
- };
93
- }
84
+ ? // Widened-name guard (#642): some name-less call forms — notably the
85
+ // 3-arg children-fn overload path(pattern, component, () => [...]) —
86
+ // let TName infer to the bare `string` constraint instead of the
87
+ // UnnamedRoute sentinel, because the children-fn argument structurally
88
+ // satisfies the all-optional PathOptions<TName> union member. Mapping
89
+ // over a bare `string` key would emit `{ [K in string]: TPattern }`, an
90
+ // index signature that poisons the whole sibling map (Rango.Path
91
+ // collapses to never). Treat an unresolved name as unnamed.
92
+ string extends TName
93
+ ? {}
94
+ : TName extends UnnamedRoute
95
+ ? {} // Exclude unnamed routes from type map
96
+ : {} extends TSearch
97
+ ? { [K in TName]: TPattern }
98
+ : {
99
+ [K in TName]: {
100
+ readonly path: TPattern;
101
+ readonly search: TSearch;
102
+ };
103
+ }
94
104
  : {}
95
105
  : // TypedIncludeItem: extract prefixed routes (both name and URL prefix)
96
106
  T extends TypedIncludeItem<
@@ -128,9 +138,15 @@ type ExtractRoutesFromItems<T extends readonly any[]> = T extends readonly any[]
128
138
  ? UnionToIntersection<
129
139
  { [K in keyof T]: ExtractRoutesFromItem<T[K]> }[number]
130
140
  > extends infer R
131
- ? R extends Record<string, any>
132
- ? R
133
- : {}
141
+ ? // Blast-radius guard: never let a single malformed item collapse the
142
+ // whole map. A `never` intersection satisfies `extends Record<string,any>`
143
+ // (never extends everything), so check it explicitly first and fall back
144
+ // to `{}` rather than propagating `never`. See #642.
145
+ [R] extends [never]
146
+ ? {}
147
+ : R extends Record<string, any>
148
+ ? R
149
+ : {}
134
150
  : {}
135
151
  : {};
136
152
 
@@ -169,9 +185,15 @@ type PrefixKeys<
169
185
  type ExtractResponsesFromItem<T> =
170
186
  T extends TypedRouteItem<infer TName, any, infer TData>
171
187
  ? TName extends string
172
- ? TName extends UnnamedRoute
188
+ ? // Widened-name guard (#642), parallels ExtractRoutesFromItem. A name-less
189
+ // children-fn path.json(pattern, handler, () => [...]) infers TName as
190
+ // bare `string`; without this the response map picks up an index
191
+ // signature { [K in string]: TData } that wipes named siblings.
192
+ string extends TName
173
193
  ? {}
174
- : { [K in TName]: TData }
194
+ : TName extends UnnamedRoute
195
+ ? {}
196
+ : { [K in TName]: TData }
175
197
  : {}
176
198
  : T extends TypedIncludeItem<any, infer TNamePrefix, any, infer TResponses>
177
199
  ? TNamePrefix extends LocalOnlyInclude
@@ -206,9 +228,12 @@ type ExtractResponsesFromItems<T extends readonly any[]> =
206
228
  ? UnionToIntersection<
207
229
  { [K in keyof T]: ExtractResponsesFromItem<T[K]> }[number]
208
230
  > extends infer R
209
- ? R extends Record<string, unknown>
210
- ? R
211
- : {}
231
+ ? // Blast-radius guard (parallels ExtractRoutesFromItems). See #642.
232
+ [R] extends [never]
233
+ ? {}
234
+ : R extends Record<string, unknown>
235
+ ? R
236
+ : {}
212
237
  : {}
213
238
  : {};
214
239
 
@@ -192,3 +192,64 @@ export class DiscoveryError extends Error {
192
192
  Object.setPrototypeOf(this, DiscoveryError.prototype);
193
193
  }
194
194
  }
195
+
196
+ /** How the dev caller should surface a discovery failure. */
197
+ export interface DiscoveryFailureReport {
198
+ level: "error" | "warn";
199
+ message: string;
200
+ }
201
+
202
+ /**
203
+ * Decide how to surface a dev-boot discovery failure in the terminal.
204
+ *
205
+ * The bare "no routers found" case (a DiscoveryError with no caught host-handler
206
+ * failures) is ambiguous. It is either:
207
+ * - a genuine misconfiguration — the entry never calls createRouter(), or the
208
+ * configured entry path is wrong; or
209
+ * - a transient artifact of a Vite dependency re-optimization racing with boot
210
+ * discovery (a module read before the entry import resolves to the
211
+ * pre-optimize copy of the runner graph while createRouter() populated the
212
+ * post-optimize copy — see router-discovery.ts).
213
+ *
214
+ * We can only tell them apart when the caller observed the dep optimizer's
215
+ * `browserHash` change across the discovery attempt (`reoptimizeObserved`): a
216
+ * reload-causing re-optimization landed mid-flight, so the empty read was
217
+ * transient and the app self-heals per-request (handler.ts builds the trie from
218
+ * the router's live urlpatterns) with discovery re-running on the next boot.
219
+ *
220
+ * A DiscoveryError that DOES carry caught host-handler failures already embeds
221
+ * the real cause in its message, and any non-DiscoveryError is a hard failure;
222
+ * both stay loud with full detail.
223
+ */
224
+ export function describeDiscoveryFailure(
225
+ err: unknown,
226
+ opts: { reoptimizeObserved?: boolean } = {},
227
+ ): DiscoveryFailureReport {
228
+ if (err instanceof DiscoveryError && err.caught.length === 0) {
229
+ const entry = err.entryPath ?? "the router entry";
230
+ if (opts.reoptimizeObserved) {
231
+ return {
232
+ level: "warn",
233
+ message:
234
+ `[rango] No routers found while Vite was re-optimizing dependencies on ` +
235
+ `dev boot. This is transient: routes are served per-request and ` +
236
+ `discovery re-runs automatically, so it clears on the next boot. If ` +
237
+ `routes still 404, confirm ${entry} calls createRouter().`,
238
+ };
239
+ }
240
+ return {
241
+ level: "error",
242
+ message:
243
+ `${err.message}\n` +
244
+ ` Ensure ${entry} calls createRouter() at module top level and that the ` +
245
+ `configured router entry path is correct.`,
246
+ };
247
+ }
248
+
249
+ const e = err as { stack?: string; message?: string };
250
+ const detail = e?.stack ?? e?.message ?? String(err);
251
+ return {
252
+ level: "error",
253
+ message: `[rango] Router discovery failed: ${detail}`,
254
+ };
255
+ }
@@ -38,9 +38,14 @@ initializeApp().catch(console.error);
38
38
 
39
39
  export const VIRTUAL_ENTRY_SSR: string = `
40
40
  import { createFromReadableStream } from "@rangojs/router/internal/deps/ssr";
41
- import { renderToReadableStream } from "react-dom/server.edge";
41
+ import { renderToReadableStream, resume } from "react-dom/server.edge";
42
+ import { prerender } from "react-dom/static.edge";
42
43
  import { injectRSCPayload } from "@rangojs/router/internal/deps/html-stream-server";
43
- import { createSSRHandler } from "@rangojs/router/ssr";
44
+ import {
45
+ createSSRHandler,
46
+ createShellCaptureHandler,
47
+ createShellResumeHandler,
48
+ } from "@rangojs/router/ssr";
44
49
 
45
50
  export const renderHTML = createSSRHandler({
46
51
  createFromReadableStream,
@@ -49,6 +54,26 @@ export const renderHTML = createSSRHandler({
49
54
  loadBootstrapScriptContent: () =>
50
55
  import.meta.viteRsc.loadBootstrapScriptContent("index"),
51
56
  });
57
+
58
+ export const captureShellHTML = createShellCaptureHandler({
59
+ createFromReadableStream,
60
+ renderToReadableStream,
61
+ injectRSCPayload,
62
+ prerender,
63
+ resume,
64
+ loadBootstrapScriptContent: () =>
65
+ import.meta.viteRsc.loadBootstrapScriptContent("index"),
66
+ });
67
+
68
+ export const resumeShellHTML = createShellResumeHandler({
69
+ createFromReadableStream,
70
+ renderToReadableStream,
71
+ injectRSCPayload,
72
+ prerender,
73
+ resume,
74
+ loadBootstrapScriptContent: () =>
75
+ import.meta.viteRsc.loadBootstrapScriptContent("index"),
76
+ });
52
77
  `.trim();
53
78
 
54
79
  /**
@@ -43,6 +43,7 @@ import {
43
43
  peekSelfGenWrite,
44
44
  } from "./discovery/self-gen-tracking.js";
45
45
  import { discoverRouters } from "./discovery/discover-routers.js";
46
+ import { describeDiscoveryFailure } from "./discovery/discovery-errors.js";
46
47
  import {
47
48
  writeCombinedRouteTypesWithTracking,
48
49
  writeRouteTypesFiles,
@@ -653,6 +654,30 @@ export function createRouterDiscoveryPlugin(
653
654
  return tempRscEnv;
654
655
  }
655
656
 
657
+ // Surface a discovery failure on either dev path (Node RSC runner or the
658
+ // Cloudflare temp Node server). `hashBefore`/`hashAfter` are the discovering
659
+ // environment's dep-optimizer browserHash snapshots: a change across the
660
+ // attempt means a reload-causing re-optimization landed mid-flight, so an
661
+ // empty registry was the transient race (downgraded to a warning) rather
662
+ // than a genuine misconfig (loud, actionable error). Shared so both catch
663
+ // sites frame the same failure identically.
664
+ const emitDiscoveryFailure = (
665
+ err: unknown,
666
+ hashBefore: string | undefined,
667
+ hashAfter: string | undefined,
668
+ ): void => {
669
+ const reoptimizeObserved =
670
+ hashBefore !== undefined &&
671
+ hashAfter !== undefined &&
672
+ hashBefore !== hashAfter;
673
+ const report = describeDiscoveryFailure(err, { reoptimizeObserved });
674
+ if (report.level === "warn") {
675
+ console.warn(report.message);
676
+ } else {
677
+ console.error(report.message);
678
+ }
679
+ };
680
+
656
681
  const discover = async () => {
657
682
  const discoverStart = performance.now();
658
683
  const rscEnv = (server.environments as any)?.rsc;
@@ -668,18 +693,26 @@ export function createRouterDiscoveryPlugin(
668
693
 
669
694
  // Create a temp Node.js server to run runtime discovery and generate
670
695
  // named route types (static parser can't resolve factory calls).
696
+ // The temp server is a separate Vite instance with its own dep
697
+ // optimizer; snapshot ITS browserHash (hoisted so the catch can tell a
698
+ // transient re-optimization apart from a genuine empty registry, the
699
+ // same way the Node path below does).
700
+ let tempRscEnv: any;
701
+ let optimizerHashBefore: string | undefined;
671
702
  try {
672
703
  // Acquire build-time env bindings for dev prerender
673
704
  await timed(debugDiscovery, "acquireBuildEnv", () =>
674
705
  acquireBuildEnv(s, viteCommand, viteMode),
675
706
  );
676
707
 
677
- const tempRscEnv = await timed(
708
+ tempRscEnv = await timed(
678
709
  debugDiscovery,
679
710
  "getOrCreateTempServer",
680
711
  () => getOrCreateTempServer(),
681
712
  );
682
713
  if (tempRscEnv) {
714
+ optimizerHashBefore =
715
+ tempRscEnv.depsOptimizer?.metadata?.browserHash;
683
716
  await timed(debugDiscovery, "discoverRouters (cloudflare)", () =>
684
717
  discoverRouters(s, tempRscEnv),
685
718
  );
@@ -688,8 +721,10 @@ export function createRouterDiscoveryPlugin(
688
721
  );
689
722
  }
690
723
  } catch (err: any) {
691
- console.warn(
692
- `[rango] Cloudflare dev discovery failed: ${err.message}\n${err.stack}`,
724
+ emitDiscoveryFailure(
725
+ err,
726
+ optimizerHashBefore,
727
+ tempRscEnv?.depsOptimizer?.metadata?.browserHash,
693
728
  );
694
729
  }
695
730
 
@@ -701,6 +736,13 @@ export function createRouterDiscoveryPlugin(
701
736
  return;
702
737
  }
703
738
 
739
+ // Snapshot the dep-optimizer hash before discovery so the catch can tell
740
+ // a transient re-optimization race apart from a genuine empty registry.
741
+ // A reload-causing re-optimization regenerates browserHash; if it changed
742
+ // across the attempt, an empty read was almost certainly the race below.
743
+ const optimizerHashBefore: string | undefined =
744
+ rscEnv.depsOptimizer?.metadata?.browserHash;
745
+
704
746
  try {
705
747
  // Acquire build-time env bindings for dev prerender (Node.js path)
706
748
  debugDiscovery?.("dev: node path start");
@@ -708,21 +750,31 @@ export function createRouterDiscoveryPlugin(
708
750
  acquireBuildEnv(s, viteCommand, viteMode),
709
751
  );
710
752
 
711
- // Set the readiness gate BEFORE discovery so early requests
712
- // block until manifest is populated
713
- const serverMod = await timed(
714
- debugDiscovery,
715
- "import @rangojs/router/server",
716
- () => rscEnv.runner.import("@rangojs/router/server"),
753
+ // Discover routers FIRST, then arm the manifest-readiness gate on the
754
+ // server module discovery actually read the registry from.
755
+ //
756
+ // We deliberately do NOT pre-import "@rangojs/router/server" before the
757
+ // entry to arm the gate early. During a Vite dependency re-optimization
758
+ // (dev boot after a lockfile change, or `vite dev --force`), a module
759
+ // imported here before the entry resolves to the pre-optimize copy of
760
+ // the runner's module graph, while discoverRouters' entry import — which
761
+ // awaits the in-flight re-optimization — resolves to the post-optimize
762
+ // copy. createRouter() then populates RouterRegistry on the fresh copy,
763
+ // but a stale pre-imported "@rangojs/router/server" reads the other
764
+ // copy's empty Map and discovery throws a spurious "No routers found"
765
+ // even though the app is configured correctly. discoverRouters imports
766
+ // the entry first and reads the registry off the same instance, keeping
767
+ // read and write on one copy. The virtual manifest module's own gate
768
+ // (s.discoveryDone, armed by beginDiscoveryGate) already blocks early
769
+ // requests during discovery on the Node path, so arming
770
+ // manifestReadyPromise after discovery is sufficient here.
771
+ const serverMod = await timed(debugDiscovery, "discoverRouters", () =>
772
+ discoverRouters(s, rscEnv),
717
773
  );
718
774
  if (serverMod?.setManifestReadyPromise) {
719
775
  serverMod.setManifestReadyPromise(discoveryPromise);
720
776
  }
721
777
 
722
- await timed(debugDiscovery, "discoverRouters", () =>
723
- discoverRouters(s, rscEnv),
724
- );
725
-
726
778
  // Store server origin for dev prerender endpoint (virtual module injection)
727
779
  s.devServerOrigin = getDevServerOrigin();
728
780
 
@@ -740,8 +792,10 @@ export function createRouterDiscoveryPlugin(
740
792
  propagateDiscoveryState(rscEnv),
741
793
  );
742
794
  } catch (err: any) {
743
- console.warn(
744
- `[rango] Router discovery failed: ${err.message}\n${err.stack}`,
795
+ emitDiscoveryFailure(
796
+ err,
797
+ optimizerHashBefore,
798
+ rscEnv.depsOptimizer?.metadata?.browserHash,
745
799
  );
746
800
  } finally {
747
801
  debugDiscovery?.(