@rangojs/router 0.11.0 → 0.12.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,11 +14,11 @@ Real machinery: Vite transpiles `@rangojs/router`'s shipped TS source and resolv
14
14
 
15
15
  ### Functions
16
16
 
17
- | Function | Returns | Use |
18
- | --------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
19
- | `rangoTestConfig(opts?)` | `{ alias, server: { deps: { inline } } }` | Recommended. Spread into the node/DOM project's `test` block. Bundles the resolve aliases AND `server.deps.inline`. |
20
- | `rangoTestAliases(opts?)` | `TestAlias[]` (`{ find, replacement }[]`) | Lower-level. The bare `@rangojs/router` -> `index.rsc.ts` alias plus the `:version` / `@vitejs/plugin-rsc/rsc` stubs (and CF stubs under `preset:"cloudflare"`). Used in the rsc project's `resolve.alias`. |
21
- | `rangoUseClientTransform()` | a Vite plugin (`{ name, transform }`) | Add to the rsc project `plugins`. Applies the `"use client"` transform so `renderServerTree` auto-discovers client islands from the server tree's imports. |
17
+ | Function | Returns | Use |
18
+ | --------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
19
+ | `rangoTestConfig(opts?)` | `{ alias, server: { deps: { inline } } }` | Recommended. Spread into the node/DOM project's `test` block. Bundles the resolve aliases AND `server.deps.inline`. |
20
+ | `rangoTestAliases(opts?)` | `TestAlias[]` (`{ find, replacement }[]`) | Lower-level. The bare `@rangojs/router` -> `index.rsc.ts` alias plus the `:version` / `@vitejs/plugin-rsc/rsc` (`/rsc/server`, `/rsc/client`) stubs (and CF stubs under `preset:"cloudflare"`). Used in the rsc project's `resolve.alias`. |
21
+ | `rangoUseClientTransform()` | a Vite plugin (`{ name, transform }`) | Add to the rsc project `plugins`. Applies the `"use client"` transform so `renderServerTree` auto-discovers client islands from the server tree's imports. |
22
22
 
23
23
  ### Returns — `RangoTestConfig` (from `rangoTestConfig`)
24
24
 
@@ -112,9 +112,9 @@ Scripts:
112
112
  - The rsc project needs BOTH `resolve.conditions: ["react-server"]` AND the bare `@rangojs/router` -> `index.rsc.ts` alias from `rangoTestAliases({ preset })`. `resolve.conditions` alone is not reliably applied to bare-package export resolution; without the alias a handler/component reading `getRequestContext()` / `cookies()` resolves the throwing out-of-react-server stub (symptom: `renderHandler` returns `tree: undefined`). `renderToFlightString` / `renderServerTree` now self-diagnose this exact misconfiguration — they reject with an actionable message naming `rangoTestAliases`, rather than surfacing the opaque stub error.
113
113
  - `NODE_ENV` must be `"production"` in the rsc project. Dev `NODE_ENV` crashes the bare worker (jsxDEV owner-stack machinery uninitialized) and emits volatile debug rows that defeat stable Flight snapshots.
114
114
  - The forked rsc worker (`pool: "forks"`) must force the condition via `execArgv: ["--conditions=react-server"]`, or React throws "the react-server condition must be enabled".
115
- - The `@rangojs/router:version` and `@vitejs/plugin-rsc/rsc` virtuals must be stubbed; the preset does it. A bare router import without stubbing throws.
115
+ - The `@rangojs/router:version` and `@vitejs/plugin-rsc/rsc` (`/rsc/server`, `/rsc/client`) virtuals must be stubbed; the preset does it. A bare router import without stubbing throws.
116
116
  - The rango fragment goes under `test` (`test.alias` + `test.server.deps.inline`, both returned by `rangoTestConfig`), NOT under top-level `resolve`.
117
- - Wire `rangoUseClientTransform()` into the rsc project `plugins` so islands auto-discover from the server tree imports (see `./server-tree.md`); without it, register islands explicitly with `clientComponents`.
117
+ - Wire `rangoUseClientTransform()` into the rsc project `plugins` so islands auto-discover from the server tree imports (see `./server-tree.md`); without it, register islands explicitly with `clientComponents`. The transform's `registerClientReference` import is resolved from `@rangojs/router`'s `@vitejs/plugin-rsc` — the consumer does not need a direct plugin-rsc dependency.
118
118
 
119
119
  ## See also
120
120
 
@@ -19,7 +19,7 @@
19
19
  import {
20
20
  encodeReply,
21
21
  createClientTemporaryReferenceSet,
22
- } from "@vitejs/plugin-rsc/rsc";
22
+ } from "../deps/rsc-client.js";
23
23
  import {
24
24
  getRequestContext,
25
25
  runWithRequestContext,
@@ -15,8 +15,8 @@ import { segmentFragment } from "../segment-fragments.js";
15
15
  import {
16
16
  renderToReadableStream,
17
17
  createTemporaryReferenceSet,
18
- } from "@vitejs/plugin-rsc/rsc";
19
- import { createFromReadableStream } from "@vitejs/plugin-rsc/rsc";
18
+ } from "../deps/rsc.js";
19
+ import { createFromReadableStream } from "../deps/rsc-client.js";
20
20
 
21
21
  // Preserve embedded server references on a cache/prerender HIT so they
22
22
  // re-serialize to the client instead of resolving to a raw function React
@@ -0,0 +1,8 @@
1
+ /// <reference types="@vitejs/plugin-rsc/types" />
2
+ // RSC-environment *client* protocol (deserialize / encodeReply). Kept as its
3
+ // own module so a server-only importer of `./rsc.ts` does not pull this side.
4
+ export {
5
+ createFromReadableStream,
6
+ encodeReply,
7
+ createClientTemporaryReferenceSet,
8
+ } from "@vitejs/plugin-rsc/rsc/client";
package/src/deps/rsc.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /// <reference types="@vitejs/plugin-rsc/types" />
2
- // Re-export @vitejs/plugin-rsc/rsc for internal use by virtual entries
2
+ // Re-export the RSC-environment *server* runtime for virtual entries.
3
+ // Prefer `@vitejs/plugin-rsc/rsc/server` over the combined `/rsc` barrel so
4
+ // Vite can skip bundling the unused `react-server-dom` client protocol.
3
5
  export {
4
6
  renderToReadableStream,
5
7
  decodeReply,
@@ -7,4 +9,4 @@ export {
7
9
  loadServerAction,
8
10
  decodeAction,
9
11
  decodeFormState,
10
- } from "@vitejs/plugin-rsc/rsc";
12
+ } from "@vitejs/plugin-rsc/rsc/server";
package/src/deps/ssr.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export {
2
2
  createFromReadableStream,
3
3
  setOnClientReference,
4
+ getClientEntryUrl,
4
5
  } from "@vitejs/plugin-rsc/ssr";
@@ -16,7 +16,7 @@ import {
16
16
  _getRequestContext,
17
17
  createRequestContext,
18
18
  } from "../server/request-context.js";
19
- import * as rscDeps from "@vitejs/plugin-rsc/rsc";
19
+ import * as rscDeps from "@vitejs/plugin-rsc/rsc/server";
20
20
  import type {
21
21
  RscPayload,
22
22
  CreateRSCHandlerOptions,
@@ -137,7 +137,7 @@ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
137
137
  * @example With custom deps (advanced)
138
138
  * ```tsx
139
139
  * import { createRSCHandler } from "@rangojs/router/rsc";
140
- * import * as rsc from "@vitejs/plugin-rsc/rsc";
140
+ * import * as rsc from "@vitejs/plugin-rsc/rsc/server";
141
141
  * import { router } from "./router.js";
142
142
  *
143
143
  * export default createRSCHandler({
@@ -176,7 +176,7 @@ export function createRSCHandler<
176
176
  // stores not covered by the app-level ctx._cacheStore.
177
177
  const explicitTaggedStores = new Set<SegmentCacheStore>();
178
178
 
179
- // Use provided deps or default to @vitejs/plugin-rsc/rsc exports
179
+ // Use provided deps or default to @vitejs/plugin-rsc/rsc/server exports
180
180
  const deps = options.deps ?? rscDeps;
181
181
  const {
182
182
  renderToReadableStream,
package/src/rsc/types.ts CHANGED
@@ -103,11 +103,11 @@ export interface RscPayload {
103
103
  export type ReactFormState = unknown;
104
104
 
105
105
  /**
106
- * RSC dependencies from @vitejs/plugin-rsc/rsc
106
+ * RSC dependencies from @vitejs/plugin-rsc/rsc/server
107
107
  */
108
108
  export interface RSCDependencies {
109
109
  /**
110
- * renderToReadableStream from @vitejs/plugin-rsc/rsc
110
+ * renderToReadableStream from @vitejs/plugin-rsc/rsc/server
111
111
  */
112
112
  renderToReadableStream: <T>(
113
113
  payload: T,
@@ -118,7 +118,7 @@ export interface RSCDependencies {
118
118
  ) => ReadableStream<Uint8Array>;
119
119
 
120
120
  /**
121
- * decodeReply from @vitejs/plugin-rsc/rsc
121
+ * decodeReply from @vitejs/plugin-rsc/rsc/server
122
122
  */
123
123
  decodeReply: (
124
124
  body: FormData | string,
@@ -126,23 +126,23 @@ export interface RSCDependencies {
126
126
  ) => Promise<unknown[]>;
127
127
 
128
128
  /**
129
- * createTemporaryReferenceSet from @vitejs/plugin-rsc/rsc
129
+ * createTemporaryReferenceSet from @vitejs/plugin-rsc/rsc/server
130
130
  */
131
131
  createTemporaryReferenceSet: () => unknown;
132
132
 
133
133
  /**
134
- * loadServerAction from @vitejs/plugin-rsc/rsc
134
+ * loadServerAction from @vitejs/plugin-rsc/rsc/server
135
135
  */
136
136
  loadServerAction: (actionId: string) => Promise<Function>;
137
137
 
138
138
  /**
139
- * decodeAction from @vitejs/plugin-rsc/rsc
139
+ * decodeAction from @vitejs/plugin-rsc/rsc/server
140
140
  * Decodes a FormData into a bound action function (for useActionState forms)
141
141
  */
142
142
  decodeAction: (body: FormData) => Promise<() => Promise<unknown>>;
143
143
 
144
144
  /**
145
- * decodeFormState from @vitejs/plugin-rsc/rsc
145
+ * decodeFormState from @vitejs/plugin-rsc/rsc/server
146
146
  * Decodes the action result into a ReactFormState for useActionState progressive enhancement
147
147
  */
148
148
  decodeFormState: (
@@ -300,8 +300,8 @@ export interface CreateRSCHandlerOptions<
300
300
  router: RangoInternal<TEnv, TRoutes>;
301
301
 
302
302
  /**
303
- * RSC dependencies from @vitejs/plugin-rsc/rsc.
304
- * Defaults to the exports from @vitejs/plugin-rsc/rsc.
303
+ * RSC dependencies from @vitejs/plugin-rsc/rsc/server.
304
+ * Defaults to the exports from @vitejs/plugin-rsc/rsc/server.
305
305
  */
306
306
  deps?: RSCDependencies;
307
307
 
package/src/ssr/index.tsx CHANGED
@@ -154,10 +154,20 @@ export interface SSRDependencies<TEnv = unknown> {
154
154
  ) => TransformStream<Uint8Array, Uint8Array>;
155
155
 
156
156
  /**
157
- * Function to load bootstrap script content
158
- * Typically: () => import.meta.viteRsc.loadBootstrapScriptContent("index")
157
+ * Function to load bootstrap script content.
158
+ * Required unless `getClientEntryUrl` is provided with `headScripts: "preinit"`.
159
+ * Custom SSR entries typically: `() => import.meta.viteRsc.loadBootstrapScriptContent("index")`
160
+ * (deprecated in `@vitejs/plugin-rsc` 0.5.33 in favor of `getClientEntryUrl`).
159
161
  */
160
- loadBootstrapScriptContent: () => Promise<string>;
162
+ loadBootstrapScriptContent?: () => Promise<string>;
163
+
164
+ /**
165
+ * Client entry URL from `@vitejs/plugin-rsc/ssr` `getClientEntryUrl()`.
166
+ * Preferred when `headScripts` is `"preinit"`: Fizz receives `bootstrapModules`
167
+ * without the deprecated `loadBootstrapScriptContent` round-trip. Custom SSR
168
+ * entries can omit this and keep the inline bootstrap path.
169
+ */
170
+ getClientEntryUrl?: () => string;
161
171
 
162
172
  /**
163
173
  * Document script strategy; the generated virtual SSR entry threads the
@@ -438,36 +448,105 @@ interface ShellResumeOptions {
438
448
  const BOOTSTRAP_IMPORT_ONLY_RE =
439
449
  /^\s*import\(\s*(["'])([^"'\\]+)\1\s*\)\s*;?\s*$/;
440
450
 
451
+ const MISSING_BOOTSTRAP_MSG =
452
+ "[ssr] Missing bootstrap dependency: provide loadBootstrapScriptContent(), " +
453
+ 'or getClientEntryUrl with headScripts: "preinit".';
454
+
441
455
  /**
442
- * Prefer bootstrapModules over the inline import() bootstrap. When the content
443
- * is exactly `import("<entry-url>")`, hand Fizz the URL instead: React then
444
- * emits a `<link rel="modulepreload" fetchpriority="low">` hint in the head
445
- * plus the executing `<script type="module" src async>` at end of shell the
446
- * entry fetch starts with the first flushed bytes instead of when the parser
447
- * reaches an opaque inline script that only reveals the URL once executed.
448
- * Fizz stamps the request nonce on both tags (the inline form needed that
449
- * too), and under PPR both land in the stored prelude; on resume React has
450
- * already cleared the bootstrap fields from the postponed state, so nothing
451
- * re-emits.
456
+ * Construction-time guard for {@link resolveBootstrap}: a handler whose deps
457
+ * can never produce a bootstrap must fail at startup, not 500 per request.
458
+ * getClientEntryUrl only counts under an explicit `headScripts: "preinit"`
459
+ * any other headScripts keeps the inline path, so its presence alone is a
460
+ * misconfiguration worth flagging rather than silently ignoring.
452
461
  */
453
- function resolveBootstrapOptions(
454
- content: string,
455
- headScripts: SSRDependencies["headScripts"],
456
- ): Pick<
462
+ function assertBootstrapDeps(deps: SSRDependencies): void {
463
+ const preinit = deps.headScripts === "preinit";
464
+ if (deps.getClientEntryUrl && !preinit) {
465
+ console.warn(
466
+ '[ssr] getClientEntryUrl is ignored without headScripts: "preinit"; ' +
467
+ "the inline loadBootstrapScriptContent path is used instead.",
468
+ );
469
+ }
470
+ if (
471
+ !(preinit && deps.getClientEntryUrl) &&
472
+ !deps.loadBootstrapScriptContent
473
+ ) {
474
+ throw new Error(MISSING_BOOTSTRAP_MSG);
475
+ }
476
+ }
477
+
478
+ type BootstrapOptions = Pick<
457
479
  RenderToReadableStreamOptions,
458
480
  "bootstrapScriptContent" | "bootstrapModules"
459
- > {
460
- // Explicit opt-in only: undefined (a custom SSR entry that predates the
461
- // option, which also never installed the preinit hook) keeps the inline
462
- // bootstrap byte-for-byte converting by default would break CSPs that
463
- // allowlist the known inline import() via a script hash.
464
- if (headScripts !== "preinit") {
465
- return { bootstrapScriptContent: content };
481
+ >;
482
+
483
+ /**
484
+ * Resolve Fizz's bootstrap options from the deps.
485
+ *
486
+ * Prefer bootstrapModules over the inline import() bootstrap: with
487
+ * `headScripts: "preinit"`, getClientEntryUrl() (sync — nothing to race)
488
+ * short-circuits to bootstrapModules, and inline content that is exactly
489
+ * `import("<entry-url>")` converts to the URL. React then emits a
490
+ * `<link rel="modulepreload" fetchpriority="low">` hint in the head plus the
491
+ * executing `<script type="module" src async>` at end of shell — the entry
492
+ * fetch starts with the first flushed bytes instead of when the parser reaches
493
+ * an opaque inline script that only reveals the URL once executed. Fizz stamps
494
+ * the request nonce on both tags, and under PPR both land in the stored
495
+ * prelude; on resume React has already cleared the bootstrap fields from the
496
+ * postponed state, so nothing re-emits. The conversion is an explicit opt-in:
497
+ * undefined headScripts (a custom SSR entry that predates the option, which
498
+ * also never installed the preinit hook) keeps the inline bootstrap
499
+ * byte-for-byte — converting by default would break CSPs that allowlist the
500
+ * known inline import() via a script hash.
501
+ *
502
+ * With `deadline` (shell capture), the inline load races it: a load that never
503
+ * resolves within the deadline is the same bounded no-shell degrade as a shell
504
+ * that never goes quiet — resolves `null`, the caller's degrade sentinel
505
+ * (disjoint from the load's string). A load that REJECTS is a genuine error
506
+ * and still propagates. The no-op catch keeps a late rejection off the
507
+ * unhandledRejection path when the deadline already won; a rejection that
508
+ * lands first still propagates out.
509
+ */
510
+ async function resolveBootstrap(
511
+ deps: SSRDependencies,
512
+ ): Promise<BootstrapOptions>;
513
+ async function resolveBootstrap(
514
+ deps: SSRDependencies,
515
+ deadline: Promise<void>,
516
+ ): Promise<BootstrapOptions | null>;
517
+ async function resolveBootstrap(
518
+ deps: SSRDependencies,
519
+ deadline?: Promise<void>,
520
+ ): Promise<BootstrapOptions | null> {
521
+ const preinit = deps.headScripts === "preinit";
522
+ if (preinit) {
523
+ // Truthy on purpose, and the ONLY predicate on the URL: an empty string is
524
+ // an unusable entry URL and falls through to the inline path.
525
+ const url = deps.getClientEntryUrl?.();
526
+ if (url) {
527
+ return { bootstrapModules: [url] };
528
+ }
529
+ }
530
+ if (!deps.loadBootstrapScriptContent) {
531
+ throw new Error(MISSING_BOOTSTRAP_MSG);
532
+ }
533
+ let content: string;
534
+ if (deadline) {
535
+ const load = deps.loadBootstrapScriptContent();
536
+ load.catch(() => {});
537
+ const raced = await Promise.race([load, deadline.then(() => null)]);
538
+ if (raced === null) return null;
539
+ content = raced;
540
+ } else {
541
+ content = await deps.loadBootstrapScriptContent();
542
+ }
543
+ if (preinit) {
544
+ const match = BOOTSTRAP_IMPORT_ONLY_RE.exec(content);
545
+ return match
546
+ ? { bootstrapModules: [match[2]!] }
547
+ : { bootstrapScriptContent: content };
466
548
  }
467
- const match = BOOTSTRAP_IMPORT_ONLY_RE.exec(content);
468
- return match
469
- ? { bootstrapModules: [match[2]!] }
470
- : { bootstrapScriptContent: content };
549
+ return { bootstrapScriptContent: content };
471
550
  }
472
551
 
473
552
  /**
@@ -476,7 +555,10 @@ function resolveBootstrapOptions(
476
555
  * @example
477
556
  * ```tsx
478
557
  * import { createSSRHandler } from "@rangojs/router/ssr";
479
- * import { createFromReadableStream } from "@rangojs/router/internal/deps/ssr";
558
+ * import {
559
+ * createFromReadableStream,
560
+ * getClientEntryUrl,
561
+ * } from "@rangojs/router/internal/deps/ssr";
480
562
  * import { renderToReadableStream } from "react-dom/server.edge";
481
563
  * import { injectRSCPayload } from "@rangojs/router/internal/deps/html-stream-server";
482
564
  *
@@ -484,6 +566,17 @@ function resolveBootstrapOptions(
484
566
  * createFromReadableStream,
485
567
  * renderToReadableStream,
486
568
  * injectRSCPayload,
569
+ * getClientEntryUrl,
570
+ * headScripts: "preinit", // getClientEntryUrl is only used under "preinit"
571
+ * });
572
+ * ```
573
+ *
574
+ * Custom SSR entries that still use the deprecated bootstrap helper:
575
+ * ```tsx
576
+ * export const renderHTML = createSSRHandler({
577
+ * createFromReadableStream,
578
+ * renderToReadableStream,
579
+ * injectRSCPayload,
487
580
  * loadBootstrapScriptContent: () =>
488
581
  * import.meta.viteRsc.loadBootstrapScriptContent("index"),
489
582
  * });
@@ -494,9 +587,9 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
494
587
  createFromReadableStream,
495
588
  renderToReadableStream,
496
589
  injectRSCPayload,
497
- loadBootstrapScriptContent,
498
590
  onError,
499
591
  } = deps;
592
+ assertBootstrapDeps(deps);
500
593
 
501
594
  /**
502
595
  * Render RSC stream to HTML stream
@@ -541,8 +634,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
541
634
  origin,
542
635
  });
543
636
 
544
- // Get bootstrap script content
545
- const bootstrapScriptContent = await loadBootstrapScriptContent();
637
+ const bootstrap = await resolveBootstrap(deps);
546
638
 
547
639
  // ssr:false auto-raise (see SSRDependencies.progressiveChunkSize).
548
640
  // Awaiting the payload here is latency-neutral: fizz cannot emit even
@@ -563,7 +655,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
563
655
  // isolate-global, the nonce per request).
564
656
  const htmlStream = await runWithPreinitNonce(nonce, () =>
565
657
  renderToReadableStream(<SsrRoot />, {
566
- ...resolveBootstrapOptions(bootstrapScriptContent, deps.headScripts),
658
+ ...bootstrap,
567
659
  formState,
568
660
  nonce,
569
661
  ...(progressiveChunkSize !== undefined && { progressiveChunkSize }),
@@ -600,8 +692,7 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
600
692
  export function createShellCaptureHandler<TEnv = unknown>(
601
693
  deps: SSRDependencies<TEnv>,
602
694
  ) {
603
- const { createFromReadableStream, loadBootstrapScriptContent, prerender } =
604
- deps;
695
+ const { createFromReadableStream, prerender } = deps;
605
696
  const onError = deps.onError;
606
697
 
607
698
  if (!prerender) {
@@ -610,6 +701,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
610
701
  "PPR shell capture requires the prerender export; wire it in the SSR virtual entry.",
611
702
  );
612
703
  }
704
+ assertBootstrapDeps(deps);
613
705
 
614
706
  /**
615
707
  * Prerender the shell and return the stored artifacts, or null when the
@@ -659,21 +751,10 @@ export function createShellCaptureHandler<TEnv = unknown>(
659
751
  origin: opts.origin,
660
752
  });
661
753
 
662
- // Bootstrap load raced against the deadline. A load that never resolves
663
- // within maxWaitMs is the same bounded no-shell degrade as a shell that
664
- // never goes quiet: return null, do not hang. A load that REJECTS is a
665
- // genuine error and still propagates (it is not the deadline). `null` is
666
- // the deadline sentinel — disjoint from the load's `Promise<string>`, so
667
- // the race narrows to `string | null` with no wrapper. The no-op catch
668
- // keeps a late rejection off the unhandledRejection path when the deadline
669
- // already won; a rejection that lands first still propagates out.
670
- const load = loadBootstrapScriptContent();
671
- load.catch(() => {});
672
- const bootstrapScriptContent = await Promise.race([
673
- load,
674
- deadline.promise.then(() => null),
675
- ]);
676
- if (bootstrapScriptContent === null) {
754
+ // Bootstrap resolution raced against the deadline (see resolveBootstrap):
755
+ // null means the deadline won — the bounded no-shell degrade.
756
+ const bootstrap = await resolveBootstrap(deps, deadline.promise);
757
+ if (bootstrap === null) {
677
758
  return null;
678
759
  }
679
760
 
@@ -689,7 +770,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
689
770
  const abortReason = { rangoShellCaptureAbort: true };
690
771
  const prerenderPromise = prerender(<SsrRoot />, {
691
772
  signal: controller.signal,
692
- ...resolveBootstrapOptions(bootstrapScriptContent, deps.headScripts),
773
+ ...bootstrap,
693
774
  // Explicit option only — the ssr:false auto-raise is live-SSR scoped
694
775
  // (RangoBaseOptions.progressiveChunkSize documents the contract); the
695
776
  // capture handler starts prerender without deserializing the payload,
@@ -7,14 +7,15 @@
7
7
  * the same react-server-dom serializer the router uses at runtime. It runs in
8
8
  * plain node (no Vite, no browser), but ONLY under the `react-server` export
9
9
  * condition. The serializer is the VENDORED build shipped with
10
- * @vitejs/plugin-rsc — the public `@vitejs/plugin-rsc/rsc` entry top-level
11
- * imports Vite virtual modules and is not usable outside a Vite build.
10
+ * @vitejs/plugin-rsc — the public `@vitejs/plugin-rsc/rsc/server` entry
11
+ * top-level imports Vite virtual modules and is not usable outside a Vite
12
+ * build.
12
13
  *
13
14
  * Run the example/tests for this module via the dedicated rsc vitest project
14
15
  * (vitest.rsc.config.ts), which forces `--conditions=react-server` on the
15
16
  * worker. The main vitest project must NOT use that condition (it would flip
16
17
  * React to the no-hooks server build and break the ~50 tests that mock
17
- * @vitejs/plugin-rsc/rsc).
18
+ * @vitejs/plugin-rsc/rsc/server).
18
19
  *
19
20
  * Scope / limitations (v1):
20
21
  * - Server-only / leaf trees. A tree containing a CLIENT component emits an
@@ -1,8 +1,9 @@
1
- // Stub for `@vitejs/plugin-rsc/rsc`, shipped so consumers do not have to write a
2
- // per-file `vi.mock(...)`. Importing a router internal transitively pulls this
3
- // module, whose real top-level body imports Vite virtuals that do not resolve in
4
- // plain node. The unit/integration primitives (dispatch/runLoader/runMiddleware)
5
- // never render RSC, so empty fns suffice.
1
+ // Stub for `@vitejs/plugin-rsc/rsc` and the split `/rsc/server`, `/rsc/client`
2
+ // entries, shipped so consumers do not have to write a per-file `vi.mock(...)`.
3
+ // Importing a router internal transitively pulls this module, whose real
4
+ // top-level body imports Vite virtuals that do not resolve in plain node. The
5
+ // unit/integration primitives (dispatch/runLoader/runMiddleware) never render
6
+ // RSC, so empty fns suffice.
6
7
  export const createFromReadableStream = (): never => {
7
8
  throw new Error("plugin-rsc stub: createFromReadableStream not available");
8
9
  };
@@ -14,3 +15,10 @@ export const decodeReply = (): undefined => undefined;
14
15
  export const decodeAction = (): undefined => undefined;
15
16
  export const decodeFormState = (): undefined => undefined;
16
17
  export const createTemporaryReferenceSet = (): Record<string, never> => ({});
18
+ export const encodeReply = (): never => {
19
+ throw new Error("plugin-rsc stub: encodeReply not available");
20
+ };
21
+ export const createClientTemporaryReferenceSet = (): Record<
22
+ string,
23
+ never
24
+ > => ({});
@@ -18,7 +18,8 @@
18
18
  * `@rangojs/router` specifier to its react-server entry (real impls) while
19
19
  * leaving React as the client build — which is exactly what this helper does.
20
20
  * - The build-only `@rangojs/router:version` virtual and `@vitejs/plugin-rsc/rsc`
21
- * (whose real body imports unresolvable Vite virtuals) are stubbed.
21
+ * plus `/rsc/server`, `/rsc/client` (whose real body imports unresolvable
22
+ * Vite virtuals) are stubbed.
22
23
  * - Cloudflare apps additionally import the `cloudflare:workers` /
23
24
  * `cloudflare:email` runtime virtuals; pass `{ preset: "cloudflare" }` to stub them.
24
25
  *
@@ -75,7 +76,8 @@
75
76
  * a focused include, or use e2e.
76
77
  */
77
78
 
78
- import { fileURLToPath } from "node:url";
79
+ import { createRequire } from "node:module";
80
+ import { fileURLToPath, pathToFileURL } from "node:url";
79
81
 
80
82
  /** A single Vite/Vitest resolve alias entry. Structurally a Vite `Alias`. */
81
83
  export interface TestAlias {
@@ -107,6 +109,32 @@ function here(relativeFromRoot: string): string {
107
109
  return fileURLToPath(new URL(`../../${relativeFromRoot}`, import.meta.url));
108
110
  }
109
111
 
112
+ /**
113
+ * Spec `rangoUseClientTransform` injects into `"use client"` modules. A
114
+ * consumer app does not depend on `@vitejs/plugin-rsc`, so the bare specifier
115
+ * is unresolvable from their `"use client"` files (cloudflare-basic
116
+ * `server-tree.rsc-test.tsx` on plugin-rsc 0.5.34). Resolve it from THIS
117
+ * module — `@rangojs/router` does depend on plugin-rsc — and alias / inject
118
+ * the absolute path.
119
+ */
120
+ const RSD_SERVER_EDGE_SPEC: string =
121
+ "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge";
122
+
123
+ const requireFromHere: ReturnType<typeof createRequire> = createRequire(
124
+ import.meta.url,
125
+ );
126
+
127
+ function resolveFromRouter(spec: string): string | undefined {
128
+ try {
129
+ return requireFromHere.resolve(spec);
130
+ } catch {
131
+ return undefined;
132
+ }
133
+ }
134
+
135
+ const rsdServerEdgePath: string | undefined =
136
+ resolveFromRouter(RSD_SERVER_EDGE_SPEC);
137
+
110
138
  /**
111
139
  * Build the `resolve.alias` entries a consumer's node/DOM Vitest project needs to
112
140
  * import a real @rangojs/router app's router/loaders/middleware. Spread into a
@@ -126,10 +154,16 @@ export function rangoTestAliases(
126
154
  replacement: here("src/testing/vitest-stubs/version.ts"),
127
155
  },
128
156
  {
129
- find: /^@vitejs\/plugin-rsc\/rsc$/,
157
+ find: /^@vitejs\/plugin-rsc\/rsc(\/(server|client))?$/,
130
158
  replacement: here("src/testing/vitest-stubs/plugin-rsc.ts"),
131
159
  },
132
160
  ];
161
+ if (rsdServerEdgePath) {
162
+ aliases.push({
163
+ find: RSD_SERVER_EDGE_SPEC,
164
+ replacement: rsdServerEdgePath,
165
+ });
166
+ }
133
167
 
134
168
  if (opts.preset === "cloudflare") {
135
169
  aliases.push(
@@ -291,10 +325,13 @@ export function rangoUseClientTransform(): FlightTransformPlugin {
291
325
  });
292
326
  if (!result) return undefined;
293
327
  const { output } = result;
294
- // The vendored server serializer is the one renderToFlightString uses;
295
- // resolvable here under the react-server condition.
328
+ // Absolute file URL, not the bare specifier: the consumer's "use client"
329
+ // module cannot resolve @vitejs/plugin-rsc (it is a router dependency).
330
+ const rsdHref = rsdServerEdgePath
331
+ ? pathToFileURL(rsdServerEdgePath).href
332
+ : RSD_SERVER_EDGE_SPEC;
296
333
  output.prepend(
297
- `import * as $$RangoRSD from "@vitejs/plugin-rsc/vendor/react-server-dom/server.edge";\n`,
334
+ `import * as $$RangoRSD from ${JSON.stringify(rsdHref)};\n`,
298
335
  );
299
336
  return {
300
337
  code: output.toString(),