diff --git a/packages/rangojs-router/CHANGELOG.md b/packages/rangojs-router/CHANGELOG.md index 2d5d35566..1cc9f9409 100644 --- a/packages/rangojs-router/CHANGELOG.md +++ b/packages/rangojs-router/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## Unreleased + +### Fixed + +- Inline `"use cache"` hoist against `@vitejs/plugin-rsc` 0.5.34: strip + `directive: null` fields Vite/oxc now emit on ordinary + ExpressionStatements before calling `transformHoistInlineDirective`. + 0.5.34's `matchDirective` does `stmt.directive.match(...)` after + `"directive" in node`, so a file that mixes a cached function with a + sibling handler whose first statement is an expression threw and the + wrap was dropped (cache-tag / inline-handler e2e never hit). + +### Dependencies + +- `@vitejs/plugin-rsc` `^0.5.34`. Generated SSR entries use + `getClientEntryUrl()` for `headScripts: "preinit"` instead of the + deprecated `loadBootstrapScriptContent`. RSC runtime imports split onto + `@vitejs/plugin-rsc/rsc/server` and `/rsc/client`. File-level `"use cache"` + leaves mixed `"use server"` exports for plugin-rsc — both the hoisted + `$$hoist_*` helpers and the `registerServerReference` rebinds of the + original export names. +- 0.5.34 is a hard floor: `@vitejs/plugin-rsc` is a singleton peer, and pnpm + resolves an in-range older install (0.5.31-0.5.33) with only a warning — + such an install then fails module linking at boot (`/rsc/server`, + `/rsc/client`, and `ssr`'s `getClientEntryUrl` do not exist there). Upgrade + the peer together with the router. +- `SSRDependencies.loadBootstrapScriptContent` is now optional (a + `headScripts: "preinit"` entry uses `getClientEntryUrl` instead); + `createSSRHandler`/`createShellCaptureHandler` throw at construction when + neither bootstrap dependency is usable, instead of per-request. + ## 0.11.0 (2026-08-18) Client `revalidate()` now receives the same callable `isAction(...refs)` diff --git a/packages/rangojs-router/docs/internal/feature-file-map.md b/packages/rangojs-router/docs/internal/feature-file-map.md index 3fe1ab516..f53bb33ef 100644 --- a/packages/rangojs-router/docs/internal/feature-file-map.md +++ b/packages/rangojs-router/docs/internal/feature-file-map.md @@ -75,7 +75,7 @@ Cross-tab invalidation state adoption and no-rotate receiving clears are owned b | Telemetry and observability | [router/telemetry.ts](../../src/router/telemetry.ts), [router/telemetry-otel.ts](../../src/router/telemetry-otel.ts), [router/instrument.ts](../../src/router/instrument.ts), [router/router-options.ts](../../src/router/router-options.ts) | `TelemetrySink`, event types, `createConsoleSink`, `safeEmit`, `resolveSink`. Discrete facts emit via `observeEvent()` (instrument.ts). `createOTelSink` is the OTel **event** adapter (instant spans for the facts only — NOT phase spans); `createOTelTracing` (`OTelTracingOptions`) is the OTel **phase-span** adapter for the `tracing` slot via `startActiveSpan`. | | Span tracing (phase spans) | [router/tracing.ts](../../src/router/tracing.ts), [router/telemetry-otel.ts](../../src/router/telemetry-otel.ts), [router/loader-resolution.ts](../../src/router/loader-resolution.ts), [rsc/server-action.ts](../../src/rsc/server-action.ts), [cloudflare/tracing.ts](../../src/cloudflare/tracing.ts), [cloudflare/index.ts](../../src/cloudflare/index.ts), [vercel/tracing.ts](../../src/vercel/tracing.ts), [vercel/index.ts](../../src/vercel/index.ts), [router/router-options.ts](../../src/router/router-options.ts), [router/router-interfaces.ts](../../src/router/router-interfaces.ts), [router.ts](../../src/router.ts), [server/request-context.ts](../../src/server/request-context.ts), [rsc/handler.ts](../../src/rsc/handler.ts), [router/middleware.ts](../../src/router/middleware.ts), [router/instrument.ts](../../src/router/instrument.ts), [router/segment-resolution/loader-cache.ts](../../src/router/segment-resolution/loader-cache.ts), [router/segment-resolution/fresh.ts](../../src/router/segment-resolution/fresh.ts), [router/segment-resolution/helpers.ts](../../src/router/segment-resolution/helpers.ts), [router/segment-resolution/revalidation.ts](../../src/router/segment-resolution/revalidation.ts), [rsc/loader-fetch.ts](../../src/rsc/loader-fetch.ts), [rsc/rsc-rendering.ts](../../src/rsc/rsc-rendering.ts) | Single phase-instrumentation API `observePhase()` + `PHASES` registry + `observeEvent()` (instrument.ts). `observePhase` co-emits the span AND (unless `metric:false`) the debugPerformance perf metric from one wrap site, so the two surfaces can't drift. `traceSpan`/`resolveTracing`/`SpanRunner`/`TraceSpan`/`RouterTracingConfig`/`TracePhaseToggles` (tracing.ts) are the platform-agnostic primitive (pass-through with `NOOP_TRACE_SPAN` when off); `traceSpan` is internal — only `observePhase` calls it. `tracing` option resolved in `router.ts` to `RangoInternal.tracing`, threaded onto `RequestContext._tracing`. Coverage: `rango.request` (span-only; handler:total metered directly), `rango.middleware` (span-only incl. intercept middleware; pre/post metered directly), `rango.action` (action:; server-action execution, JS + no-JS/PE, wraps executeServerAction / progressive-enhancement.ts), `rango.loader` (single metering site = `useLoader` in loader-resolution.ts, reached via `ctx.use` from resolveLoaderData; + the fetchable `_rsc_loader` path; cache HIT = no loader phase), `rango.handler` (per-segment route/layout handler EXECUTION via `observeHandler`; span-only — `handler:` owned by `track()`; tagged `rango.handler_id`; no span on a static/prerender hit), `rango.render` (render:total:; normal AND action-revalidation), `rango.ssr` (ssr-render-html), `rango.response` (span-only handoff marker at the tail of the request span in rsc/handler.ts: wraps only finalization — partial-redirect interception, Server-Timing mutation, guardOutgoingRedirect — after downstream execution returned; at most one per request, direct child of `rango.request`, never touches response.body; attributes `http.response.status_code`/`rango.response.mode` (classified mode via `RequestContext._requestMode`, or `middleware-short-circuit`)/`rango.response.body_kind`), `rango.background` (span-only wrapper for detached waitUntil work, `rango.background.kind` = shell-capture (rsc/shell-capture.ts scheduleShellCapture — inner phase spans stay stripped; carries rango.shell_key/outcome/queue_wait_ms) / document-revalidation (cache/document-cache.ts STALE lane) / loader-revalidation (router/segment-resolution/loader-cache.ts wrapBackground through cache/read-through-swr.ts, covering the setItem write) / use-cache-revalidation (cache/cache-runtime.ts, covering the setItem write); revalidation lanes keep inner rango.\* spans nested under it). Runners: `createCloudflareTracing()` → `executionContext.tracing.enterSpan` (import-free); `createOTelTracing()` → `tracer.startActiveSpan` (callback-bound); `createVercelTracing()` (`@rangojs/router/vercel`) → `createOTelTracing` over the global `@vercel/otel` tracer (Node-only). | | Consumer testing primitives | [testing/index.ts](../../src/testing/index.ts), [testing/vitest.ts](../../src/testing/vitest.ts), [testing/vitest-stubs/](../../src/testing/vitest-stubs/), [testing/run-middleware.ts](../../src/testing/run-middleware.ts), [testing/run-loader.ts](../../src/testing/run-loader.ts), [testing/run-transition-when.ts](../../src/testing/run-transition-when.ts), [testing/dispatch.ts](../../src/testing/dispatch.ts), [testing/render-route.tsx](../../src/testing/render-route.tsx), [testing/dom.entry.ts](../../src/testing/dom.entry.ts), [testing/flight.ts](../../src/testing/flight.ts), [testing/flight.entry.ts](../../src/testing/flight.entry.ts), [testing/flight-tree.ts](../../src/testing/flight-tree.ts), [testing/render-handler.ts](../../src/testing/render-handler.ts), [testing/flight-matchers.ts](../../src/testing/flight-matchers.ts), [testing/flight-normalize.ts](../../src/testing/flight-normalize.ts), [testing/cache-status.ts](../../src/testing/cache-status.ts), [testing/shell-status.ts](../../src/testing/shell-status.ts), [testing/generated-routes.ts](../../src/testing/generated-routes.ts), [testing/collect-handle.ts](../../src/testing/collect-handle.ts), [testing/internal/context.ts](../../src/testing/internal/context.ts), [testing/internal/seed-vars.ts](../../src/testing/internal/seed-vars.ts), [testing/internal/flight-client-globals.ts](../../src/testing/internal/flight-client-globals.ts), [testing/e2e/index.ts](../../src/testing/e2e/index.ts), [testing/e2e/fixture.ts](../../src/testing/e2e/fixture.ts), [testing/e2e/parity.ts](../../src/testing/e2e/parity.ts), [testing/e2e/page-helpers.ts](../../src/testing/e2e/page-helpers.ts), [testing/e2e/matchers.ts](../../src/testing/e2e/matchers.ts), [testing/e2e/server.ts](../../src/testing/e2e/server.ts) | Six entries by dependency/runtime: `@rangojs/router/testing` (Vitest unit+integration), `@rangojs/router/testing/vitest` (the `rangoTestConfig` setup preset; `rangoTestAliases` is the lower-level alias-only helper), `@rangojs/router/testing/dom` (`renderRoute`), `@rangojs/router/testing/e2e` (Playwright harness), `@rangojs/router/testing/flight` (real Flight renderer, react-server condition only), `@rangojs/router/testing/flight-matchers` (`flightMatchers`, split out so the renderer entry never top-level-imports `vitest`). Unit (`runMiddleware` over the real request-context + `executeLoaderMiddleware`; `runLoader` takes a raw loader fn OR a registered `createLoader()` handle, recovering its fn from the fetchable registry; `runLoaderResult` is the envelope sibling that also surfaces the loader's effects {data, thrown, response, cookies, headers, locationState, stateCookieName}; `runTransitionWhen` drives the real `applyViewTransitionDefault`+`gateTransitions` over a request context to test a `transition({ when })` predicate, returning `{ kept, dropped, whenContext, ctx }`), integration (`dispatch` request->Response, no Flight, mirrors `handleResponseRoute` error + `Vary: Accept`; `renderRoute` client-tree RTL stub mounting the real `NavigationProvider`, lazy `@testing-library/react`), RSC Flight (`renderToFlightString` + `flightMatchers`; `renderServerTree`/`findClientBoundaries` for a deserialized inspectable tree; `renderHandler` runs a real route handler `(ctx) => rsc` with a seeded HandlerContext -> rendered RSC + effects; `rangoUseClientTransform` Vite plugin for the rsc project; `seed-vars.ts`/`flight-client-globals.ts` are react-server-safe internals; `flight-normalize.ts` holds the serializer-free `normalizeFlight` shared by `flight.ts` (re-export) and `flight-matchers.ts`, so the matchers import it without the react-server-only serializer), cache/prerender signals (`assertCacheStatus`/`parseCacheHeader` read `X-Rango-Cache` behind the `debugCacheSignal`/`RANGO_TEST_SIGNALS` gate; `createCacheSink`/`assertCacheDecision`/`filterCacheDecisions` telemetry path), PPR shell signals (`assertShellStatus`/`parseShellStatus`/`shellCacheKey`/`SHELL_STATUS_HEADER` in `shell-status.ts`), generated-route drift (`diffGeneratedRoutes`/`assertGeneratedRoutesMatch`), and the e2e harness (`createRangoE2E` -> `useFixture`/`parityDescribe`/`expectParity`/page helpers/`rangoMatchers`, with the consumer's Playwright `test`/`expect` injected). Tests under [testing/\_\_tests\_\_/\*](../../src/testing/__tests__/) (`*.rsc-test.tsx` runs under `vitest.rsc.config.ts`). | -| Internal-only internal APIs | [\_\_internal.ts](../../src/__internal.ts), [server.ts](../../src/server.ts), [route-map-builder.ts](../../src/route-map-builder.ts), [server/loader-registry.ts](../../src/server/loader-registry.ts), [server/request-context.ts](../../src/server/request-context.ts), [host/router.ts](../../src/host/router.ts), [deps/browser.ts](../../src/deps/browser.ts), [deps/ssr.ts](../../src/deps/ssr.ts) | Runtime plumbing shared by build/runtime/Vite internals, including router discovery registries. | +| Internal-only internal APIs | [\_\_internal.ts](../../src/__internal.ts), [server.ts](../../src/server.ts), [route-map-builder.ts](../../src/route-map-builder.ts), [server/loader-registry.ts](../../src/server/loader-registry.ts), [server/request-context.ts](../../src/server/request-context.ts), [host/router.ts](../../src/host/router.ts), [deps/browser.ts](../../src/deps/browser.ts), [deps/ssr.ts](../../src/deps/ssr.ts), [deps/rsc.ts](../../src/deps/rsc.ts), [deps/rsc-client.ts](../../src/deps/rsc-client.ts) | Runtime plumbing shared by build/runtime/Vite internals, including router discovery registries. | | Dev discovery browser handshake | [dev-discovery-protocol.ts](../../src/dev-discovery-protocol.ts), [browser/dev-discovery.ts](../../src/browser/dev-discovery.ts), [vite/router-discovery.ts](../../src/vite/router-discovery.ts), [vite/discovery/state.ts](../../src/vite/discovery/state.ts), [vite/discovery/virtual-module-codegen.ts](../../src/vite/discovery/virtual-module-codegen.ts), [vite/plugins/version-injector.ts](../../src/vite/plugins/version-injector.ts), [vite/plugins/virtual-entries.ts](../../src/vite/plugins/virtual-entries.ts), [router.ts](../../src/router.ts), [router/router-interfaces.ts](../../src/router/router-interfaces.ts), [rsc/handler.ts](../../src/rsc/handler.ts), [rsc/handler-context.ts](../../src/rsc/handler-context.ts), [rsc/render-pipeline.ts](../../src/rsc/render-pipeline.ts), [rsc/types.ts](../../src/rsc/types.ts), [browser/types.ts](../../src/browser/types.ts) | Cloudflare-dev worker generation protocol. Successful discovery advances an epoch; the virtual manifest installs it before router construction; the evaluated custom worker entry acknowledges readiness to Vite; Vite relays a custom event and serves the latest acknowledged epoch from a no-cache endpoint. Flight metadata carries the router generation that produced the document, and the browser reloads once only when that generation is older. Production and content-only `rsc:update` paths remain inert. | The dev-discovery row's transport description is superseded: readiness is an active request from `vite/router-discovery.ts` to the reloaded router's probe header in `router.ts`, while browser startup and reconnect queries use Vite's client hot channel. Successful discovery advances the epoch only when the path, name, trailing-slash, or search-schema shape changes. A stale router answers probes with its actual epoch without rendering the app; repeat workerd reloads use bounded backoff, and superseded probes stop before publishing, so readiness confirms the new router instance rather than predicting workerd reload completion. `clientUrls()` edits explicitly invalidate router roots only on the Node/local-runner path, sharing Vite's importer traversal set across roots; Cloudflare skips that redundant walk because its temp discovery graph and workerd graph are invalidated wholesale during runtime rediscovery. diff --git a/packages/rangojs-router/docs/internal/feature-map.md b/packages/rangojs-router/docs/internal/feature-map.md index 0fc64c4d4..90f287622 100644 --- a/packages/rangojs-router/docs/internal/feature-map.md +++ b/packages/rangojs-router/docs/internal/feature-map.md @@ -74,19 +74,20 @@ package's Node 24 engine floor. `VercelPresetOptions.runtime` can override it. These subpaths are consumed by the Vite plugin, RSC handler, or build tooling. They are not part of the public API and may change without notice. -| Export | Description | -| ------------------------------------ | ------------------------------------------------------------------------------------------ | -| `./server` | Manifest/build internals: plugin bridge, route-map management, router discovery registries | -| `./__internal` | Internal plumbing shared by build/runtime/Vite | -| `./internal/deps/browser` | Browser runtime dependency bridge | -| `./internal/deps/ssr` | SSR runtime dependency bridge (`createFromReadableStream`, `setOnClientReference`) | -| `./internal/deps/rsc` | RSC runtime dependency bridge | -| `./internal/deps/html-stream-client` | HTML stream client dependency bridge | -| `./internal/deps/html-stream-server` | HTML stream server dependency bridge | -| `./internal/rsc-handler` | RSC handler internals | -| `./internal/browser/dev-discovery` | Browser-only Cloudflare dev stale-document convergence helper | -| `./cache-runtime` | Cache runtime dependencies | -| `./types` | Type declarations for the `@rangojs/router:version` virtual module | +| Export | Description | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------- | +| `./server` | Manifest/build internals: plugin bridge, route-map management, router discovery registries | +| `./__internal` | Internal plumbing shared by build/runtime/Vite | +| `./internal/deps/browser` | Browser runtime dependency bridge | +| `./internal/deps/ssr` | SSR runtime dependency bridge (`createFromReadableStream`, `setOnClientReference`, `getClientEntryUrl`) | +| `./internal/deps/rsc` | RSC server-protocol dependency bridge (`@vitejs/plugin-rsc/rsc/server`) | +| `./internal/deps/rsc-client` | RSC client-protocol dependency bridge (`@vitejs/plugin-rsc/rsc/client`) | +| `./internal/deps/html-stream-client` | HTML stream client dependency bridge | +| `./internal/deps/html-stream-server` | HTML stream server dependency bridge | +| `./internal/rsc-handler` | RSC handler internals | +| `./internal/browser/dev-discovery` | Browser-only Cloudflare dev stale-document convergence helper | +| `./cache-runtime` | Cache runtime dependencies | +| `./types` | Type declarations for the `@rangojs/router:version` virtual module | ### CLI diff --git a/packages/rangojs-router/docs/internal/why-web-streams-everywhere.md b/packages/rangojs-router/docs/internal/why-web-streams-everywhere.md index b1219b75b..a885e1c43 100644 --- a/packages/rangojs-router/docs/internal/why-web-streams-everywhere.md +++ b/packages/rangojs-router/docs/internal/why-web-streams-everywhere.md @@ -9,18 +9,20 @@ to Node streams should read this first. ## The constraint - **SSR / HTML layer** pins `react-dom/server.edge`: - `src/vite/plugins/virtual-entries.ts:41` (the SSR virtual entry). The renderer - is injected as a dep into the generic `createSSRHandler` (`src/ssr/index.tsx`), - which only ever calls `renderToReadableStream`, `.allReady`, and `.pipeThrough` - (`src/ssr/index.tsx:344`, `:354`, `:358`) — all Web Streams APIs. -- **Flight / RSC layer** uses `@vitejs/plugin-rsc/rsc` (re-exported via + `src/vite/plugins/virtual-entries.ts:109` (the generated SSR entry). The + renderer is injected as a dep into the generic `createSSRHandler` + (`src/ssr/index.tsx`), which only ever calls `renderToReadableStream`, + `.allReady`, and `.pipeThrough` (`src/ssr/index.tsx:657`, `:669`, `:673`) — + all Web Streams APIs. +- **Flight / RSC layer** uses `@vitejs/plugin-rsc/rsc/server` (re-exported via `src/deps/rsc.ts`), whose `renderToReadableStream` comes from the vendored - `react-server-dom-webpack/server.edge`. Call site: `src/rsc/rsc-rendering.ts:197`. + `react-server-dom-webpack/server.edge`. Call site: `src/rsc/render-pipeline.ts:158` + (via `ctx.renderToReadableStream`, wired in `src/rsc/handler.ts`). - Both stream bodies are handed to `new Response(...)` as a Web `ReadableStream` - via `createResponseWithMergedHeaders` (`src/rsc/helpers.ts:117`); RSC-only - responses return the raw Web `rscStream` (`src/rsc/rsc-rendering.ts:240`), HTML - responses return `htmlStream` after `pipeThrough(injectRSCPayload(...))` - (`src/ssr/index.tsx:359`). + via `createResponseWithMergedHeaders` (`src/rsc/helpers.ts:131`). RSC-only + responses wrap the Flight stream (`src/rsc/render-pipeline.ts:249`); HTML + responses wrap the stream after `pipeThrough(injectRSCPayload(...))` + (`src/ssr/index.tsx:673`). `react-dom@19.x` `./server.edge` exports **only** `renderToReadableStream`. `./server.node` exports **both** `renderToPipeableStream` _and_ diff --git a/packages/rangojs-router/docs/testing.md b/packages/rangojs-router/docs/testing.md index 27f971714..eed1bce0e 100644 --- a/packages/rangojs-router/docs/testing.md +++ b/packages/rangojs-router/docs/testing.md @@ -207,7 +207,8 @@ export default defineConfig({ `rangoTestConfig()` returns the resolve `alias` entries AND `server.deps.inline: [/@rangojs[/\\]router/]`, spread together into `test`. The aliases point the bare `@rangojs/router` at its real impls and stub the -build-only `@rangojs/router:version` and `@vitejs/plugin-rsc/rsc` virtuals — so +build-only `@rangojs/router:version` and `@vitejs/plugin-rsc/rsc` +(`/rsc/server`, `/rsc/client`) virtuals — so you do **not** need a per-file `vi.mock("@vitejs/plugin-rsc/rsc")`. The `deps.inline` half is mandatory for an installed (node_modules) consumer: diff --git a/packages/rangojs-router/package.json b/packages/rangojs-router/package.json index b1008db86..6a4b9b017 100644 --- a/packages/rangojs-router/package.json +++ b/packages/rangojs-router/package.json @@ -88,6 +88,11 @@ "react-server": "./src/deps/rsc.ts", "default": "./src/deps/rsc.ts" }, + "./internal/deps/rsc-client": { + "types": "./dist/types/deps/rsc-client.d.ts", + "react-server": "./src/deps/rsc-client.ts", + "default": "./src/deps/rsc-client.ts" + }, "./internal/deps/html-stream-client": { "types": "./dist/types/deps/html-stream-client.d.ts", "default": "./src/deps/html-stream-client.ts" @@ -193,7 +198,7 @@ }, "dependencies": { "@types/debug": "^4.1.12", - "@vitejs/plugin-rsc": "^0.5.31", + "@vitejs/plugin-rsc": "^0.5.34", "debug": "^4.4.1", "magic-string": "^0.30.17", "picomatch": "^4.0.4", @@ -226,7 +231,7 @@ "@playwright/test": "^1.49.1", "@testing-library/react": ">=16", "@vercel/functions": "^3.0.0", - "@vitejs/plugin-rsc": "^0.5.31", + "@vitejs/plugin-rsc": "^0.5.34", "react": ">=19.2.8 <20", "react-dom": ">=19.2.8 <20", "vite": "^8.0.16", diff --git a/packages/rangojs-router/skills/testing/setup.md b/packages/rangojs-router/skills/testing/setup.md index 82abdda0a..a8b0fb560 100644 --- a/packages/rangojs-router/skills/testing/setup.md +++ b/packages/rangojs-router/skills/testing/setup.md @@ -14,11 +14,11 @@ Real machinery: Vite transpiles `@rangojs/router`'s shipped TS source and resolv ### Functions -| Function | Returns | Use | -| --------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `rangoTestConfig(opts?)` | `{ alias, server: { deps: { inline } } }` | Recommended. Spread into the node/DOM project's `test` block. Bundles the resolve aliases AND `server.deps.inline`. | -| `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`. | -| `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. | +| Function | Returns | Use | +| --------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `rangoTestConfig(opts?)` | `{ alias, server: { deps: { inline } } }` | Recommended. Spread into the node/DOM project's `test` block. Bundles the resolve aliases AND `server.deps.inline`. | +| `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`. | +| `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. | ### Returns — `RangoTestConfig` (from `rangoTestConfig`) @@ -112,7 +112,7 @@ Scripts: - 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. - `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. - 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". -- The `@rangojs/router:version` and `@vitejs/plugin-rsc/rsc` virtuals must be stubbed; the preset does it. A bare router import without stubbing throws. +- 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. - The rango fragment goes under `test` (`test.alias` + `test.server.deps.inline`, both returned by `rangoTestConfig`), NOT under top-level `resolve`. - 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`. diff --git a/packages/rangojs-router/src/__tests__/segment-codec-result.test.ts b/packages/rangojs-router/src/__tests__/segment-codec-result.test.ts index d7bc30c83..afe5fbd2d 100644 --- a/packages/rangojs-router/src/__tests__/segment-codec-result.test.ts +++ b/packages/rangojs-router/src/__tests__/segment-codec-result.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect, vi } from "vitest"; -vi.mock("@vitejs/plugin-rsc/rsc", () => { +function pluginRscMock() { const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -32,7 +32,9 @@ vi.mock("@vitejs/plugin-rsc/rsc", () => { return new Set(); }, }; -}); +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); // Import AFTER mock registration so the real module picks up the mock const { serializeResult, deserializeResult, rscSerialize, rscDeserialize } = diff --git a/packages/rangojs-router/src/__tests__/static-id-fallback.test.ts b/packages/rangojs-router/src/__tests__/static-id-fallback.test.ts index 78d4cbd13..bc2e9b7f6 100644 --- a/packages/rangojs-router/src/__tests__/static-id-fallback.test.ts +++ b/packages/rangojs-router/src/__tests__/static-id-fallback.test.ts @@ -2,15 +2,19 @@ import { describe, it, expect, vi } from "vitest"; // createRouter's match path transitively imports @vitejs/plugin-rsc/rsc; stub it // (these tests never render RSC). Mirrors src/testing/__tests__/dispatch.test.ts. -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - createFromReadableStream: vi.fn(), - renderToReadableStream: vi.fn(), - loadServerAction: vi.fn(), - decodeReply: vi.fn(), - decodeAction: vi.fn(), - decodeFormState: vi.fn(), - createTemporaryReferenceSet: vi.fn(), -})); +function pluginRscMock() { + return { + createFromReadableStream: vi.fn(), + renderToReadableStream: vi.fn(), + loadServerAction: vi.fn(), + decodeReply: vi.fn(), + decodeAction: vi.fn(), + decodeFormState: vi.fn(), + createTemporaryReferenceSet: vi.fn(), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); import { Static } from "../static-handler.js"; import { Prerender } from "../prerender.js"; diff --git a/packages/rangojs-router/src/cache/__tests__/cache-exec-parallel-guard.test.ts b/packages/rangojs-router/src/cache/__tests__/cache-exec-parallel-guard.test.ts index 287d73a07..0d7ad78dc 100644 --- a/packages/rangojs-router/src/cache/__tests__/cache-exec-parallel-guard.test.ts +++ b/packages/rangojs-router/src/cache/__tests__/cache-exec-parallel-guard.test.ts @@ -18,12 +18,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { cookies, headers } from "../../server/cookie-store.js"; -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - encodeReply: vi.fn((args: unknown[]) => - Promise.resolve(JSON.stringify(args)), - ), - createClientTemporaryReferenceSet: vi.fn().mockReturnValue(new Set()), -})); +// cache-runtime.ts imports both @vitejs/plugin-rsc/rsc/server and /rsc/client. +function pluginRscMock() { + return { + encodeReply: vi.fn((args: unknown[]) => + Promise.resolve(JSON.stringify(args)), + ), + createClientTemporaryReferenceSet: vi.fn().mockReturnValue(new Set()), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); const mockGetRequestContext = vi.fn<() => any>(() => null); vi.mock("../../server/request-context.js", () => ({ diff --git a/packages/rangojs-router/src/cache/__tests__/cache-runtime-binary-hit.test.ts b/packages/rangojs-router/src/cache/__tests__/cache-runtime-binary-hit.test.ts index 3219d68bf..f9ce71f36 100644 --- a/packages/rangojs-router/src/cache/__tests__/cache-runtime-binary-hit.test.ts +++ b/packages/rangojs-router/src/cache/__tests__/cache-runtime-binary-hit.test.ts @@ -20,22 +20,26 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // encodeReply emits FormData for a typed-array/Blob arg (per-call random // boundary). Reproduce that shape so replyToCacheKey's binary branch runs. -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - encodeReply: vi.fn(async (args: unknown[]) => { - const fd = new FormData(); - args.forEach((arg, i) => { - if (arg instanceof Uint8Array) { - fd.append(String(i), new Blob([arg.slice()], { type: "" })); - } else if (arg instanceof Blob) { - fd.append(String(i), arg); - } else { - fd.append(String(i), JSON.stringify(arg)); - } - }); - return fd; - }), - createClientTemporaryReferenceSet: vi.fn(() => new Set()), -})); +function pluginRscMock() { + return { + encodeReply: vi.fn(async (args: unknown[]) => { + const fd = new FormData(); + args.forEach((arg, i) => { + if (arg instanceof Uint8Array) { + fd.append(String(i), new Blob([arg.slice()], { type: "" })); + } else if (arg instanceof Blob) { + fd.append(String(i), arg); + } else { + fd.append(String(i), JSON.stringify(arg)); + } + }); + return fd; + }), + createClientTemporaryReferenceSet: vi.fn(() => new Set()), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); // Identity codec — the value round-trips through the store unchanged. vi.mock("../segment-codec.js", () => ({ diff --git a/packages/rangojs-router/src/cache/__tests__/cache-runtime-fastkey.test.ts b/packages/rangojs-router/src/cache/__tests__/cache-runtime-fastkey.test.ts index 916a64ab0..0739a3873 100644 --- a/packages/rangojs-router/src/cache/__tests__/cache-runtime-fastkey.test.ts +++ b/packages/rangojs-router/src/cache/__tests__/cache-runtime-fastkey.test.ts @@ -21,10 +21,14 @@ import { compileSearchParamsFilter } from "../search-params-filter.js"; const encodeReply = vi.fn(async (args: unknown[], _opts?: unknown) => JSON.stringify(args), ); -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - encodeReply: (args: unknown[], opts: unknown) => encodeReply(args, opts), - createClientTemporaryReferenceSet: vi.fn(() => new Set()), -})); +function pluginRscMock() { + return { + encodeReply: (args: unknown[], opts: unknown) => encodeReply(args, opts), + createClientTemporaryReferenceSet: vi.fn(() => new Set()), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); vi.mock("../segment-codec.js", () => ({ serializeResult: vi.fn(async (v: any) => JSON.stringify(v)), diff --git a/packages/rangojs-router/src/cache/__tests__/cache-runtime-inflight.test.ts b/packages/rangojs-router/src/cache/__tests__/cache-runtime-inflight.test.ts index 89cacc843..3662ea250 100644 --- a/packages/rangojs-router/src/cache/__tests__/cache-runtime-inflight.test.ts +++ b/packages/rangojs-router/src/cache/__tests__/cache-runtime-inflight.test.ts @@ -18,12 +18,16 @@ import { NOCACHE_SYMBOL } from "../taint.js"; // encodeReply serializes args so JSON-safe args still exercise the wrapper; the // fast-path key builder handles them without calling this in practice. -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - encodeReply: vi.fn((args: unknown[]) => - Promise.resolve(JSON.stringify(args)), - ), - createClientTemporaryReferenceSet: vi.fn().mockReturnValue(new Set()), -})); +function pluginRscMock() { + return { + encodeReply: vi.fn((args: unknown[]) => + Promise.resolve(JSON.stringify(args)), + ), + createClientTemporaryReferenceSet: vi.fn().mockReturnValue(new Set()), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); const mockGetRequestContext = vi.fn<() => any>(() => null); vi.mock("../../server/request-context.js", () => ({ diff --git a/packages/rangojs-router/src/cache/__tests__/cache-runtime-key.test.ts b/packages/rangojs-router/src/cache/__tests__/cache-runtime-key.test.ts index 19cb95296..391c5f9a0 100644 --- a/packages/rangojs-router/src/cache/__tests__/cache-runtime-key.test.ts +++ b/packages/rangojs-router/src/cache/__tests__/cache-runtime-key.test.ts @@ -18,10 +18,14 @@ import { describe, it, expect, vi } from "vitest"; // @vitejs/plugin-rsc/rsc is a virtual module, not resolvable in vitest. The // key-derivation tests do not invoke encodeReply, but the import must resolve. -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - encodeReply: vi.fn(), - createClientTemporaryReferenceSet: vi.fn(), -})); +function pluginRscMock() { + return { + encodeReply: vi.fn(), + createClientTemporaryReferenceSet: vi.fn(), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); import { replyToCacheKey } from "../cache-runtime.js"; diff --git a/packages/rangojs-router/src/cache/__tests__/cache-runtime-stale.test.ts b/packages/rangojs-router/src/cache/__tests__/cache-runtime-stale.test.ts index 18de7014c..3cd5c2a8a 100644 --- a/packages/rangojs-router/src/cache/__tests__/cache-runtime-stale.test.ts +++ b/packages/rangojs-router/src/cache/__tests__/cache-runtime-stale.test.ts @@ -14,12 +14,16 @@ import { NOCACHE_SYMBOL } from "../taint.js"; // Mock @vitejs/plugin-rsc/rsc (virtual module, not resolvable in vitest) // encodeReply serializes args so different inputs produce different cache keys. -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - encodeReply: vi.fn((args: unknown[]) => - Promise.resolve(JSON.stringify(args)), - ), - createClientTemporaryReferenceSet: vi.fn().mockReturnValue(new Set()), -})); +function pluginRscMock() { + return { + encodeReply: vi.fn((args: unknown[]) => + Promise.resolve(JSON.stringify(args)), + ), + createClientTemporaryReferenceSet: vi.fn().mockReturnValue(new Set()), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); // Mock request context. runWithRequestContext is exercised by the background // revalidation path (it re-establishes the request-context ALS so the cached diff --git a/packages/rangojs-router/src/cache/__tests__/cache-scope.test.ts b/packages/rangojs-router/src/cache/__tests__/cache-scope.test.ts index 28f8ba8ca..c26cd753c 100644 --- a/packages/rangojs-router/src/cache/__tests__/cache-scope.test.ts +++ b/packages/rangojs-router/src/cache/__tests__/cache-scope.test.ts @@ -7,7 +7,9 @@ import type { SerializedSegmentData } from "../types.js"; // require a full React Server Components runtime which is not available in vitest. // We replace them with simple JSON-based encode/decode so we can test the // serialize/deserialize logic without the RSC dependency. -vi.mock("@vitejs/plugin-rsc/rsc", () => { +// The module under test imports from both @vitejs/plugin-rsc/rsc/server and +// /rsc/client. +function pluginRscMock() { const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -46,7 +48,9 @@ vi.mock("@vitejs/plugin-rsc/rsc", () => { return JSON.parse(result); }, }; -}); +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); // Import AFTER mocks are registered so vitest applies them. const { serializeSegments, deserializeSegments, serializeResult } = @@ -256,7 +260,7 @@ describe("serializeSegments / deserializeSegments", () => { describe("sentinel handling must bypass rscDeserialize", () => { it('should NOT call createFromReadableStream when encodedLoading is "null"', async () => { - const rscModule = await import("@vitejs/plugin-rsc/rsc"); + const rscModule = await import("@vitejs/plugin-rsc/rsc/client"); const createSpy = vi.fn(rscModule.createFromReadableStream); // Temporarily replace the module's export diff --git a/packages/rangojs-router/src/cache/__tests__/segment-codec-fragments.test.ts b/packages/rangojs-router/src/cache/__tests__/segment-codec-fragments.test.ts index b619a4c3a..ffec45bd3 100644 --- a/packages/rangojs-router/src/cache/__tests__/segment-codec-fragments.test.ts +++ b/packages/rangojs-router/src/cache/__tests__/segment-codec-fragments.test.ts @@ -23,7 +23,9 @@ import { expandSegmentFragments, } from "../../segment-fragments.js"; -vi.mock("@vitejs/plugin-rsc/rsc", () => { +// The module under test imports from both @vitejs/plugin-rsc/rsc/server and +// /rsc/client. +function pluginRscMock() { const encoder = new TextEncoder(); const decoder = new TextDecoder(); return { @@ -49,7 +51,9 @@ vi.mock("@vitejs/plugin-rsc/rsc", () => { return JSON.parse(result); }, }; -}); +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); const { serializeSegments, deserializeSegments, fragmentSegments } = await import("../segment-codec.js"); diff --git a/packages/rangojs-router/src/cache/__tests__/segment-codec.bench.ts b/packages/rangojs-router/src/cache/__tests__/segment-codec.bench.ts index c7b3fa127..1d38c8820 100644 --- a/packages/rangojs-router/src/cache/__tests__/segment-codec.bench.ts +++ b/packages/rangojs-router/src/cache/__tests__/segment-codec.bench.ts @@ -3,7 +3,7 @@ import type { ResolvedSegment } from "../../types.js"; // Mock the RSC module with lightweight JSON-based encode/decode // so the benchmark measures parallelization overhead, not RSC runtime cost. -vi.mock("@vitejs/plugin-rsc/rsc", () => { +function pluginRscMock() { const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -33,7 +33,9 @@ vi.mock("@vitejs/plugin-rsc/rsc", () => { return JSON.parse(result); }, }; -}); +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); const { serializeSegments, deserializeSegments } = await import("../segment-codec.js"); diff --git a/packages/rangojs-router/src/cache/__tests__/segment-codec.test.ts b/packages/rangojs-router/src/cache/__tests__/segment-codec.test.ts index b5ad71fda..ec9fa3089 100644 --- a/packages/rangojs-router/src/cache/__tests__/segment-codec.test.ts +++ b/packages/rangojs-router/src/cache/__tests__/segment-codec.test.ts @@ -24,11 +24,15 @@ const { }; }); -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - renderToReadableStream: vi.fn(), - createTemporaryReferenceSet: createTemporaryReferenceSetSpy, - createFromReadableStream: createFromReadableStreamSpy, -})); +function pluginRscMock() { + return { + renderToReadableStream: vi.fn(), + createTemporaryReferenceSet: createTemporaryReferenceSetSpy, + createFromReadableStream: createFromReadableStreamSpy, + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); import { deserializeResult, diff --git a/packages/rangojs-router/src/cache/cache-runtime.ts b/packages/rangojs-router/src/cache/cache-runtime.ts index 6f469c69f..726642ce2 100644 --- a/packages/rangojs-router/src/cache/cache-runtime.ts +++ b/packages/rangojs-router/src/cache/cache-runtime.ts @@ -19,7 +19,7 @@ import { encodeReply, createClientTemporaryReferenceSet, -} from "@vitejs/plugin-rsc/rsc"; +} from "../deps/rsc-client.js"; import { getRequestContext, runWithRequestContext, diff --git a/packages/rangojs-router/src/cache/segment-codec.ts b/packages/rangojs-router/src/cache/segment-codec.ts index 9dc3da8d2..5bb2c1e30 100644 --- a/packages/rangojs-router/src/cache/segment-codec.ts +++ b/packages/rangojs-router/src/cache/segment-codec.ts @@ -15,8 +15,8 @@ import { segmentFragment } from "../segment-fragments.js"; import { renderToReadableStream, createTemporaryReferenceSet, -} from "@vitejs/plugin-rsc/rsc"; -import { createFromReadableStream } from "@vitejs/plugin-rsc/rsc"; +} from "../deps/rsc.js"; +import { createFromReadableStream } from "../deps/rsc-client.js"; // Preserve embedded server references on a cache/prerender HIT so they // re-serialize to the client instead of resolving to a raw function React diff --git a/packages/rangojs-router/src/deps/rsc-client.ts b/packages/rangojs-router/src/deps/rsc-client.ts new file mode 100644 index 000000000..c6c7c18b2 --- /dev/null +++ b/packages/rangojs-router/src/deps/rsc-client.ts @@ -0,0 +1,8 @@ +/// +// RSC-environment *client* protocol (deserialize / encodeReply). Kept as its +// own module so a server-only importer of `./rsc.ts` does not pull this side. +export { + createFromReadableStream, + encodeReply, + createClientTemporaryReferenceSet, +} from "@vitejs/plugin-rsc/rsc/client"; diff --git a/packages/rangojs-router/src/deps/rsc.ts b/packages/rangojs-router/src/deps/rsc.ts index 849ca5bec..333640c6e 100644 --- a/packages/rangojs-router/src/deps/rsc.ts +++ b/packages/rangojs-router/src/deps/rsc.ts @@ -1,5 +1,7 @@ /// -// Re-export @vitejs/plugin-rsc/rsc for internal use by virtual entries +// Re-export the RSC-environment *server* runtime for virtual entries. +// Prefer `@vitejs/plugin-rsc/rsc/server` over the combined `/rsc` barrel so +// Vite can skip bundling the unused `react-server-dom` client protocol. export { renderToReadableStream, decodeReply, @@ -7,4 +9,4 @@ export { loadServerAction, decodeAction, decodeFormState, -} from "@vitejs/plugin-rsc/rsc"; +} from "@vitejs/plugin-rsc/rsc/server"; diff --git a/packages/rangojs-router/src/deps/ssr.ts b/packages/rangojs-router/src/deps/ssr.ts index 1428a9736..7ba9cf269 100644 --- a/packages/rangojs-router/src/deps/ssr.ts +++ b/packages/rangojs-router/src/deps/ssr.ts @@ -1,4 +1,5 @@ export { createFromReadableStream, setOnClientReference, + getClientEntryUrl, } from "@vitejs/plugin-rsc/ssr"; diff --git a/packages/rangojs-router/src/router/__tests__/prerender-render-error.test.tsx b/packages/rangojs-router/src/router/__tests__/prerender-render-error.test.tsx index 1d8fd1cd1..89bc05b27 100644 --- a/packages/rangojs-router/src/router/__tests__/prerender-render-error.test.tsx +++ b/packages/rangojs-router/src/router/__tests__/prerender-render-error.test.tsx @@ -28,15 +28,19 @@ const { renderSpy } = vi.hoisted(() => ({ ), })); -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - createFromReadableStream: vi.fn(), - renderToReadableStream: renderSpy, - loadServerAction: vi.fn(), - decodeReply: vi.fn(), - decodeAction: vi.fn(), - decodeFormState: vi.fn(), - createTemporaryReferenceSet: vi.fn(() => ({})), -})); +function pluginRscMock() { + return { + createFromReadableStream: vi.fn(), + renderToReadableStream: renderSpy, + loadServerAction: vi.fn(), + decodeReply: vi.fn(), + decodeAction: vi.fn(), + decodeFormState: vi.fn(), + createTemporaryReferenceSet: vi.fn(() => ({})), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); import { Prerender } from "../../prerender.js"; import { Skip } from "../../errors.js"; diff --git a/packages/rangojs-router/src/router/__tests__/prerender-rootscope-isolation.test.ts b/packages/rangojs-router/src/router/__tests__/prerender-rootscope-isolation.test.ts index 4b1993002..ff1b2ac10 100644 --- a/packages/rangojs-router/src/router/__tests__/prerender-rootscope-isolation.test.ts +++ b/packages/rangojs-router/src/router/__tests__/prerender-rootscope-isolation.test.ts @@ -4,15 +4,19 @@ import { describe, it, expect, vi } from "vitest"; // top-level body imports Vite virtual modules that do not resolve in plain // node/vitest. The bake path under test never renders Flight (the handler // halts before encoding), so a stub is sufficient. -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - createFromReadableStream: vi.fn(), - renderToReadableStream: vi.fn(), - loadServerAction: vi.fn(), - decodeReply: vi.fn(), - decodeAction: vi.fn(), - decodeFormState: vi.fn(), - createTemporaryReferenceSet: vi.fn(), -})); +function pluginRscMock() { + return { + createFromReadableStream: vi.fn(), + renderToReadableStream: vi.fn(), + loadServerAction: vi.fn(), + decodeReply: vi.fn(), + decodeAction: vi.fn(), + decodeFormState: vi.fn(), + createTemporaryReferenceSet: vi.fn(), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); import { createRouter } from "../../router.js"; import type { RangoInternal } from "../../router/router-interfaces.js"; diff --git a/packages/rangojs-router/src/router/match-middleware/__tests__/cache-lookup-shell-replay-fallback.test.ts b/packages/rangojs-router/src/router/match-middleware/__tests__/cache-lookup-shell-replay-fallback.test.ts index dd3c41d02..8249a2f90 100644 --- a/packages/rangojs-router/src/router/match-middleware/__tests__/cache-lookup-shell-replay-fallback.test.ts +++ b/packages/rangojs-router/src/router/match-middleware/__tests__/cache-lookup-shell-replay-fallback.test.ts @@ -23,7 +23,7 @@ vi.mock("../../../prerender/store.js", () => ({ // lookupRoute deserializes cached segments through segment-codec; same // JSON-based Flight stand-in as cache-scope.test.ts, mocked at the // virtual-module seam. -vi.mock("@vitejs/plugin-rsc/rsc", () => { +function pluginRscMock() { const encoder = new TextEncoder(); const decoder = new TextDecoder(); return { @@ -48,7 +48,9 @@ vi.mock("@vitejs/plugin-rsc/rsc", () => { return JSON.parse(result + decoder.decode()); }, }; -}); +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); import { withCacheLookup } from "../cache-lookup.js"; import { runWithRouterContext } from "../../router-context.js"; diff --git a/packages/rangojs-router/src/router/match-middleware/__tests__/cache-store-shell-doc-record.test.ts b/packages/rangojs-router/src/router/match-middleware/__tests__/cache-store-shell-doc-record.test.ts index d217a1ab9..87ea3f4c8 100644 --- a/packages/rangojs-router/src/router/match-middleware/__tests__/cache-store-shell-doc-record.test.ts +++ b/packages/rangojs-router/src/router/match-middleware/__tests__/cache-store-shell-doc-record.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect, vi } from "vitest"; // needs a Flight runtime vitest lacks. Same JSON-based stand-in as // cache-scope.test.ts, mocked at the virtual-module seam so every importer // (cache-scope AND handle-snapshot) sees it. -vi.mock("@vitejs/plugin-rsc/rsc", () => { +function pluginRscMock() { const encoder = new TextEncoder(); const decoder = new TextDecoder(); return { @@ -29,7 +29,9 @@ vi.mock("@vitejs/plugin-rsc/rsc", () => { return JSON.parse(result + decoder.decode()); }, }; -}); +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); import { withCacheStore } from "../cache-store.js"; import { runWithRouterContext } from "../../router-context.js"; diff --git a/packages/rangojs-router/src/rsc/__tests__/handler-metrics.test.ts b/packages/rangojs-router/src/rsc/__tests__/handler-metrics.test.ts index 58a65dbd2..0a8676b14 100644 --- a/packages/rangojs-router/src/rsc/__tests__/handler-metrics.test.ts +++ b/packages/rangojs-router/src/rsc/__tests__/handler-metrics.test.ts @@ -5,77 +5,7 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -// Mock route-map-builder so manifest is always available. -vi.mock("../../route-map-builder.js", () => ({ - hasCachedManifest: () => true, - waitForManifestReady: () => null, - getRouterManifest: () => ({ home: "/" }), - getRouterTrie: () => null, - getGlobalRouteMap: () => ({ home: "/" }), - isRouteRootScoped: () => false, -})); - -// Mock @vitejs/plugin-rsc/rsc with minimal stubs -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - renderToReadableStream: () => new ReadableStream(), - decodeReply: vi.fn(), - createTemporaryReferenceSet: vi.fn(() => new Set()), - loadServerAction: vi.fn(), - decodeAction: vi.fn(), - decodeFormState: vi.fn(), -})); - -vi.mock("../nonce.js", () => ({ - generateNonce: () => undefined, - nonce: Symbol("nonce"), -})); - -vi.mock("../manifest-init.js", () => ({ - buildRouterTrieFromUrlpatterns: vi.fn(), -})); - -// Mock dependencies used by classifyRequest → resolveRoute -vi.mock("../../router/manifest.js", () => ({ - loadManifest: vi.fn(async () => ({ - type: "route", - shortCode: "R0", - parent: null, - handler: vi.fn(), - responseType: "json", - })), - clearManifestCache: vi.fn(), -})); - -vi.mock("../../router/middleware.js", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - collectRouteMiddleware: vi.fn(() => []), - }; -}); - -vi.mock("../../cache/cache-scope.js", () => ({ - createCacheScope: vi.fn(() => null), -})); - -// handleResponseRoute returns a simple response for response-route tests -vi.mock("../response-route-handler.js", () => ({ - handleResponseRoute: vi.fn( - async () => new Response("response-route", { status: 200 }), - ), -})); - -vi.mock("../../router/telemetry.js", () => ({ - resolveSink: () => null, - safeEmit: vi.fn(), - getRequestId: () => "test-req-id", -})); - -vi.mock("../../router/router-context.js", () => ({ - getRouterContext: () => null, -})); - +import "./handler-test-mocks.js"; import { createRSCHandler } from "../handler.js"; import { getRequestContext } from "../../server/request-context.js"; import type { RangoInternal } from "../../router/router-interfaces.js"; diff --git a/packages/rangojs-router/src/rsc/__tests__/handler-mock-factories.ts b/packages/rangojs-router/src/rsc/__tests__/handler-mock-factories.ts new file mode 100644 index 000000000..8beb2d880 --- /dev/null +++ b/packages/rangojs-router/src/rsc/__tests__/handler-mock-factories.ts @@ -0,0 +1,114 @@ +/** + * Pure vi.mock factory bodies for handler-level tests. handler-test-mocks.ts + * registers the full set as a side-effect preamble; files whose mock surface + * diverges (spy-instrumented plugin-rsc, deliberately-unmocked telemetry, a + * manifest without responseType) must NOT import that preamble — they register + * only the factories they share, via dynamic import so the hoisted vi.mock + * call never touches an uninitialized binding: + * + * vi.mock("../nonce.js", async () => + * (await import("./handler-mock-factories.js")).nonceMock(), + * ); + * + * No vi.mock calls in this module — importing it must not register anything. + */ +import { vi } from "vitest"; + +// Manifest is always "available" so the handler does not short-circuit. +export function routeMapBuilderMock(): Record { + return { + hasCachedManifest: () => true, + waitForManifestReady: () => null, + getRouterManifest: () => ({ home: "/" }), + getRouterTrie: () => null, + getGlobalRouteMap: () => ({ home: "/" }), + isRouteRootScoped: () => false, + }; +} + +// Minimal @vitejs/plugin-rsc stub; register for BOTH /rsc/server and +// /rsc/client (segment-codec imports from both entries), so the shape is the +// union of both: the last three names are the real /rsc/client surface +// (createFromReadableStream, encodeReply, createClientTemporaryReferenceSet) +// — without them a test reaching a cache-deserialize path dies on a module +// link error instead of a meaningful assertion. +export function pluginRscMock(): Record { + return { + renderToReadableStream: () => new ReadableStream(), + decodeReply: vi.fn(), + createTemporaryReferenceSet: vi.fn(() => new Set()), + loadServerAction: vi.fn(), + decodeAction: vi.fn(), + decodeFormState: vi.fn(), + createFromReadableStream: vi.fn(), + encodeReply: vi.fn(), + createClientTemporaryReferenceSet: vi.fn(() => ({})), + }; +} + +export function nonceMock(): Record { + return { + generateNonce: () => undefined, + nonce: Symbol("nonce"), + }; +} + +export function manifestInitMock(): Record { + return { + buildRouterTrieFromUrlpatterns: vi.fn(), + }; +} + +// Used by classifyRequest -> resolveRoute; responseType "json" classifies +// every route as a response route. +export function manifestMock(): Record { + return { + loadManifest: vi.fn(async () => ({ + type: "route", + shortCode: "R0", + parent: null, + handler: vi.fn(), + responseType: "json", + })), + clearManifestCache: vi.fn(), + }; +} + +export async function middlewareMock( + importOriginal: () => Promise, +): Promise> { + const actual = await importOriginal(); + return { + ...actual, + collectRouteMiddleware: vi.fn(() => []), + }; +} + +export function cacheScopeMock(): Record { + return { + createCacheScope: vi.fn(() => null), + }; +} + +// handleResponseRoute returns a simple response for response-route tests. +export function responseRouteMock(): Record { + return { + handleResponseRoute: vi.fn( + async () => new Response("response-route", { status: 200 }), + ), + }; +} + +export function telemetryMock(): Record { + return { + resolveSink: () => null, + safeEmit: vi.fn(), + getRequestId: () => "test-req-id", + }; +} + +export function routerContextMock(): Record { + return { + getRouterContext: () => null, + }; +} diff --git a/packages/rangojs-router/src/rsc/__tests__/handler-notfound-payload.test.ts b/packages/rangojs-router/src/rsc/__tests__/handler-notfound-payload.test.ts index cf6573f83..98124cac3 100644 --- a/packages/rangojs-router/src/rsc/__tests__/handler-notfound-payload.test.ts +++ b/packages/rangojs-router/src/rsc/__tests__/handler-notfound-payload.test.ts @@ -12,45 +12,40 @@ import { describe, it, expect, vi, afterEach } from "vitest"; // Capture the payload passed to renderToReadableStream so we can inspect the // not-found payload metadata. The actual stream contents are irrelevant here. const renderToReadableStreamSpy = vi.fn(); -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - renderToReadableStream: (payload: unknown) => { - renderToReadableStreamSpy(payload); - return new ReadableStream(); - }, - decodeReply: vi.fn(), - createTemporaryReferenceSet: vi.fn(() => new Set()), - loadServerAction: vi.fn(), - decodeAction: vi.fn(), - decodeFormState: vi.fn(), -})); - -// Manifest is always "available" so the handler does not short-circuit. -vi.mock("../../route-map-builder.js", () => ({ - hasCachedManifest: () => true, - waitForManifestReady: () => null, - getRouterManifest: () => ({ home: "/" }), - getRouterTrie: () => null, - getGlobalRouteMap: () => ({ home: "/" }), - isRouteRootScoped: () => false, -})); - -vi.mock("../nonce.js", () => ({ - generateNonce: () => undefined, - nonce: Symbol("nonce"), -})); - -vi.mock("../manifest-init.js", () => ({ - buildRouterTrieFromUrlpatterns: vi.fn(), -})); - -vi.mock("../../router/telemetry.js", () => ({ - resolveSink: () => null, - safeEmit: vi.fn(), -})); +function pluginRscMock() { + return { + renderToReadableStream: (payload: unknown) => { + renderToReadableStreamSpy(payload); + return new ReadableStream(); + }, + decodeReply: vi.fn(), + createTemporaryReferenceSet: vi.fn(() => new Set()), + loadServerAction: vi.fn(), + decodeAction: vi.fn(), + decodeFormState: vi.fn(), + createFromReadableStream: vi.fn(), + encodeReply: vi.fn(), + createClientTemporaryReferenceSet: vi.fn(() => ({})), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); -vi.mock("../../router/router-context.js", () => ({ - getRouterContext: () => null, -})); +vi.mock("../../route-map-builder.js", async () => + (await import("./handler-mock-factories.js")).routeMapBuilderMock(), +); +vi.mock("../nonce.js", async () => + (await import("./handler-mock-factories.js")).nonceMock(), +); +vi.mock("../manifest-init.js", async () => + (await import("./handler-mock-factories.js")).manifestInitMock(), +); +vi.mock("../../router/telemetry.js", async () => + (await import("./handler-mock-factories.js")).telemetryMock(), +); +vi.mock("../../router/router-context.js", async () => + (await import("./handler-mock-factories.js")).routerContextMock(), +); import { createRSCHandler } from "../handler.js"; import { RouteNotFoundError } from "../../errors.js"; diff --git a/packages/rangojs-router/src/rsc/__tests__/handler-ssr-kickoff.test.ts b/packages/rangojs-router/src/rsc/__tests__/handler-ssr-kickoff.test.ts index 1b9f8b42c..a91347d0d 100644 --- a/packages/rangojs-router/src/rsc/__tests__/handler-ssr-kickoff.test.ts +++ b/packages/rangojs-router/src/rsc/__tests__/handler-ssr-kickoff.test.ts @@ -17,39 +17,24 @@ vi.mock("../ssr-setup.js", async (importOriginal) => { }; }); -// Mock route-map-builder so manifest is always available. -// Also provides getGlobalRouteMap/isRouteRootScoped used by request-context. -vi.mock("../../route-map-builder.js", () => ({ - hasCachedManifest: () => true, - waitForManifestReady: () => null, - getRouterManifest: () => ({ home: "/" }), - getRouterTrie: () => null, - getGlobalRouteMap: () => ({ home: "/" }), - isRouteRootScoped: () => false, -})); - -// Mock @vitejs/plugin-rsc/rsc with minimal stubs -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - renderToReadableStream: () => new ReadableStream(), - decodeReply: vi.fn(), - createTemporaryReferenceSet: vi.fn(() => new Set()), - loadServerAction: vi.fn(), - decodeAction: vi.fn(), - decodeFormState: vi.fn(), -})); - -// Mock the nonce module -vi.mock("../nonce.js", () => ({ - generateNonce: () => undefined, - nonce: Symbol("nonce"), -})); - -// Mock manifest-init to avoid Vite-specific imports -vi.mock("../manifest-init.js", () => ({ - buildRouterTrieFromUrlpatterns: vi.fn(), -})); - -// Mock manifest loading used by resolveRoute (inside classifyRequest) +vi.mock("../../route-map-builder.js", async () => + (await import("./handler-mock-factories.js")).routeMapBuilderMock(), +); +vi.mock("@vitejs/plugin-rsc/rsc/server", async () => + (await import("./handler-mock-factories.js")).pluginRscMock(), +); +vi.mock("@vitejs/plugin-rsc/rsc/client", async () => + (await import("./handler-mock-factories.js")).pluginRscMock(), +); +vi.mock("../nonce.js", async () => + (await import("./handler-mock-factories.js")).nonceMock(), +); +vi.mock("../manifest-init.js", async () => + (await import("./handler-mock-factories.js")).manifestInitMock(), +); + +// Unlike the shared manifestMock, no responseType: routes default to render so +// per-test loadManifest overrides pick response vs render classification. vi.mock("../../router/manifest.js", () => ({ loadManifest: vi.fn(async () => ({ type: "route", @@ -60,38 +45,21 @@ vi.mock("../../router/manifest.js", () => ({ clearManifestCache: vi.fn(), })); -// Mock middleware collection used by resolveRoute -vi.mock("../../router/middleware.js", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - collectRouteMiddleware: vi.fn(() => []), - }; -}); - -// Mock cache-scope used by resolveRoute -vi.mock("../../cache/cache-scope.js", () => ({ - createCacheScope: vi.fn(() => null), -})); - -// Mock response-route-handler to return a simple response -vi.mock("../response-route-handler.js", () => ({ - handleResponseRoute: vi.fn( - async () => new Response("response-route", { status: 200 }), - ), -})); - -// Mock telemetry to avoid ALS dependency -vi.mock("../../router/telemetry.js", () => ({ - resolveSink: () => null, - safeEmit: vi.fn(), -})); - -// Mock router-context -vi.mock("../../router/router-context.js", () => ({ - getRouterContext: () => null, -})); +vi.mock("../../router/middleware.js", async (importOriginal) => + (await import("./handler-mock-factories.js")).middlewareMock(importOriginal), +); +vi.mock("../../cache/cache-scope.js", async () => + (await import("./handler-mock-factories.js")).cacheScopeMock(), +); +vi.mock("../response-route-handler.js", async () => + (await import("./handler-mock-factories.js")).responseRouteMock(), +); +vi.mock("../../router/telemetry.js", async () => + (await import("./handler-mock-factories.js")).telemetryMock(), +); +vi.mock("../../router/router-context.js", async () => + (await import("./handler-mock-factories.js")).routerContextMock(), +); import { createRSCHandler } from "../handler.js"; import type { RangoInternal } from "../../router/router-interfaces.js"; diff --git a/packages/rangojs-router/src/rsc/__tests__/handler-telemetry-events.test.ts b/packages/rangojs-router/src/rsc/__tests__/handler-telemetry-events.test.ts index 030521356..bf095a0cc 100644 --- a/packages/rangojs-router/src/rsc/__tests__/handler-telemetry-events.test.ts +++ b/packages/rangojs-router/src/rsc/__tests__/handler-telemetry-events.test.ts @@ -13,61 +13,32 @@ */ import { describe, it, expect, vi, afterEach } from "vitest"; -// Mock route-map-builder so manifest is always available. -vi.mock("../../route-map-builder.js", () => ({ - hasCachedManifest: () => true, - waitForManifestReady: () => null, - getRouterManifest: () => ({ home: "/" }), - getRouterTrie: () => null, - getGlobalRouteMap: () => ({ home: "/" }), - isRouteRootScoped: () => false, -})); - -// Mock @vitejs/plugin-rsc/rsc with minimal stubs -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - renderToReadableStream: () => new ReadableStream(), - decodeReply: vi.fn(), - createTemporaryReferenceSet: vi.fn(() => new Set()), - loadServerAction: vi.fn(), - decodeAction: vi.fn(), - decodeFormState: vi.fn(), -})); - -vi.mock("../nonce.js", () => ({ - generateNonce: () => undefined, - nonce: Symbol("nonce"), -})); - -vi.mock("../manifest-init.js", () => ({ - buildRouterTrieFromUrlpatterns: vi.fn(), -})); - -// Mock dependencies used by classifyRequest → resolveRoute. loadManifest -// defaults to a response route (responseType: "json") for case A; case B -// overrides it per-call with mockResolvedValueOnce to a plain render route. -vi.mock("../../router/manifest.js", () => ({ - loadManifest: vi.fn(async () => ({ - type: "route", - shortCode: "R0", - parent: null, - handler: vi.fn(), - responseType: "json", - })), - clearManifestCache: vi.fn(), -})); - -vi.mock("../../router/middleware.js", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - collectRouteMiddleware: vi.fn(() => []), - }; -}); - -vi.mock("../../cache/cache-scope.js", () => ({ - createCacheScope: vi.fn(() => null), -})); +vi.mock("../../route-map-builder.js", async () => + (await import("./handler-mock-factories.js")).routeMapBuilderMock(), +); +vi.mock("@vitejs/plugin-rsc/rsc/server", async () => + (await import("./handler-mock-factories.js")).pluginRscMock(), +); +vi.mock("@vitejs/plugin-rsc/rsc/client", async () => + (await import("./handler-mock-factories.js")).pluginRscMock(), +); +vi.mock("../nonce.js", async () => + (await import("./handler-mock-factories.js")).nonceMock(), +); +vi.mock("../manifest-init.js", async () => + (await import("./handler-mock-factories.js")).manifestInitMock(), +); +// loadManifest defaults to a response route (responseType: "json") for case A; +// case B overrides it per-call with mockResolvedValueOnce to a render route. +vi.mock("../../router/manifest.js", async () => + (await import("./handler-mock-factories.js")).manifestMock(), +); +vi.mock("../../router/middleware.js", async (importOriginal) => + (await import("./handler-mock-factories.js")).middlewareMock(importOriginal), +); +vi.mock("../../cache/cache-scope.js", async () => + (await import("./handler-mock-factories.js")).cacheScopeMock(), +); // Never resolves — forces the render-start timeout to fire in case A. vi.mock("../response-route-handler.js", () => ({ diff --git a/packages/rangojs-router/src/rsc/__tests__/handler-test-mocks.ts b/packages/rangojs-router/src/rsc/__tests__/handler-test-mocks.ts index 930b0ea19..4e1a9d844 100644 --- a/packages/rangojs-router/src/rsc/__tests__/handler-test-mocks.ts +++ b/packages/rangojs-router/src/rsc/__tests__/handler-test-mocks.ts @@ -6,85 +6,49 @@ * import "./handler-test-mocks.js"; * import { createRSCHandler } from "../handler.js"; * - * vi.mock is NOT hoisted outside a test file, so registration here relies on - * this module evaluating before the mocked modules load — which ESM import - * order guarantees as long as this import statement comes first. Mock paths - * resolve relative to THIS file, so it must stay in src/rsc/__tests__/. + * Mock paths resolve relative to THIS file, so it must stay in + * src/rsc/__tests__/. Factory bodies live in handler-mock-factories.ts and are + * pulled via dynamic import INSIDE each factory: vitest hoists these vi.mock + * calls above this module's own imports, so referencing a static import in the + * factory argument would hit its binding before initialization. * - * The same preamble exists inline (pre-dating this module) in - * handler-metrics.test.ts, handler-telemetry-events.test.ts, - * handler-ssr-kickoff.test.ts, redirect-flight-onerror.test.ts, and - * handler-notfound-payload.test.ts — migrate them here when touched. + * A file that needs a DIFFERENT factory for any of these ids (or needs one of + * them left unmocked) must NOT import this preamble — it registers only the + * shared factories it wants, individually, with the same dynamic-import + * pattern; see handler-telemetry-events.test.ts. */ import { vi } from "vitest"; -// Mock route-map-builder so manifest is always available. -vi.mock("../../route-map-builder.js", () => ({ - hasCachedManifest: () => true, - waitForManifestReady: () => null, - getRouterManifest: () => ({ home: "/" }), - getRouterTrie: () => null, - getGlobalRouteMap: () => ({ home: "/" }), - isRouteRootScoped: () => false, -})); - -// Mock @vitejs/plugin-rsc/rsc with minimal stubs -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - renderToReadableStream: () => new ReadableStream(), - decodeReply: vi.fn(), - createTemporaryReferenceSet: vi.fn(() => new Set()), - loadServerAction: vi.fn(), - decodeAction: vi.fn(), - decodeFormState: vi.fn(), -})); - -vi.mock("../nonce.js", () => ({ - generateNonce: () => undefined, - nonce: Symbol("nonce"), -})); - -vi.mock("../manifest-init.js", () => ({ - buildRouterTrieFromUrlpatterns: vi.fn(), -})); - -// Mock dependencies used by classifyRequest → resolveRoute -vi.mock("../../router/manifest.js", () => ({ - loadManifest: vi.fn(async () => ({ - type: "route", - shortCode: "R0", - parent: null, - handler: vi.fn(), - responseType: "json", - })), - clearManifestCache: vi.fn(), -})); - -vi.mock("../../router/middleware.js", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - collectRouteMiddleware: vi.fn(() => []), - }; -}); - -vi.mock("../../cache/cache-scope.js", () => ({ - createCacheScope: vi.fn(() => null), -})); - -// handleResponseRoute returns a simple response for response-route tests -vi.mock("../response-route-handler.js", () => ({ - handleResponseRoute: vi.fn( - async () => new Response("response-route", { status: 200 }), - ), -})); - -vi.mock("../../router/telemetry.js", () => ({ - resolveSink: () => null, - safeEmit: vi.fn(), - getRequestId: () => "test-req-id", -})); - -vi.mock("../../router/router-context.js", () => ({ - getRouterContext: () => null, -})); +vi.mock("../../route-map-builder.js", async () => + (await import("./handler-mock-factories.js")).routeMapBuilderMock(), +); +vi.mock("@vitejs/plugin-rsc/rsc/server", async () => + (await import("./handler-mock-factories.js")).pluginRscMock(), +); +vi.mock("@vitejs/plugin-rsc/rsc/client", async () => + (await import("./handler-mock-factories.js")).pluginRscMock(), +); +vi.mock("../nonce.js", async () => + (await import("./handler-mock-factories.js")).nonceMock(), +); +vi.mock("../manifest-init.js", async () => + (await import("./handler-mock-factories.js")).manifestInitMock(), +); +vi.mock("../../router/manifest.js", async () => + (await import("./handler-mock-factories.js")).manifestMock(), +); +vi.mock("../../router/middleware.js", async (importOriginal) => + (await import("./handler-mock-factories.js")).middlewareMock(importOriginal), +); +vi.mock("../../cache/cache-scope.js", async () => + (await import("./handler-mock-factories.js")).cacheScopeMock(), +); +vi.mock("../response-route-handler.js", async () => + (await import("./handler-mock-factories.js")).responseRouteMock(), +); +vi.mock("../../router/telemetry.js", async () => + (await import("./handler-mock-factories.js")).telemetryMock(), +); +vi.mock("../../router/router-context.js", async () => + (await import("./handler-mock-factories.js")).routerContextMock(), +); diff --git a/packages/rangojs-router/src/rsc/__tests__/redirect-flight-onerror.test.ts b/packages/rangojs-router/src/rsc/__tests__/redirect-flight-onerror.test.ts index ae8297ecb..e97251c9d 100644 --- a/packages/rangojs-router/src/rsc/__tests__/redirect-flight-onerror.test.ts +++ b/packages/rangojs-router/src/rsc/__tests__/redirect-flight-onerror.test.ts @@ -21,30 +21,31 @@ const serializationError = new Error("Flight cannot serialize this value"); // renderToReadableStream fires onError, mimicking a serialization failure on // the redirect payload. The stream contents are irrelevant for this test. -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - renderToReadableStream: ( - _payload: unknown, - options?: { onError?: (error: unknown) => void }, - ) => { - options?.onError?.(serializationError); - return new ReadableStream(); - }, - decodeReply: vi.fn(), - createTemporaryReferenceSet: vi.fn(() => new Set()), - loadServerAction: vi.fn(), - decodeAction: vi.fn(), - decodeFormState: vi.fn(), -})); +function pluginRscMock() { + return { + renderToReadableStream: ( + _payload: unknown, + options?: { onError?: (error: unknown) => void }, + ) => { + options?.onError?.(serializationError); + return new ReadableStream(); + }, + decodeReply: vi.fn(), + createTemporaryReferenceSet: vi.fn(() => new Set()), + loadServerAction: vi.fn(), + decodeAction: vi.fn(), + decodeFormState: vi.fn(), + createFromReadableStream: vi.fn(), + encodeReply: vi.fn(), + createClientTemporaryReferenceSet: vi.fn(() => ({})), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); -// Manifest is always "available" so the handler does not short-circuit. -vi.mock("../../route-map-builder.js", () => ({ - hasCachedManifest: () => true, - waitForManifestReady: () => null, - getRouterManifest: () => ({ home: "/" }), - getRouterTrie: () => null, - getGlobalRouteMap: () => ({ home: "/" }), - isRouteRootScoped: () => false, -})); +vi.mock("../../route-map-builder.js", async () => + (await import("./handler-mock-factories.js")).routeMapBuilderMock(), +); // Drive the partial-redirect short-circuit (handler.ts plan.mode === "redirect" // with _rsc_partial): resolveRoute returns a redirect so classifyRequest yields @@ -56,23 +57,18 @@ vi.mock("../../router/route-snapshot.js", () => ({ })), })); -vi.mock("../nonce.js", () => ({ - generateNonce: () => undefined, - nonce: Symbol("nonce"), -})); - -vi.mock("../manifest-init.js", () => ({ - buildRouterTrieFromUrlpatterns: vi.fn(), -})); - -vi.mock("../../router/telemetry.js", () => ({ - resolveSink: () => null, - safeEmit: vi.fn(), -})); - -vi.mock("../../router/router-context.js", () => ({ - getRouterContext: () => null, -})); +vi.mock("../nonce.js", async () => + (await import("./handler-mock-factories.js")).nonceMock(), +); +vi.mock("../manifest-init.js", async () => + (await import("./handler-mock-factories.js")).manifestInitMock(), +); +vi.mock("../../router/telemetry.js", async () => + (await import("./handler-mock-factories.js")).telemetryMock(), +); +vi.mock("../../router/router-context.js", async () => + (await import("./handler-mock-factories.js")).routerContextMock(), +); import { createRSCHandler } from "../handler.js"; import type { RangoInternal } from "../../router/router-interfaces.js"; diff --git a/packages/rangojs-router/src/rsc/__tests__/rsc-rendering-default-prefetch-primitive.test.ts b/packages/rangojs-router/src/rsc/__tests__/rsc-rendering-default-prefetch-primitive.test.ts index 8dcb90d71..b14d8042e 100644 --- a/packages/rangojs-router/src/rsc/__tests__/rsc-rendering-default-prefetch-primitive.test.ts +++ b/packages/rangojs-router/src/rsc/__tests__/rsc-rendering-default-prefetch-primitive.test.ts @@ -21,15 +21,21 @@ const { renderSpy } = vi.hoisted(() => ({ ), })); -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - createFromReadableStream: vi.fn(), - renderToReadableStream: renderSpy, - loadServerAction: vi.fn(), - decodeReply: vi.fn(), - decodeAction: vi.fn(), - decodeFormState: vi.fn(), - createTemporaryReferenceSet: vi.fn(() => ({})), -})); +function pluginRscMock() { + return { + createFromReadableStream: vi.fn(), + renderToReadableStream: renderSpy, + loadServerAction: vi.fn(), + decodeReply: vi.fn(), + decodeAction: vi.fn(), + decodeFormState: vi.fn(), + createTemporaryReferenceSet: vi.fn(() => ({})), + encodeReply: vi.fn(), + createClientTemporaryReferenceSet: vi.fn(() => ({})), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); import { createRouter } from "../../router.js"; import { urls } from "../../urls/urls-function.js"; diff --git a/packages/rangojs-router/src/rsc/__tests__/rsc-rendering-strict-mode-primitive.test.ts b/packages/rangojs-router/src/rsc/__tests__/rsc-rendering-strict-mode-primitive.test.ts index 1f8cebd72..2a24fb3d0 100644 --- a/packages/rangojs-router/src/rsc/__tests__/rsc-rendering-strict-mode-primitive.test.ts +++ b/packages/rangojs-router/src/rsc/__tests__/rsc-rendering-strict-mode-primitive.test.ts @@ -25,15 +25,21 @@ const { renderSpy } = vi.hoisted(() => ({ ), })); -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - createFromReadableStream: vi.fn(), - renderToReadableStream: renderSpy, - loadServerAction: vi.fn(), - decodeReply: vi.fn(), - decodeAction: vi.fn(), - decodeFormState: vi.fn(), - createTemporaryReferenceSet: vi.fn(() => ({})), -})); +function pluginRscMock() { + return { + createFromReadableStream: vi.fn(), + renderToReadableStream: renderSpy, + loadServerAction: vi.fn(), + decodeReply: vi.fn(), + decodeAction: vi.fn(), + decodeFormState: vi.fn(), + createTemporaryReferenceSet: vi.fn(() => ({})), + encodeReply: vi.fn(), + createClientTemporaryReferenceSet: vi.fn(() => ({})), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); import { createRouter } from "../../router.js"; import { urls } from "../../urls/urls-function.js"; diff --git a/packages/rangojs-router/src/rsc/__tests__/rsc-rendering-warmup-primitive.test.ts b/packages/rangojs-router/src/rsc/__tests__/rsc-rendering-warmup-primitive.test.ts index c56e435e9..baa581f8e 100644 --- a/packages/rangojs-router/src/rsc/__tests__/rsc-rendering-warmup-primitive.test.ts +++ b/packages/rangojs-router/src/rsc/__tests__/rsc-rendering-warmup-primitive.test.ts @@ -24,15 +24,21 @@ const { renderSpy } = vi.hoisted(() => ({ ), })); -vi.mock("@vitejs/plugin-rsc/rsc", () => ({ - createFromReadableStream: vi.fn(), - renderToReadableStream: renderSpy, - loadServerAction: vi.fn(), - decodeReply: vi.fn(), - decodeAction: vi.fn(), - decodeFormState: vi.fn(), - createTemporaryReferenceSet: vi.fn(() => ({})), -})); +function pluginRscMock() { + return { + createFromReadableStream: vi.fn(), + renderToReadableStream: renderSpy, + loadServerAction: vi.fn(), + decodeReply: vi.fn(), + decodeAction: vi.fn(), + decodeFormState: vi.fn(), + createTemporaryReferenceSet: vi.fn(() => ({})), + encodeReply: vi.fn(), + createClientTemporaryReferenceSet: vi.fn(() => ({})), + }; +} +vi.mock("@vitejs/plugin-rsc/rsc/server", pluginRscMock); +vi.mock("@vitejs/plugin-rsc/rsc/client", pluginRscMock); import { createRouter } from "../../router.js"; import { urls } from "../../urls/urls-function.js"; diff --git a/packages/rangojs-router/src/rsc/handler.ts b/packages/rangojs-router/src/rsc/handler.ts index d6ef351db..ec28719f0 100644 --- a/packages/rangojs-router/src/rsc/handler.ts +++ b/packages/rangojs-router/src/rsc/handler.ts @@ -16,7 +16,7 @@ import { _getRequestContext, createRequestContext, } from "../server/request-context.js"; -import * as rscDeps from "@vitejs/plugin-rsc/rsc"; +import * as rscDeps from "@vitejs/plugin-rsc/rsc/server"; import type { RscPayload, CreateRSCHandlerOptions, @@ -137,7 +137,7 @@ import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js"; * @example With custom deps (advanced) * ```tsx * import { createRSCHandler } from "@rangojs/router/rsc"; - * import * as rsc from "@vitejs/plugin-rsc/rsc"; + * import * as rsc from "@vitejs/plugin-rsc/rsc/server"; * import { router } from "./router.js"; * * export default createRSCHandler({ @@ -176,7 +176,7 @@ export function createRSCHandler< // stores not covered by the app-level ctx._cacheStore. const explicitTaggedStores = new Set(); - // Use provided deps or default to @vitejs/plugin-rsc/rsc exports + // Use provided deps or default to @vitejs/plugin-rsc/rsc/server exports const deps = options.deps ?? rscDeps; const { renderToReadableStream, diff --git a/packages/rangojs-router/src/rsc/types.ts b/packages/rangojs-router/src/rsc/types.ts index ee5446171..18240765f 100644 --- a/packages/rangojs-router/src/rsc/types.ts +++ b/packages/rangojs-router/src/rsc/types.ts @@ -103,11 +103,11 @@ export interface RscPayload { export type ReactFormState = unknown; /** - * RSC dependencies from @vitejs/plugin-rsc/rsc + * RSC dependencies from @vitejs/plugin-rsc/rsc/server */ export interface RSCDependencies { /** - * renderToReadableStream from @vitejs/plugin-rsc/rsc + * renderToReadableStream from @vitejs/plugin-rsc/rsc/server */ renderToReadableStream: ( payload: T, @@ -118,7 +118,7 @@ export interface RSCDependencies { ) => ReadableStream; /** - * decodeReply from @vitejs/plugin-rsc/rsc + * decodeReply from @vitejs/plugin-rsc/rsc/server */ decodeReply: ( body: FormData | string, @@ -126,23 +126,23 @@ export interface RSCDependencies { ) => Promise; /** - * createTemporaryReferenceSet from @vitejs/plugin-rsc/rsc + * createTemporaryReferenceSet from @vitejs/plugin-rsc/rsc/server */ createTemporaryReferenceSet: () => unknown; /** - * loadServerAction from @vitejs/plugin-rsc/rsc + * loadServerAction from @vitejs/plugin-rsc/rsc/server */ loadServerAction: (actionId: string) => Promise; /** - * decodeAction from @vitejs/plugin-rsc/rsc + * decodeAction from @vitejs/plugin-rsc/rsc/server * Decodes a FormData into a bound action function (for useActionState forms) */ decodeAction: (body: FormData) => Promise<() => Promise>; /** - * decodeFormState from @vitejs/plugin-rsc/rsc + * decodeFormState from @vitejs/plugin-rsc/rsc/server * Decodes the action result into a ReactFormState for useActionState progressive enhancement */ decodeFormState: ( @@ -300,8 +300,8 @@ export interface CreateRSCHandlerOptions< router: RangoInternal; /** - * RSC dependencies from @vitejs/plugin-rsc/rsc. - * Defaults to the exports from @vitejs/plugin-rsc/rsc. + * RSC dependencies from @vitejs/plugin-rsc/rsc/server. + * Defaults to the exports from @vitejs/plugin-rsc/rsc/server. */ deps?: RSCDependencies; diff --git a/packages/rangojs-router/src/ssr/__tests__/preinit-client-references.test.tsx b/packages/rangojs-router/src/ssr/__tests__/preinit-client-references.test.tsx index b2387daa5..5d8f558b8 100644 --- a/packages/rangojs-router/src/ssr/__tests__/preinit-client-references.test.tsx +++ b/packages/rangojs-router/src/ssr/__tests__/preinit-client-references.test.tsx @@ -150,6 +150,68 @@ describe("bootstrapModules conversion (resolveBootstrapOptions)", () => { ); expect(opts.bootstrapModules).toBeUndefined(); }); + + it("prefers getClientEntryUrl over loadBootstrapScriptContent on preinit", async () => { + const { deps, renderToReadableStream } = renderSpyDeps( + 'import("/assets/index-abc123.js")', + ); + deps.headScripts = "preinit"; + deps.getClientEntryUrl = () => "/assets/from-url.js"; + await createSSRHandler(deps)(createMockRscStream()); + const opts = renderToReadableStream.mock.calls[0]![1] as { + bootstrapModules?: string[]; + bootstrapScriptContent?: string; + }; + expect(opts.bootstrapModules).toEqual(["/assets/from-url.js"]); + expect(opts.bootstrapScriptContent).toBeUndefined(); + expect(deps.loadBootstrapScriptContent).not.toHaveBeenCalled(); + }); + + it("preinit + getClientEntryUrl alone renders without loadBootstrapScriptContent", async () => { + const { deps, renderToReadableStream } = renderSpyDeps(""); + delete deps.loadBootstrapScriptContent; + deps.headScripts = "preinit"; + deps.getClientEntryUrl = () => "/assets/from-url.js"; + await createSSRHandler(deps)(createMockRscStream()); + const opts = renderToReadableStream.mock.calls[0]![1] as { + bootstrapModules?: string[]; + }; + expect(opts.bootstrapModules).toEqual(["/assets/from-url.js"]); + }); + + it("an empty getClientEntryUrl() falls through to the inline bootstrap", async () => { + const { deps, renderToReadableStream } = renderSpyDeps( + 'import("/assets/index-abc123.js")', + ); + deps.headScripts = "preinit"; + deps.getClientEntryUrl = () => ""; + await createSSRHandler(deps)(createMockRscStream()); + const opts = renderToReadableStream.mock.calls[0]![1] as { + bootstrapModules?: string[]; + bootstrapScriptContent?: string; + }; + expect(opts.bootstrapModules).toEqual(["/assets/index-abc123.js"]); + expect(deps.loadBootstrapScriptContent).toHaveBeenCalled(); + }); + + it("throws at construction when neither bootstrap dep is usable", () => { + const { deps } = renderSpyDeps(""); + delete deps.loadBootstrapScriptContent; + expect(() => createSSRHandler(deps)).toThrow( + /Missing bootstrap dependency/, + ); + // getClientEntryUrl without preinit cannot substitute for the inline dep; + // its presence is warned about, not silently ignored. + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + deps.getClientEntryUrl = () => "/assets/from-url.js"; + expect(() => createSSRHandler(deps)).toThrow( + /Missing bootstrap dependency/, + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("getClientEntryUrl is ignored"), + ); + warnSpy.mockRestore(); + }); }); describe("installClientReferencePreinit (real fizz render)", () => { diff --git a/packages/rangojs-router/src/ssr/__tests__/shell-handlers.test.tsx b/packages/rangojs-router/src/ssr/__tests__/shell-handlers.test.tsx index 32e2f83d2..282bc800f 100644 --- a/packages/rangojs-router/src/ssr/__tests__/shell-handlers.test.tsx +++ b/packages/rangojs-router/src/ssr/__tests__/shell-handlers.test.tsx @@ -160,6 +160,14 @@ describe("createShellCaptureHandler", () => { ).toThrow(/prerender/); }); + it("throws at creation when neither bootstrap dep is usable", () => { + expect(() => + createShellCaptureHandler( + makeDeps({ loadBootstrapScriptContent: undefined }), + ), + ).toThrow(/Missing bootstrap dependency/); + }); + it("(a) returns a prelude with shell + fallback + bootstrap, not the hole; postponed round-trips", async () => { const result = await captureShell(makeDeps(), "cap"); expect(result).not.toBeNull(); diff --git a/packages/rangojs-router/src/ssr/index.tsx b/packages/rangojs-router/src/ssr/index.tsx index 05407479a..37513e5d4 100644 --- a/packages/rangojs-router/src/ssr/index.tsx +++ b/packages/rangojs-router/src/ssr/index.tsx @@ -154,10 +154,20 @@ export interface SSRDependencies { ) => TransformStream; /** - * Function to load bootstrap script content - * Typically: () => import.meta.viteRsc.loadBootstrapScriptContent("index") + * Function to load bootstrap script content. + * Required unless `getClientEntryUrl` is provided with `headScripts: "preinit"`. + * Custom SSR entries typically: `() => import.meta.viteRsc.loadBootstrapScriptContent("index")` + * (deprecated in `@vitejs/plugin-rsc` 0.5.33 in favor of `getClientEntryUrl`). */ - loadBootstrapScriptContent: () => Promise; + loadBootstrapScriptContent?: () => Promise; + + /** + * Client entry URL from `@vitejs/plugin-rsc/ssr` `getClientEntryUrl()`. + * Preferred when `headScripts` is `"preinit"`: Fizz receives `bootstrapModules` + * without the deprecated `loadBootstrapScriptContent` round-trip. Custom SSR + * entries can omit this and keep the inline bootstrap path. + */ + getClientEntryUrl?: () => string; /** * Document script strategy; the generated virtual SSR entry threads the @@ -438,36 +448,105 @@ interface ShellResumeOptions { const BOOTSTRAP_IMPORT_ONLY_RE = /^\s*import\(\s*(["'])([^"'\\]+)\1\s*\)\s*;?\s*$/; +const MISSING_BOOTSTRAP_MSG = + "[ssr] Missing bootstrap dependency: provide loadBootstrapScriptContent(), " + + 'or getClientEntryUrl with headScripts: "preinit".'; + /** - * Prefer bootstrapModules over the inline import() bootstrap. When the content - * is exactly `import("")`, hand Fizz the URL instead: React then - * emits a `` hint in the head - * plus the executing `