Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/rangojs-router/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)`
Expand Down
2 changes: 1 addition & 1 deletion packages/rangojs-router/docs/internal/feature-file-map.md

Large diffs are not rendered by default.

27 changes: 14 additions & 13 deletions packages/rangojs-router/docs/internal/feature-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 12 additions & 10 deletions packages/rangojs-router/docs/internal/why-web-streams-everywhere.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_
Expand Down
3 changes: 2 additions & 1 deletion packages/rangojs-router/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions packages/rangojs-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions packages/rangojs-router/skills/testing/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 } =
Expand Down
22 changes: 13 additions & 9 deletions packages/rangojs-router/src/__tests__/static-id-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
Loading
Loading