diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ca1aad91f..b2bf121e9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -19,6 +19,8 @@ body: [plugin-react-swc](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-swc) - label: | [plugin-react-oxc](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-oxc) + - label: | + [plugin-rsc](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) - type: textarea id: bug-description attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 453e70e6d..22ad9c813 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -19,6 +19,8 @@ body: [plugin-react-swc](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-swc) - label: | [plugin-react-oxc](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-oxc) + - label: | + [plugin-rsc](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) - type: textarea id: feature-description attributes: diff --git a/.github/renovate.json5 b/.github/renovate.json5 index c5e9bc814..4135c2e0a 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -1,18 +1,23 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["config:base", "schedule:weekly", "group:allNonMajor"], + "extends": ["config:recommended", "schedule:weekly", "group:allNonMajor"], "labels": ["dependencies"], "ignorePaths": ["**/__tests__/**"], "rangeStrategy": "bump", "packageRules": [ { - "depTypeList": ["peerDependencies"], + "matchDepTypes": ["peerDependencies"], "enabled": false, }, { "matchFileNames": ["**/react-18/**", "**/compiler-react-18/**"], "ignoreDeps": ["react", "react-dom", "@types/react", "@types/react-dom"], }, + { + "extends": ["monorepo:swc"], + "groupName": "swc monorepo", + "separateMajorMinor": false, + }, // renovate doesn't properly handle x.x.x-beta-hash-yyyymm version schema { "matchPackageNames": [ @@ -23,16 +28,14 @@ }, { "matchDepTypes": ["action"], - "excludePackagePrefixes": ["actions/", "github/"], "pinDigests": true, + "matchPackageNames": ["!actions/{/,}**", "!github/{/,}**"], }, ], "ignoreDeps": [ // manually bumping "node", - "generouted", // testing lib shipping JSX (new version ship transpiled JS) - // breaking changes "source-map", // `source-map:v0.7.0+` needs more investigation "kill-port", // `kill-port:^2.0.0 has perf issues (#8392) diff --git a/.github/workflows/ci-rsc.yml b/.github/workflows/ci-rsc.yml new file mode 100644 index 000000000..7dcbd388c --- /dev/null +++ b/.github/workflows/ci-rsc.yml @@ -0,0 +1,86 @@ +name: ci-rsc + +permissions: {} + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + paths: + - "packages/plugin-rsc/**" + - "pnpm-lock.yaml" + - ".github/workflows/ci-rsc.yml" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.number || github.sha }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - run: pnpm i + - run: pnpm build + - run: pnpm -C packages/plugin-rsc tsc + - run: pnpm -C packages/plugin-rsc test + + test-e2e: + name: test-rsc (${{ matrix.os }} / ${{ matrix.browser }}) ${{ matrix.rolldown == true && '(rolldown)' || '' }} ${{ matrix.react_version && format('(react-{0})', matrix.react_version) || '' }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + browser: [chromium] + rolldown: [false] + react_version: [""] + include: + - os: ubuntu-latest + browser: firefox + - os: macos-latest + browser: webkit + - os: ubuntu-latest + browser: chromium + rolldown: true + - os: ubuntu-latest + browser: chromium + react_version: canary + - os: ubuntu-latest + browser: chromium + react_version: experimental + fail-fast: false + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - run: pnpm i + - name: install react + if: ${{ matrix.react_version }} + run: | + sed -i "/^overrides:/a\ react: \"${{ matrix.react_version }}\"" pnpm-workspace.yaml + sed -i "/^overrides:/a\ react-dom: \"${{ matrix.react_version }}\"" pnpm-workspace.yaml + sed -i "/^overrides:/a\ react-server-dom-webpack: \"${{ matrix.react_version }}\"" pnpm-workspace.yaml + pnpm i --no-frozen-lockfile + - run: pnpm build + - name: install rolldown + if: ${{ matrix.rolldown }} + run: | + sed -i '/^overrides:/a\ vite: "npm:rolldown-vite@latest"' pnpm-workspace.yaml + pnpm i --no-frozen-lockfile + - run: pnpm -C packages/plugin-rsc exec playwright install ${{ matrix.browser }} + - run: pnpm -C packages/plugin-rsc test-e2e-ci --project=${{ matrix.browser }} + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.os }}-${{ matrix.browser }}${{ matrix.rolldown == true && '-rolldown' || '' }}${{ matrix.react_version && format('-react-{0}', matrix.react_version) || '' }} + path: | + packages/plugin-rsc/test-results + if-no-files-found: ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc6104cac..b25a7a6df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - node_version: [18, 20, 22] + node_version: [20, 22, 24] include: # Active LTS + other OS - os: macos-latest @@ -41,7 +41,7 @@ jobs: name: "Build&Test: node-${{ matrix.node_version }}, ${{ matrix.os }}" steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install pnpm uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 @@ -87,19 +87,33 @@ jobs: - name: Test serve run: pnpm run test-serve + - name: Test full bundle mode serve + run: pnpm run test-full-bundle-mode + - name: Test build run: pnpm run test-build - name: Test SWC run: pnpm --filter ./packages/plugin-react-swc run test + - name: Setup rolldown-vite + run: | + sed -i"" -e "s/overrides:/overrides:\n vite: catalog:rolldown-vite/" pnpm-workspace.yaml + pnpm i --no-frozen-lockfile + + - name: Test serve (rolldown-vite) + run: pnpm run test-serve + + - name: Test build (rolldown-vite) + run: pnpm run test-build + lint: if: github.repository == 'vitejs/vite-plugin-react' timeout-minutes: 10 runs-on: ubuntu-latest name: "Lint: node-20, ubuntu-latest" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 000000000..6261d4a06 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,28 @@ +# https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/customize-the-agent-environment + +name: "Copilot Setup Steps" + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - run: pnpm i + - run: pnpm exec playwright install chromium diff --git a/.github/workflows/issue-close-require.yml b/.github/workflows/issue-close-require.yml index 6dce7b71a..9a642ad7a 100644 --- a/.github/workflows/issue-close-require.yml +++ b/.github/workflows/issue-close-require.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write # for actions-cool/issues-helper to update PRs steps: - name: need reproduction - uses: actions-cool/issues-helper@a610082f8ac0cf03e357eb8dd0d5e2ba075e017e # v3 + uses: actions-cool/issues-helper@45d75b6cf72bf4f254be6230cb887ad002702491 # v3 with: actions: "close-issues" token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/issue-labeled.yml b/.github/workflows/issue-labeled.yml index fdbebdcb0..2823e6395 100644 --- a/.github/workflows/issue-labeled.yml +++ b/.github/workflows/issue-labeled.yml @@ -14,7 +14,7 @@ jobs: steps: - name: contribution welcome if: github.event.label.name == 'contribution welcome' || github.event.label.name == 'help wanted' - uses: actions-cool/issues-helper@a610082f8ac0cf03e357eb8dd0d5e2ba075e017e # v3 + uses: actions-cool/issues-helper@45d75b6cf72bf4f254be6230cb887ad002702491 # v3 with: actions: "create-comment, remove-labels" token: ${{ secrets.GITHUB_TOKEN }} @@ -25,7 +25,7 @@ jobs: - name: remove pending if: (github.event.label.name == 'enhancement' || contains(github.event.label.description, '(priority)')) && contains(github.event.issue.labels.*.name, 'pending triage') - uses: actions-cool/issues-helper@a610082f8ac0cf03e357eb8dd0d5e2ba075e017e # v3 + uses: actions-cool/issues-helper@45d75b6cf72bf4f254be6230cb887ad002702491 # v3 with: actions: "remove-labels" token: ${{ secrets.GITHUB_TOKEN }} @@ -34,7 +34,7 @@ jobs: - name: need reproduction if: github.event.label.name == 'need reproduction' - uses: actions-cool/issues-helper@a610082f8ac0cf03e357eb8dd0d5e2ba075e017e # v3 + uses: actions-cool/issues-helper@45d75b6cf72bf4f254be6230cb887ad002702491 # v3 with: actions: "create-comment, remove-labels" token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index af2715b15..524924df8 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,15 +14,15 @@ jobs: environment: Release steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install pnpm uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - - name: Set node version to 20 + - name: Set node version uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 registry-url: https://registry.npmjs.org/ cache: "pnpm" @@ -55,8 +55,17 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - if: steps.tag.outputs.isAlpha == 'false' + - if: steps.tag.outputs.isAlpha == 'false' && steps.tag.outputs.pkgName != 'plugin-rsc' uses: ArnaudBarre/github-release@4fa6eafe8e2449c7c1c5a91ae50de4ee34db0b40 # v1.5.0 with: path: packages/${{ steps.tag.outputs.pkgName }}/CHANGELOG.md tag-name: ${{ github.ref_name }} + + - if: steps.tag.outputs.isAlpha == 'false' && steps.tag.outputs.pkgName == 'plugin-rsc' + uses: yyx990803/release-tag@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + body: | + Please refer to [CHANGELOG.md](https://github.com/vitejs/vite-plugin-react/blob/${{ github.ref_name }}/packages/${{ steps.tag.outputs.pkgName }}/CHANGELOG.md) for details. diff --git a/.github/workflows/release-continuous.yml b/.github/workflows/release-continuous.yml new file mode 100644 index 000000000..af673dbb3 --- /dev/null +++ b/.github/workflows/release-continuous.yml @@ -0,0 +1,38 @@ +name: Preview Publish + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, labeled] + +permissions: {} + +jobs: + preview: + if: > + github.repository == 'vitejs/vite-plugin-react' && + (github.event_name == 'push' || + (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'trigger: preview'))) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: pnpm + + - name: Install dependencies + run: pnpm install + + - name: Build + run: pnpm build + + - name: Publish + run: pnpm dlx pkg-pr-new@0.0 publish --pnpm --compact './packages/*' './packages/plugin-react-swc/dist' diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index 333597eae..8fa3d2a8b 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -16,7 +16,7 @@ jobs: pull-requests: read steps: - name: Validate PR title - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 # v5 + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 with: subjectPattern: ^(?![A-Z]).+$ subjectPatternError: | diff --git a/.npmrc b/.npmrc index bdc1cfac7..80af6f76f 100644 --- a/.npmrc +++ b/.npmrc @@ -5,3 +5,5 @@ hoist-pattern[]=eslint-import-resolver-* strict-peer-dependencies=false shell-emulator=true auto-install-peers=false +link-workspace-packages=true +prefer-workspace-packages=true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..955ffb013 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# AI Agent Development Guide + +This document provides AI-agent-specific guidance for working with the vite-plugin-react monorepo. For comprehensive documentation, see: + +- **[README.md](README.md)** - Repository overview and package links +- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Setup, testing, debugging, and contribution guidelines + +## Quick Reference for AI Agents + +### Repository Navigation + +This monorepo contains multiple packages (see [README.md](README.md#packages) for details): + +- `packages/plugin-react/` - Main React plugin with Babel +- `packages/plugin-react-swc/` - SWC-based React plugin +- `packages/plugin-rsc/` - React Server Components ([AI guidance](packages/plugin-rsc/AGENTS.md)) +- `packages/plugin-react-oxc/` - Deprecated (merged with plugin-react) + +### Essential Setup Commands + +```bash +pnpm install && pnpm build # Initial setup (see CONTRIBUTING.md for details) +pnpm dev # Watch mode development +pnpm test # Run all tests +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1a4e84dc..94862594c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,12 +13,10 @@ This repo is a monorepo using pnpm workspaces. The package manager used to insta - Checkout a topic branch from a base branch (e.g. `main`), and merge back against that branch. - If adding a new feature: - - Add accompanying test case. - Provide a convincing reason to add this feature. Ideally, you should open a suggestion issue first, and have it approved before working on it. - If fixing a bug: - - If you are resolving a special issue, add `(fix #xxxx[,#xxxx])` (#xxxx is the issue id) in your PR title for a better release log (e.g. `fix: update entities encoding/decoding (fix #3899)`). - Provide a detailed description of the bug in the PR. Live demo preferred. - Add appropriate test coverage if applicable. @@ -134,3 +132,7 @@ Some errors are masked and hidden away because of the layers of abstraction and In many test cases, we need to mock dependencies using `link:` and `file:` protocols. `pnpm` treats `link:` as symlinks and `file:` as hardlinks. To test dependencies as if they were copied into `node_modules`, use the `file:` protocol. Otherwise, use the `link:` protocol. For a mock dependency, make sure you add a `@vitejs/test-` prefix to the package name. This will avoid possible issues like false-positive alerts. + +## Contributing to `@vitejs/plugin-rsc` + +See [CONTRIBUTING.md](packages/plugin-rsc/CONTRIBUTING.md) in the `@vitejs/plugin-rsc` package for specific guidelines on contributing to the React Server Components plugin. diff --git a/README.md b/README.md index e8e034627..21c911e9a 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,18 @@ See [`@vitejs/plugin-react` documentation](packages/plugin-react/README.md) and [`@vitejs/plugin-react-swc` documentation](packages/plugin-react-swc/README.md) +# Vite Plugin RSC + +See [`@vitejs/plugin-rsc` documentation](packages/plugin-rsc/README.md) + ## Packages | Package | Version (click for changelogs) | | ----------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------- | | [@vitejs/plugin-react](packages/plugin-react) | [![plugin-react version](https://img.shields.io/npm/v/@vitejs/plugin-react.svg?label=%20)](packages/plugin-react/CHANGELOG.md) | | [@vitejs/plugin-react-swc](packages/plugin-react-swc) | [![plugin-react-swc version](https://img.shields.io/npm/v/@vitejs/plugin-react-swc.svg?label=%20)](packages/plugin-react-swc/CHANGELOG.md) | +| [@vitejs/plugin-rsc](packages/plugin-rsc) | [![plugin-rsc version](https://img.shields.io/npm/v/@vitejs/plugin-rsc.svg?label=%20)](packages/plugin-rsc/CHANGELOG.md) | +| [@vitejs/plugin-react-oxc](packages/plugin-react-oxc) | [Deprecated](packages/plugin-react-oxc/CHANGELOG.md), merged with [`@vitejs/plugin-react`](packages/plugin-react) | ## License diff --git a/eslint.config.js b/eslint.config.js index 4da3ec86b..1b2ff21dc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,7 +9,12 @@ import globals from 'globals' export default tseslint.config( { - ignores: ['**/dist/**', '**/playground-temp/**', '**/temp/**'], + ignores: [ + '**/dist/**', + '**/playground-temp/**', + '**/temp/**', + 'packages/plugin-rsc/**', + ], }, eslint.configs.recommended, ...tseslint.configs.recommended, @@ -127,6 +132,7 @@ export default tseslint.config( { name: 'disables/playground', files: [ + 'packages/**/*.test.?([cm])[jt]s?(x)', 'playground/**/*.?([cm])[jt]s?(x)', 'packages/plugin-react-swc/playground/**/*.?([cm])[jt]s?(x)', ], diff --git a/package.json b/package.json index 4e5a1da38..54a3da1fe 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "private": true, "type": "module", "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, - "packageManager": "pnpm@10.9.0", + "packageManager": "pnpm@10.15.1", "homepage": "https://github.com/vitejs/vite-plugin-react/", "keywords": [ "frontend", @@ -21,37 +21,40 @@ "format": "prettier --write --cache .", "lint": "eslint --cache .", "typecheck": "tsc -p scripts && tsc -p playground && tsc -p packages/plugin-react", - "test": "pnpm run test-serve && pnpm run test-build && pnpm --filter ./packages/plugin-react-swc run test", + "test": "pnpm run test-unit && pnpm run test-serve && pnpm run test-build && pnpm --filter ./packages/plugin-react-swc run test && npm run test-full-bundle-mode", + "test-unit": "pnpm -r --filter='./packages/*' run test-unit", "test-serve": "vitest run -c playground/vitest.config.e2e.ts", + "test-full-bundle-mode": "VITE_TEST_FULL_BUNDLE_MODE=1 vitest run -c playground/vitest.config.e2e.ts", "test-build": "VITE_TEST_BUILD=1 vitest run -c playground/vitest.config.e2e.ts", "debug-serve": "VITE_DEBUG_SERVE=1 vitest run -c playground/vitest.config.e2e.ts", "debug-build": "VITE_TEST_BUILD=1 VITE_PRESERVE_BUILD_ARTIFACTS=1 vitest run -c playground/vitest.config.e2e.ts", + "debug-full-bundle-mode": "VITE_DEBUG_SERVE=1 VITE_PRESERVE_BUILD_ARTIFACTS=1 VITE_TEST_FULL_BUNDLE_MODE=1 vitest run -c playground/vitest.config.e2e.ts", "build": "pnpm -r --filter='./packages/*' run build", "dev": "pnpm -r --parallel --filter='./packages/*' run dev", - "release": "tsx scripts/release.ts", - "ci-publish": "tsx scripts/publishCI.ts" + "release": "node scripts/release.ts", + "ci-publish": "node scripts/publishCI.ts" }, "devDependencies": { - "@eslint/js": "^9.25.1", + "@eslint/js": "^9.35.0", "@types/fs-extra": "^11.0.4", - "@types/node": "^22.15.2", - "@vitejs/release-scripts": "^1.5.0", - "eslint": "^9.25.1", - "eslint-plugin-import-x": "^4.11.0", - "eslint-plugin-n": "^17.17.0", - "eslint-plugin-regexp": "^2.7.0", - "fs-extra": "^11.3.0", - "globals": "^16.0.0", - "lint-staged": "^15.5.1", + "@types/node": "^22.18.1", + "@vitejs/release-scripts": "^1.6.0", + "eslint": "^9.35.0", + "eslint-plugin-import-x": "^4.16.1", + "eslint-plugin-n": "^17.21.3", + "eslint-plugin-regexp": "^2.10.0", + "fs-extra": "^11.3.1", + "globals": "^16.3.0", + "lint-staged": "^16.1.6", "picocolors": "^1.1.1", - "playwright-chromium": "^1.52.0", - "prettier": "^3.0.3", - "simple-git-hooks": "^2.13.0", - "tsx": "^4.19.3", - "typescript": "^5.8.3", - "typescript-eslint": "^8.31.0", - "vite": "^6.3.3", - "vitest": "^3.1.2" + "playwright-chromium": "^1.55.0", + "prettier": "^3.6.2", + "simple-git-hooks": "^2.13.1", + "typescript": "^5.9.2", + "typescript-eslint": "^8.42.0", + "vite": "^7.1.4", + "vite-plugin-inspect": "^11.3.3", + "vitest": "^3.2.4" }, "simple-git-hooks": { "pre-commit": "pnpm exec lint-staged --concurrent false" @@ -69,15 +72,5 @@ "playground/**/__tests__/**/*.ts": [ "eslint --cache --fix" ] - }, - "pnpm": { - "packageExtensions": { - "generouted": { - "peerDependencies": { - "react": "*", - "react-router-dom": "*" - } - } - } } } diff --git a/packages/common/package.json b/packages/common/package.json index dfa9ddf23..1bb806f8c 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -9,8 +9,5 @@ }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" - }, - "devDependencies": { - "vite": "^6.3.3" } } diff --git a/packages/common/refresh-runtime.js b/packages/common/refresh-runtime.js index 08d1df630..e798d000e 100644 --- a/packages/common/refresh-runtime.js +++ b/packages/common/refresh-runtime.js @@ -243,7 +243,7 @@ function performReactRefresh() { } } -function register(type, id) { +export function register(type, id) { if (type === null) { return } @@ -545,14 +545,25 @@ function isLikelyComponentType(type) { } } +function isCompoundComponent(type) { + if (!isPlainObject(type)) return false + for (const key in type) { + if (!isLikelyComponentType(type[key])) return false + } + return true +} + +function isPlainObject(obj) { + return ( + Object.prototype.toString.call(obj) === '[object Object]' && + (obj.constructor === Object || obj.constructor === undefined) + ) +} + /** * Plugin utils */ -export function getRefreshReg(filename) { - return (type, id) => register(type, filename + ' ' + id) -} - // Taken from https://github.com/pmmmwh/react-refresh-webpack-plugin/blob/main/lib/runtime/RefreshUtils.js#L141 // This allows to resister components not detected by SWC like styled component export function registerExportsForReactRefresh(filename, moduleExports) { @@ -565,6 +576,13 @@ export function registerExportsForReactRefresh(filename, moduleExports) { // The register function has an identity check to not register twice the same component, // so this is safe to not used the same key here. register(exportValue, filename + ' export ' + key) + } else if (isCompoundComponent(exportValue)) { + for (const subKey in exportValue) { + register( + exportValue[subKey], + filename + ' export ' + key + '-' + subKey, + ) + } } } } @@ -618,6 +636,7 @@ export function validateRefreshBoundaryAndEnqueueUpdate( (key, value) => { hasExports = true if (isLikelyComponentType(value)) return true + if (isCompoundComponent(value)) return true return prevExports[key] === nextExports[key] }, ) @@ -630,10 +649,7 @@ export function validateRefreshBoundaryAndEnqueueUpdate( function predicateOnExport(ignoredExports, moduleExports, predicate) { for (const key in moduleExports) { - if (key === '__esModule') continue if (ignoredExports.includes(key)) continue - const desc = Object.getOwnPropertyDescriptor(moduleExports, key) - if (desc && desc.get) return key if (!predicate(key, moduleExports[key])) return key } return true diff --git a/packages/common/refresh-utils.ts b/packages/common/refresh-utils.ts index 559cbaa04..cad2d554a 100644 --- a/packages/common/refresh-utils.ts +++ b/packages/common/refresh-utils.ts @@ -1,12 +1,12 @@ export const runtimePublicPath = '/@react-refresh' const reactCompRE = /extends\s+(?:React\.)?(?:Pure)?Component/ -const refreshContentRE = /\$Refresh(?:Reg|Sig)\$\(/ +const refreshContentRE = /\$RefreshReg\$\(/ // NOTE: this is exposed publicly via plugin-react export const preambleCode = `import { injectIntoGlobalHook } from "__BASE__${runtimePublicPath.slice( 1, -)}" +)}"; injectIntoGlobalHook(window); window.$RefreshReg$ = () => {}; window.$RefreshSig$ = () => (type) => type;` @@ -14,33 +14,23 @@ window.$RefreshSig$ = () => (type) => type;` export const getPreambleCode = (base: string): string => preambleCode.replace('__BASE__', base) -export const avoidSourceMapOption = Symbol() - -export function addRefreshWrapper( +export function addRefreshWrapper( code: string, - map: M | string | typeof avoidSourceMapOption, pluginName: string, id: string, reactRefreshHost = '', -): { code: string; map: M | null | string } { +): string | undefined { const hasRefresh = refreshContentRE.test(code) const onlyReactComp = !hasRefresh && reactCompRE.test(code) - const normalizedMap = map === avoidSourceMapOption ? null : map - - if (!hasRefresh && !onlyReactComp) return { code, map: normalizedMap } - const avoidSourceMap = map === avoidSourceMapOption - const newMap = - typeof normalizedMap === 'string' - ? (JSON.parse(normalizedMap) as M) - : normalizedMap + if (!hasRefresh && !onlyReactComp) return undefined let newCode = code - if (hasRefresh) { - const refreshHead = removeLineBreaksIfNeeded( - `let prevRefreshReg; -let prevRefreshSig; + newCode += ` +import * as RefreshRuntime from "${reactRefreshHost}${runtimePublicPath}"; +const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; +import * as __vite_react_currentExports from ${JSON.stringify(id)}; if (import.meta.hot && !inWebWorker) { if (!window.$RefreshReg$) { throw new Error( @@ -48,60 +38,25 @@ if (import.meta.hot && !inWebWorker) { ); } - prevRefreshReg = window.$RefreshReg$; - prevRefreshSig = window.$RefreshSig$; - window.$RefreshReg$ = RefreshRuntime.getRefreshReg(${JSON.stringify(id)}); - window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform; -} - -`, - avoidSourceMap, - ) - - newCode = `${refreshHead}${newCode} - -if (import.meta.hot && !inWebWorker) { - window.$RefreshReg$ = prevRefreshReg; - window.$RefreshSig$ = prevRefreshSig; -} -` - if (newMap) { - newMap.mappings = ';'.repeat(16) + newMap.mappings - } - } - - const sharedHead = removeLineBreaksIfNeeded( - `import * as RefreshRuntime from "${reactRefreshHost}${runtimePublicPath}"; -const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; - -`, - avoidSourceMap, - ) - - newCode = `${sharedHead}${newCode} - -if (import.meta.hot && !inWebWorker) { - RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => { - RefreshRuntime.registerExportsForReactRefresh(${JSON.stringify( + const currentExports = __vite_react_currentExports; + RefreshRuntime.registerExportsForReactRefresh(${JSON.stringify( + id, + )}, currentExports); + import.meta.hot.accept((nextExports) => { + if (!nextExports) return; + const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(${JSON.stringify( id, - )}, currentExports); - import.meta.hot.accept((nextExports) => { - if (!nextExports) return; - const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(${JSON.stringify( - id, - )}, currentExports, nextExports); - if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage); - }); + )}, currentExports, nextExports); + if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage); }); } ` - if (newMap) { - newMap.mappings = ';;;' + newMap.mappings - } - return { code: newCode, map: newMap } -} + if (hasRefresh) { + newCode += `function $RefreshReg$(type, id) { return RefreshRuntime.register(type, ${JSON.stringify(id)} + ' ' + id) } +function $RefreshSig$() { return RefreshRuntime.createSignatureFunctionForTransform(); } +` + } -function removeLineBreaksIfNeeded(code: string, enabled: boolean): string { - return enabled ? code.replace(/\n/g, '') : code + return newCode } diff --git a/packages/plugin-react-oxc/CHANGELOG.md b/packages/plugin-react-oxc/CHANGELOG.md index 1fc7ebfe0..0ff9a9285 100644 --- a/packages/plugin-react-oxc/CHANGELOG.md +++ b/packages/plugin-react-oxc/CHANGELOG.md @@ -2,6 +2,81 @@ ## Unreleased +### Perf: simplify refresh wrapper generation ([#835](https://github.com/vitejs/vite-plugin-react/pull/835)) + +## 0.4.1 (2025-08-19) + +### Set `optimizeDeps.rollupOptions.transform.jsx` instead of `optimizeDeps.rollupOptions.jsx` ([#735](https://github.com/vitejs/vite-plugin-react/pull/735)) + +`optimizeDeps.rollupOptions.jsx` is going to be deprecated in favor of `optimizeDeps.rollupOptions.transform.jsx`. + +## 0.4.0 (2025-08-07) + +## 0.4.0-beta.0 (2025-07-28) + +### Deprecate this plugin + +The changes of this plugin is now included in `@vitejs/plugin-react`. Please use `@vitejs/plugin-react` instead. + +### Allow processing files in `node_modules` + +The default value of `exclude` options is now `[/\/node_modules\//]` to allow processing files in `node_modules` directory. It was previously `[]` and files in `node_modules` was always excluded regardless of the value of `exclude` option. + +### Require Node 20.19+, 22.12+ + +This plugin now requires Node 20.19+ or 22.12+. + +## 0.3.0 (2025-07-18) + +### Add HMR support for compound components ([#518](https://github.com/vitejs/vite-plugin-react/pull/518)) + +HMR now works for compound components like this: + +```tsx +const Root = () =>
Accordion Root
+const Item = () =>
Accordion Item
+ +export const Accordion = { Root, Item } +``` + +### Return `Plugin[]` instead of `PluginOption[]` ([#537](https://github.com/vitejs/vite-plugin-react/pull/537)) + +The return type has changed from `react(): PluginOption[]` to more specialized type `react(): Plugin[]`. This allows for type-safe manipulation of plugins, for example: + +```tsx +// previously this causes type errors +react() + .map(p => ({ ...p, applyToEnvironment: e => e.name === 'client' })) +``` + +## 0.2.3 (2025-06-16) + +### Disable refresh transform when `server.hmr: false` is set [#502](https://github.com/vitejs/vite-plugin-react/pull/502) + +This fixes "`$RefreshReg$` is not defined" error when running Vitest with the plugin. + +## 0.2.2 (2025-06-10) + +### Add Vite 7-beta to peerDependencies range [#497](https://github.com/vitejs/vite-plugin-react/pull/497) + +React plugins are compatible with Vite 7, this removes the warning when testing the beta. + +## 0.2.1 (2025-06-03) + +### Add explicit semicolon in preambleCode [#485](https://github.com/vitejs/vite-plugin-react/pull/485) + +This fixes an edge case when using HTML minifiers that strips line breaks aggressively. + +## 0.2.0 (2025-05-23) + +### Add `filter` for rolldown-vite [#470](https://github.com/vitejs/vite-plugin-react/pull/470) + +Added `filter` so that it is more performant when running this plugin with rolldown-powered version of Vite. + +### Skip HMR for JSX files with hooks [#480](https://github.com/vitejs/vite-plugin-react/pull/480) + +This removes the HMR warning for hooks with JSX. + ## 0.1.1 (2025-04-10) ## 0.1.0 (2025-04-09) diff --git a/packages/plugin-react-oxc/README.md b/packages/plugin-react-oxc/README.md index f717cbf8e..bc6b77646 100644 --- a/packages/plugin-react-oxc/README.md +++ b/packages/plugin-react-oxc/README.md @@ -1,3 +1,6 @@ +> [!IMPORTANT] +> This package is deprecated. Please use [@vitejs/plugin-react](https://www.npmjs.com/package/@vitejs/plugin-react) instead, which automatically enables Oxc-based Fast Refresh transform on [`rolldown-vite`](https://vitejs.dev/guide/rolldown). + # @vitejs/plugin-react-oxc [![npm](https://img.shields.io/npm/v/@vitejs/plugin-react-oxc.svg)](https://npmjs.com/package/@vitejs/plugin-react-oxc) The future default Vite plugin for React projects. @@ -25,7 +28,7 @@ export default defineConfig({ ### include/exclude -Includes `.js`, `.jsx`, `.ts` & `.tsx` by default. This option can be used to add fast refresh to `.mdx` files: +Includes `.js`, `.jsx`, `.ts` & `.tsx` and excludes `/node_modules/` by default. This option can be used to add fast refresh to `.mdx` files: ```js import { defineConfig } from 'vite' @@ -40,8 +43,6 @@ export default defineConfig({ }) ``` -> `node_modules` are never processed by this plugin (but Oxc will) - ### jsxImportSource Control where the JSX factory is imported from. Default to `'react'` diff --git a/packages/plugin-react-oxc/build.config.ts b/packages/plugin-react-oxc/build.config.ts deleted file mode 100644 index 68131222c..000000000 --- a/packages/plugin-react-oxc/build.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineBuildConfig } from 'unbuild' - -export default defineBuildConfig({ - entries: ['src/index'], - externals: ['vite'], - clean: true, - declaration: true, - rollup: { - inlineDependencies: true, - }, - replace: { - 'globalThis.__IS_BUILD__': 'true', - }, -}) diff --git a/packages/plugin-react-oxc/package.json b/packages/plugin-react-oxc/package.json index f31de7df1..b13caa73c 100644 --- a/packages/plugin-react-oxc/package.json +++ b/packages/plugin-react-oxc/package.json @@ -1,6 +1,6 @@ { "name": "@vitejs/plugin-react-oxc", - "version": "0.1.1", + "version": "0.4.1", "license": "MIT", "author": "Evan You", "contributors": [ @@ -20,15 +20,14 @@ "dist" ], "type": "module", - "types": "./dist/index.d.mts", - "exports": "./dist/index.mjs", + "exports": "./dist/index.js", "scripts": { - "dev": "unbuild --stub", - "build": "unbuild && tsx scripts/copyRefreshRuntime.ts", + "dev": "tsdown --watch ./src --watch ../common", + "build": "tsdown", "prepublishOnly": "npm run build" }, "engines": { - "node": ">=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "repository": { "type": "git", @@ -40,11 +39,14 @@ }, "homepage": "https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#readme", "peerDependencies": { - "vite": "^6.3.0" + "vite": "^6.3.0 || ^7.0.0" }, "devDependencies": { "@vitejs/react-common": "workspace:*", - "unbuild": "^3.5.0", + "tsdown": "^0.14.2", "vite": "catalog:rolldown-vite" + }, + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.35" } } diff --git a/packages/plugin-react-oxc/scripts/copyRefreshRuntime.ts b/packages/plugin-react-oxc/scripts/copyRefreshRuntime.ts deleted file mode 100644 index 2666e968e..000000000 --- a/packages/plugin-react-oxc/scripts/copyRefreshRuntime.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { copyFileSync } from 'node:fs' - -copyFileSync( - 'node_modules/@vitejs/react-common/refresh-runtime.js', - 'dist/refresh-runtime.js', -) diff --git a/packages/plugin-react-oxc/src/build.d.ts b/packages/plugin-react-oxc/src/build.d.ts deleted file mode 100644 index 262bd804b..000000000 --- a/packages/plugin-react-oxc/src/build.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare global { - /** replaced by unbuild only in build */ - // eslint-disable-next-line no-var --- top level var has to be var - var __IS_BUILD__: boolean | void -} - -export {} diff --git a/packages/plugin-react-oxc/src/index.ts b/packages/plugin-react-oxc/src/index.ts index 377d4c042..1b6b31909 100644 --- a/packages/plugin-react-oxc/src/index.ts +++ b/packages/plugin-react-oxc/src/index.ts @@ -1,21 +1,17 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { readFileSync } from 'node:fs' -import type { BuildOptions, Plugin, PluginOption } from 'vite' +import type { BuildOptions, Plugin } from 'vite' import { addRefreshWrapper, - avoidSourceMapOption, getPreambleCode, runtimePublicPath, silenceUseClientWarning, } from '@vitejs/react-common' +import { exactRegex } from '@rolldown/pluginutils' const _dirname = dirname(fileURLToPath(import.meta.url)) - -const refreshRuntimePath = globalThis.__IS_BUILD__ - ? join(_dirname, 'refresh-runtime.js') - : // eslint-disable-next-line n/no-unsupported-features/node-builtins -- only used in dev - fileURLToPath(import.meta.resolve('@vitejs/react-common/refresh-runtime')) +const refreshRuntimePath = join(_dirname, 'refresh-runtime.js') export interface Options { include?: string | RegExp | Array @@ -28,17 +24,11 @@ export interface Options { } const defaultIncludeRE = /\.[tj]sx?(?:$|\?)/ +const defaultExcludeRE = /\/node_modules\// -export default function viteReact(opts: Options = {}): PluginOption[] { +export default function viteReact(opts: Options = {}): Plugin[] { const include = opts.include ?? defaultIncludeRE - const exclude = [ - ...(Array.isArray(opts.exclude) - ? opts.exclude - : opts.exclude - ? [opts.exclude] - : []), - /\/node_modules\//, - ] + const exclude = opts.exclude ?? defaultExcludeRE const jsxImportSource = opts.jsxImportSource ?? 'react' const jsxImportRuntime = `${jsxImportSource}/jsx-runtime` @@ -67,10 +57,17 @@ export default function viteReact(opts: Options = {}): PluginOption[] { jsxImportDevRuntime, jsxImportRuntime, ], - rollupOptions: { jsx: { mode: 'automatic' } }, + rollupOptions: { transform: { jsx: { runtime: 'automatic' } } }, }, } }, + configResolved(config) { + config.logger.warn( + '@vitejs/plugin-react-oxc is deprecated. ' + + 'Please use @vitejs/plugin-react instead. ' + + 'The changes of this plugin is now included in @vitejs/plugin-react.', + ) + }, options() { if (!this.meta.rolldownVersion) { throw new Error( @@ -81,6 +78,22 @@ export default function viteReact(opts: Options = {}): PluginOption[] { }, } + const viteConfigPost: Plugin = { + name: 'vite:react-oxc:config-post', + enforce: 'post', + config(userConfig) { + if (userConfig.server?.hmr === false) { + return { + oxc: { + jsx: { + refresh: false, + }, + }, + } + } + }, + } + const viteRefreshRuntime: Plugin = { name: 'vite:react-oxc:refresh-runtime', enforce: 'pre', @@ -102,11 +115,13 @@ export default function viteReact(opts: Options = {}): PluginOption[] { } let skipFastRefresh = false + let base: string const viteRefreshWrapper: Plugin = { name: 'vite:react-oxc:refresh-wrapper', apply: 'serve', configResolved(config) { + base = config.base skipFastRefresh = config.isProduction || config.server.hmr === false }, transform: { @@ -126,35 +141,27 @@ export default function viteReact(opts: Options = {}): PluginOption[] { code.includes(jsxImportRuntime)) if (!useFastRefresh) return - const { code: newCode } = addRefreshWrapper( - code, - avoidSourceMapOption, - '@vitejs/plugin-react-oxc', - id, - ) - return { code: newCode, map: null } + const newCode = addRefreshWrapper(code, '@vitejs/plugin-react-oxc', id) + return newCode ? { code: newCode, map: null } : undefined }, }, - transformIndexHtml(_, config) { - if (!skipFastRefresh) - return [ - { - tag: 'script', - attrs: { type: 'module' }, - children: getPreambleCode(config.server!.config.base), - }, - ] + transformIndexHtml: { + // TODO: maybe we can inject this to entrypoints instead of index.html? + handler() { + if (!skipFastRefresh) + return [ + { + tag: 'script', + attrs: { type: 'module' }, + children: getPreambleCode(base), + }, + ] + }, + // In unbundled mode, Vite transforms any requests. + // But in full bundled mode, Vite only transforms / bundles the scripts injected in `order: 'pre'`. + order: 'pre', }, } - return [viteConfig, viteRefreshRuntime, viteRefreshWrapper] -} - -function exactRegex(input: string): RegExp { - return new RegExp(`^${escapeRegex(input)}$`) -} - -const escapeRegexRE = /[-/\\^$*+?.()|[\]{}]/g -function escapeRegex(str: string): string { - return str.replace(escapeRegexRE, '\\$&') + return [viteConfig, viteConfigPost, viteRefreshRuntime, viteRefreshWrapper] } diff --git a/packages/plugin-react-oxc/tsconfig.json b/packages/plugin-react-oxc/tsconfig.json index e2b17f9c7..70c7eacff 100644 --- a/packages/plugin-react-oxc/tsconfig.json +++ b/packages/plugin-react-oxc/tsconfig.json @@ -1,9 +1,9 @@ { - "include": ["src", "scripts"], + "include": ["src"], "compilerOptions": { "outDir": "dist", - "target": "ES2020", - "module": "ES2020", + "target": "es2023", + "module": "preserve", "moduleResolution": "bundler", "strict": true, "declaration": true, diff --git a/packages/plugin-react-oxc/tsdown.config.ts b/packages/plugin-react-oxc/tsdown.config.ts new file mode 100644 index 000000000..3e38aa5d7 --- /dev/null +++ b/packages/plugin-react-oxc/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: 'src/index.ts', + dts: true, + copy: [ + { + from: 'node_modules/@vitejs/react-common/refresh-runtime.js', + to: 'dist/refresh-runtime.js', + }, + ], +}) diff --git a/packages/plugin-react-swc/CHANGELOG.md b/packages/plugin-react-swc/CHANGELOG.md index 8fb66bc3f..47d1a83f3 100644 --- a/packages/plugin-react-swc/CHANGELOG.md +++ b/packages/plugin-react-swc/CHANGELOG.md @@ -2,6 +2,83 @@ ## Unreleased +### Set SWC cacheRoot options + +This is set to `{viteCacheDir}/swc` and override the default of `.swc`. + +### Perf: simplify refresh wrapper generation ([#835](https://github.com/vitejs/vite-plugin-react/pull/835)) + +## 4.0.1 (2025-08-19) + +### Set `optimizeDeps.rollupOptions.transform.jsx` instead of `optimizeDeps.rollupOptions.jsx` for rolldown-vite ([#735](https://github.com/vitejs/vite-plugin-react/pull/735)) + +`optimizeDeps.rollupOptions.jsx` is going to be deprecated in favor of `optimizeDeps.rollupOptions.transform.jsx`. + +## 4.0.0 (2025-08-07) + +## 4.0.0-beta.0 (2025-07-28) + +### Require Node 20.19+, 22.12+ + +This plugin now requires Node 20.19+ or 22.12+. + +## 3.11.0 (2025-07-18) + +### Add HMR support for compound components ([#518](https://github.com/vitejs/vite-plugin-react/pull/518)) + +HMR now works for compound components like this: + +```tsx +const Root = () =>
Accordion Root
+const Item = () =>
Accordion Item
+ +export const Accordion = { Root, Item } +``` + +### Return `Plugin[]` instead of `PluginOption[]` ([#537](https://github.com/vitejs/vite-plugin-react/pull/537)) + +The return type has changed from `react(): PluginOption[]` to more specialized type `react(): Plugin[]`. This allows for type-safe manipulation of plugins, for example: + +```tsx +// previously this causes type errors +react() + .map(p => ({ ...p, applyToEnvironment: e => e.name === 'client' })) +``` + +## 3.10.2 (2025-06-10) + +### Suggest `@vitejs/plugin-react-oxc` if rolldown-vite is detected [#491](https://github.com/vitejs/vite-plugin-react/pull/491) + +Emit a log which recommends `@vitejs/plugin-react-oxc` when `rolldown-vite` is detected to improve performance and use Oxc under the hood. The warning can be disabled by setting `disableOxcRecommendation: true` in the plugin options. + +### Use `optimizeDeps.rollupOptions` instead of `optimizeDeps.esbuildOptions` for rolldown-vite [#489](https://github.com/vitejs/vite-plugin-react/pull/489) + +This suppresses the warning about `optimizeDeps.esbuildOptions` being deprecated in rolldown-vite. + +### Add Vite 7-beta to peerDependencies range [#497](https://github.com/vitejs/vite-plugin-react/pull/497) + +React plugins are compatible with Vite 7, this removes the warning when testing the beta. + +## 3.10.1 (2025-06-03) + +### Add explicit semicolon in preambleCode [#485](https://github.com/vitejs/vite-plugin-react/pull/485) + +This fixes an edge case when using HTML minifiers that strips line breaks aggressively. + +## 3.10.0 (2025-05-23) + +### Add `filter` for rolldown-vite [#470](https://github.com/vitejs/vite-plugin-react/pull/470) + +Added `filter` so that it is more performant when running this plugin with rolldown-powered version of Vite. + +### Skip HMR preamble in Vitest browser mode [#478](https://github.com/vitejs/vite-plugin-react/pull/478) + +This was causing annoying `Sourcemap for "/@react-refresh" points to missing source files` and is unnecessary in test mode. + +### Skip HMR for JSX files with hooks [#480](https://github.com/vitejs/vite-plugin-react/pull/480) + +This removes the HMR warning for hooks with JSX. + ## 3.9.0 (2025-04-15) ### Make compatible with rolldown-vite diff --git a/packages/plugin-react-swc/README.md b/packages/plugin-react-swc/README.md index 76bd52e6f..c74f11d72 100644 --- a/packages/plugin-react-swc/README.md +++ b/packages/plugin-react-swc/README.md @@ -117,6 +117,14 @@ react({ }) ``` +### disableOxcRecommendation + +If set, disables the recommendation to use `@vitejs/plugin-react-oxc` (which is shown when `rolldown-vite` is detected and neither `swc` plugins are used nor the `swc` options are mutated). + +```ts +react({ disableOxcRecommendation: true }) +``` + ## Consistent components exports For React refresh to work correctly, your file should only export React components. The best explanation I've read is the one from the [Gatsby docs](https://www.gatsbyjs.com/docs/reference/local-development/fast-refresh/#how-it-works). diff --git a/packages/plugin-react-swc/package.json b/packages/plugin-react-swc/package.json index dd1140604..885fcc029 100644 --- a/packages/plugin-react-swc/package.json +++ b/packages/plugin-react-swc/package.json @@ -1,6 +1,6 @@ { "name": "@vitejs/plugin-react-swc", - "version": "3.9.0", + "version": "4.0.1", "license": "MIT", "author": "Arnaud Barré (https://github.com/ArnaudBarre)", "description": "Speed up your Vite dev server with SWC", @@ -15,10 +15,13 @@ "type": "module", "private": true, "scripts": { - "dev": "tsx scripts/bundle.ts --dev", - "build": "tsx scripts/bundle.ts", + "dev": "tsdown --watch ./src --watch ../common", + "build": "tsdown", "test": "playwright test" }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "repository": { "type": "git", "url": "git+https://github.com/vitejs/vite-plugin-react.git", @@ -29,21 +32,20 @@ }, "homepage": "https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-swc#readme", "dependencies": { - "@swc/core": "^1.11.22" + "@rolldown/pluginutils": "1.0.0-beta.35", + "@swc/core": "^1.13.5" }, "peerDependencies": { - "vite": "^4 || ^5 || ^6" + "vite": "^4 || ^5 || ^6 || ^7" }, "devDependencies": { - "@playwright/test": "^1.52.0", + "@playwright/test": "^1.55.0", "@types/fs-extra": "^11.0.4", - "@types/node": "^22.15.2", + "@types/node": "^22.18.1", "@vitejs/react-common": "workspace:*", - "esbuild": "^0.25.3", - "fs-extra": "^11.3.0", - "picocolors": "^1.1.1", + "fs-extra": "^11.3.1", "prettier": "^3.0.3", - "typescript": "^5.8.3", - "vite": "^6.3.3" + "tsdown": "^0.14.2", + "typescript": "^5.9.2" } } diff --git a/packages/plugin-react-swc/playground/base-path/package.json b/packages/plugin-react-swc/playground/base-path/package.json index 5f3aef06d..68aed959a 100644 --- a/packages/plugin-react-swc/playground/base-path/package.json +++ b/packages/plugin-react-swc/playground/base-path/package.json @@ -8,12 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "../../dist" } } diff --git a/packages/plugin-react-swc/playground/class-components/package.json b/packages/plugin-react-swc/playground/class-components/package.json index 29f0133d3..58649398b 100644 --- a/packages/plugin-react-swc/playground/class-components/package.json +++ b/packages/plugin-react-swc/playground/class-components/package.json @@ -8,12 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "../../dist" } } diff --git a/packages/plugin-react-swc/playground/decorators/package.json b/packages/plugin-react-swc/playground/decorators/package.json index 89e37454b..6eb535bbc 100644 --- a/packages/plugin-react-swc/playground/decorators/package.json +++ b/packages/plugin-react-swc/playground/decorators/package.json @@ -8,12 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "../../dist" } } diff --git a/packages/plugin-react-swc/playground/emotion-plugin/package.json b/packages/plugin-react-swc/playground/emotion-plugin/package.json index f9541a0d6..8170dc098 100644 --- a/packages/plugin-react-swc/playground/emotion-plugin/package.json +++ b/packages/plugin-react-swc/playground/emotion-plugin/package.json @@ -9,14 +9,14 @@ }, "dependencies": { "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.0", - "react": "^19.1.0", - "react-dom": "^19.1.0" + "@emotion/styled": "^11.14.1", + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@vitejs/plugin-react-swc": "../../dist", - "@swc/plugin-emotion": "^9.0.3" + "@swc/plugin-emotion": "^11.1.0", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react-swc": "../../dist" } } diff --git a/packages/plugin-react-swc/playground/emotion/package.json b/packages/plugin-react-swc/playground/emotion/package.json index eefab7d2f..504c6b880 100644 --- a/packages/plugin-react-swc/playground/emotion/package.json +++ b/packages/plugin-react-swc/playground/emotion/package.json @@ -9,13 +9,13 @@ }, "dependencies": { "@emotion/react": "^11.14.0", - "@emotion/styled": "^11.14.0", - "react": "^19.1.0", - "react-dom": "^19.1.0" + "@emotion/styled": "^11.14.1", + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "../../dist" } } diff --git a/packages/plugin-react-swc/playground/hmr/package.json b/packages/plugin-react-swc/playground/hmr/package.json index 27f1f7a26..1a00d6cec 100644 --- a/packages/plugin-react-swc/playground/hmr/package.json +++ b/packages/plugin-react-swc/playground/hmr/package.json @@ -8,12 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "../../dist" } } diff --git a/packages/plugin-react-swc/playground/mdx/package.json b/packages/plugin-react-swc/playground/mdx/package.json index 52da5cf9f..21509d58f 100644 --- a/packages/plugin-react-swc/playground/mdx/package.json +++ b/packages/plugin-react-swc/playground/mdx/package.json @@ -8,13 +8,13 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { - "@mdx-js/rollup": "^3.1.0", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@mdx-js/rollup": "^3.1.1", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "../../dist" } } diff --git a/packages/plugin-react-swc/playground/react-18/package.json b/packages/plugin-react-swc/playground/react-18/package.json index cc31a8948..f2fd31476 100644 --- a/packages/plugin-react-swc/playground/react-18/package.json +++ b/packages/plugin-react-swc/playground/react-18/package.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", + "@types/react-dom": "^18.3.7", "@vitejs/plugin-react-swc": "../../dist" } } diff --git a/packages/plugin-react-swc/playground/shadow-export/package.json b/packages/plugin-react-swc/playground/shadow-export/package.json index 9bbc43bca..f316cbddb 100644 --- a/packages/plugin-react-swc/playground/shadow-export/package.json +++ b/packages/plugin-react-swc/playground/shadow-export/package.json @@ -8,12 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "../../dist" } } diff --git a/packages/plugin-react-swc/playground/styled-components/package.json b/packages/plugin-react-swc/playground/styled-components/package.json index ca5c0a80a..4a5eeb7c6 100644 --- a/packages/plugin-react-swc/playground/styled-components/package.json +++ b/packages/plugin-react-swc/playground/styled-components/package.json @@ -8,15 +8,15 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-is": "^19.1.0", - "styled-components": "^6.1.17" + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-is": "^19.1.1", + "styled-components": "^6.1.19" }, "devDependencies": { - "@swc/plugin-styled-components": "^7.1.3", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@swc/plugin-styled-components": "^9.1.0", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@types/styled-components": "^5.1.34", "@vitejs/plugin-react-swc": "../../dist" } diff --git a/packages/plugin-react-swc/playground/ts-lib/__tests__/ts-lib.spec.ts b/packages/plugin-react-swc/playground/ts-lib/__tests__/ts-lib.spec.ts index 44e73fd4d..7f3357908 100644 --- a/packages/plugin-react-swc/playground/ts-lib/__tests__/ts-lib.spec.ts +++ b/packages/plugin-react-swc/playground/ts-lib/__tests__/ts-lib.spec.ts @@ -1,24 +1,22 @@ -import { expect, test } from '@playwright/test' +import { type Page, expect, test } from '@playwright/test' import { setupBuildAndPreview, setupDevServer } from '../../utils.ts' test('TS lib build', async ({ page }) => { const { testUrl, server } = await setupBuildAndPreview('ts-lib') await page.goto(testUrl) - await expect(page.locator('main')).toHaveText('Home page') - - await page.locator('a', { hasText: 'About' }).click() - await expect(page.locator('main')).toHaveText('About page') - + await testNonJs(page) await server.httpServer.close() }) test('TS lib dev', async ({ page }) => { const { testUrl, server } = await setupDevServer('ts-lib') await page.goto(testUrl) - await expect(page.locator('main')).toHaveText('Home page') - - await page.locator('a', { hasText: 'About' }).click() - await expect(page.locator('main')).toHaveText('About page') - + await testNonJs(page) await server.close() }) + +async function testNonJs(page: Page) { + await expect(page.getByTestId('test-non-js')).toHaveText('test-non-js: 0') + await page.getByTestId('test-non-js').click() + await expect(page.getByTestId('test-non-js')).toHaveText('test-non-js: 1') +} diff --git a/packages/plugin-react-swc/playground/ts-lib/package.json b/packages/plugin-react-swc/playground/ts-lib/package.json index ce2efa709..7854a25f7 100644 --- a/packages/plugin-react-swc/playground/ts-lib/package.json +++ b/packages/plugin-react-swc/playground/ts-lib/package.json @@ -8,15 +8,13 @@ "preview": "vite preview" }, "dependencies": { - "@generouted/react-router": "^1.20.0", - "generouted": "1.11.7", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-router-dom": "^7.5.2" + "@vitejs/test-dep-non-js": "file:./test-dep/non-js", + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "../../dist" } } diff --git a/packages/plugin-react-swc/playground/ts-lib/src/app.tsx b/packages/plugin-react-swc/playground/ts-lib/src/app.tsx new file mode 100644 index 000000000..fc8ba7dfa --- /dev/null +++ b/packages/plugin-react-swc/playground/ts-lib/src/app.tsx @@ -0,0 +1,11 @@ +import TestNonJs from '@vitejs/test-dep-non-js' + +export default function App() { + return ( +
+
+ +
+
+ ) +} diff --git a/packages/plugin-react-swc/playground/ts-lib/src/index.tsx b/packages/plugin-react-swc/playground/ts-lib/src/index.tsx index 991484efc..c0e262737 100644 --- a/packages/plugin-react-swc/playground/ts-lib/src/index.tsx +++ b/packages/plugin-react-swc/playground/ts-lib/src/index.tsx @@ -1,9 +1,9 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { Routes } from 'generouted/react-router' +import App from './app' createRoot(document.getElementById('root')!).render( - + , ) diff --git a/packages/plugin-react-swc/playground/ts-lib/src/pages/404.tsx b/packages/plugin-react-swc/playground/ts-lib/src/pages/404.tsx deleted file mode 100644 index 6e9fc906c..000000000 --- a/packages/plugin-react-swc/playground/ts-lib/src/pages/404.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function NotFound() { - return

404

-} diff --git a/packages/plugin-react-swc/playground/ts-lib/src/pages/_app.tsx b/packages/plugin-react-swc/playground/ts-lib/src/pages/_app.tsx deleted file mode 100644 index 977445f78..000000000 --- a/packages/plugin-react-swc/playground/ts-lib/src/pages/_app.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Link, Outlet } from 'react-router-dom' - -export default function App() { - return ( -
-
- Home - About -
- -
- -
-
- ) -} diff --git a/packages/plugin-react-swc/playground/ts-lib/src/pages/about.tsx b/packages/plugin-react-swc/playground/ts-lib/src/pages/about.tsx deleted file mode 100644 index 5e6d93641..000000000 --- a/packages/plugin-react-swc/playground/ts-lib/src/pages/about.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function About() { - return

About page

-} diff --git a/packages/plugin-react-swc/playground/ts-lib/src/pages/index.tsx b/packages/plugin-react-swc/playground/ts-lib/src/pages/index.tsx deleted file mode 100644 index 342887999..000000000 --- a/packages/plugin-react-swc/playground/ts-lib/src/pages/index.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Outlet } from 'react-router-dom' - -export default function Home() { - return ( -
-

Home page

- -
- ) -} diff --git a/packages/plugin-react-swc/playground/ts-lib/test-dep/non-js/index.tsx b/packages/plugin-react-swc/playground/ts-lib/test-dep/non-js/index.tsx new file mode 100644 index 000000000..c3603614c --- /dev/null +++ b/packages/plugin-react-swc/playground/ts-lib/test-dep/non-js/index.tsx @@ -0,0 +1,10 @@ +import React from 'react' + +export default function TestNonJs() { + const [count, setCount] = React.useState(0) + return ( + + ) +} diff --git a/packages/plugin-react-swc/playground/ts-lib/test-dep/non-js/package.json b/packages/plugin-react-swc/playground/ts-lib/test-dep/non-js/package.json new file mode 100644 index 000000000..6e098895e --- /dev/null +++ b/packages/plugin-react-swc/playground/ts-lib/test-dep/non-js/package.json @@ -0,0 +1,9 @@ +{ + "name": "@vitejs/test-dep-non-js", + "private": true, + "type": "module", + "exports": "./index.tsx", + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/plugin-react-swc/playground/ts-lib/vite.config.ts b/packages/plugin-react-swc/playground/ts-lib/vite.config.ts index b9f401547..7af9f6f97 100644 --- a/packages/plugin-react-swc/playground/ts-lib/vite.config.ts +++ b/packages/plugin-react-swc/playground/ts-lib/vite.config.ts @@ -2,6 +2,5 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react-swc' export default defineConfig({ - optimizeDeps: { include: ['react-router-dom'] }, plugins: [react()], }) diff --git a/packages/plugin-react-swc/playground/worker/package.json b/packages/plugin-react-swc/playground/worker/package.json index 6d4f16e2a..0f6073411 100644 --- a/packages/plugin-react-swc/playground/worker/package.json +++ b/packages/plugin-react-swc/playground/worker/package.json @@ -8,12 +8,12 @@ "preview": "vite preview" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0" + "react": "^19.1.1", + "react-dom": "^19.1.1" }, "devDependencies": { - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", "@vitejs/plugin-react-swc": "../../dist" } } diff --git a/packages/plugin-react-swc/scripts/bundle.ts b/packages/plugin-react-swc/scripts/bundle.ts deleted file mode 100644 index dd3c63707..000000000 --- a/packages/plugin-react-swc/scripts/bundle.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { copyFileSync, rmSync, writeFileSync } from 'node:fs' -import { execSync } from 'node:child_process' -import { type BuildOptions, build, context } from 'esbuild' - -import packageJSON from '../package.json' - -const dev = process.argv.includes('--dev') - -rmSync('dist', { force: true, recursive: true }) - -const serverOptions: BuildOptions = { - bundle: true, - platform: 'node', - target: 'node14', - legalComments: 'inline', - external: Object.keys(packageJSON.peerDependencies).concat( - Object.keys(packageJSON.dependencies), - ), -} - -const buildOrWatch = async (options: BuildOptions) => { - if (!dev) return build(options) - const ctx = await context(options) - await ctx.watch() - await ctx.rebuild() -} - -Promise.all([ - buildOrWatch({ - entryPoints: ['@vitejs/react-common/refresh-runtime'], - outdir: 'dist', - platform: 'browser', - format: 'esm', - target: 'safari13', - legalComments: 'inline', - }), - buildOrWatch({ - ...serverOptions, - stdin: { - contents: `import react from "./src"; -module.exports = react; -// For backward compatibility with the first broken version -module.exports.default = react;`, - resolveDir: '.', - }, - outfile: 'dist/index.cjs', - logOverride: { 'empty-import-meta': 'silent' }, - }), - buildOrWatch({ - ...serverOptions, - entryPoints: ['src/index.ts'], - format: 'esm', - outfile: 'dist/index.mjs', - }), -]).then(() => { - copyFileSync('LICENSE', 'dist/LICENSE') - copyFileSync('README.md', 'dist/README.md') - - execSync( - 'tsc src/index.ts --declaration --isolatedDeclarations --noCheck --emitDeclarationOnly --outDir dist --target es2020 --module es2020 --moduleResolution bundler', - { stdio: 'inherit' }, - ) - - writeFileSync( - 'dist/package.json', - JSON.stringify( - { - ...Object.fromEntries( - Object.entries(packageJSON).filter( - ([key, _val]) => - key !== 'devDependencies' && - key !== 'scripts' && - key !== 'private', - ), - ), - main: 'index.cjs', - types: 'index.d.ts', - module: 'index.mjs', - exports: { - '.': { - types: './index.d.ts', - require: './index.cjs', - import: './index.mjs', - }, - }, - }, - null, - 2, - ), - ) -}) diff --git a/packages/plugin-react-swc/src/index.ts b/packages/plugin-react-swc/src/index.ts index 8e197588e..7de28a248 100644 --- a/packages/plugin-react-swc/src/index.ts +++ b/packages/plugin-react-swc/src/index.ts @@ -1,7 +1,6 @@ import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { SourceMapPayload } from 'node:module' import { createRequire } from 'node:module' import { type JscTarget, @@ -11,13 +10,15 @@ import { type Options as SWCOptions, transform, } from '@swc/core' -import type { PluginOption } from 'vite' +import type { Plugin } from 'vite' import { addRefreshWrapper, getPreambleCode, runtimePublicPath, silenceUseClientWarning, } from '@vitejs/react-common' +import * as vite from 'vite' +import { exactRegex } from '@rolldown/pluginutils' /* eslint-disable no-restricted-globals */ const _dirname = @@ -74,10 +75,17 @@ type Options = { * feature doesn't work is not fun, so we won't provide support for it, hence the name `useAtYourOwnRisk` */ useAtYourOwnRisk_mutateSwcOptions?: (options: SWCOptions) => void + + /** + * If set, disables the recommendation to use `@vitejs/plugin-react` + */ + disableOxcRecommendation?: boolean } -const react = (_options?: Options): PluginOption[] => { +const react = (_options?: Options): Plugin[] => { let hmrDisabled = false + let base: string + let viteCacheRoot: string | undefined const options = { jsxImportSource: _options?.jsxImportSource ?? 'react', tsDecorators: _options?.tsDecorators, @@ -89,6 +97,7 @@ const react = (_options?: Options): PluginOption[] => { reactRefreshHost: _options?.reactRefreshHost, useAtYourOwnRisk_mutateSwcOptions: _options?.useAtYourOwnRisk_mutateSwcOptions, + disableOxcRecommendation: _options?.disableOxcRecommendation, } return [ @@ -96,14 +105,23 @@ const react = (_options?: Options): PluginOption[] => { name: 'vite:react-swc:resolve-runtime', apply: 'serve', enforce: 'pre', // Run before Vite default resolve to avoid syscalls - resolveId: (id) => (id === runtimePublicPath ? id : undefined), - load: (id) => - id === runtimePublicPath - ? readFileSync(join(_dirname, 'refresh-runtime.js'), 'utf-8').replace( - /__README_URL__/g, - 'https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-swc', - ) - : undefined, + resolveId: { + filter: { id: exactRegex(runtimePublicPath) }, + handler: (id) => (id === runtimePublicPath ? id : undefined), + }, + load: { + filter: { id: exactRegex(runtimePublicPath) }, + handler: (id) => + id === runtimePublicPath + ? readFileSync( + join(_dirname, 'refresh-runtime.js'), + 'utf-8', + ).replace( + /__README_URL__/g, + 'https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-swc', + ) + : undefined, + }, }, { name: 'vite:react-swc', @@ -114,11 +132,18 @@ const react = (_options?: Options): PluginOption[] => { oxc: false, optimizeDeps: { include: [`${options.jsxImportSource}/jsx-dev-runtime`], - esbuildOptions: { jsx: 'automatic' }, + ...('rolldownVersion' in vite + ? { + rollupOptions: { transform: { jsx: { runtime: 'automatic' } } }, + } + : { esbuildOptions: { jsx: 'automatic' } }), }, }), configResolved(config) { + base = config.base + viteCacheRoot = config.cacheDir if (config.server.hmr === false) hmrDisabled = true + const mdxIndex = config.plugins.findIndex( (p) => p.name === '@mdx-js/rollup', ) @@ -131,14 +156,34 @@ const react = (_options?: Options): PluginOption[] => { '[vite:react-swc] The MDX plugin should be placed before this plugin', ) } + + if ( + 'rolldownVersion' in vite && + !options.plugins && + !options.useAtYourOwnRisk_mutateSwcOptions && + !options.disableOxcRecommendation + ) { + config.logger.warn( + '[vite:react-swc] We recommend switching to `@vitejs/plugin-react` for improved performance as no swc plugins are used. More information at https://vite.dev/rolldown', + ) + } }, - transformIndexHtml: (_, config) => [ - { - tag: 'script', - attrs: { type: 'module' }, - children: getPreambleCode(config.server!.config.base), + transformIndexHtml: { + // TODO: maybe we can inject this to entrypoints instead of index.html? + handler() { + if (!hmrDisabled) + return [ + { + tag: 'script', + attrs: { type: 'module' }, + children: getPreambleCode(base), + }, + ] }, - ], + // In unbundled mode, Vite transforms any requests. + // But in full bundled mode, Vite only transforms / bundles the scripts injected in `order: 'pre'`. + order: 'pre', + }, async transform(code, _id, transformOptions) { const id = _id.split('?')[0] const refresh = !transformOptions?.ssr && !hmrDisabled @@ -148,6 +193,7 @@ const react = (_options?: Options): PluginOption[] => { code, options.devTarget, options, + viteCacheRoot, { refresh, development: true, @@ -158,13 +204,13 @@ const react = (_options?: Options): PluginOption[] => { if (!result) return if (!refresh) return result - return addRefreshWrapper( + const newCode = addRefreshWrapper( result.code, - result.map!, '@vitejs/plugin-react-swc', id, options.reactRefreshHost, ) + return { code: newCode ?? result.code, map: result.map } }, }, options.plugins @@ -175,11 +221,21 @@ const react = (_options?: Options): PluginOption[] => { config: (userConfig) => ({ build: silenceUseClientWarning(userConfig), }), + configResolved(config) { + viteCacheRoot = config.cacheDir + }, transform: (code, _id) => - transformWithOptions(_id.split('?')[0], code, 'esnext', options, { - runtime: 'automatic', - importSource: options.jsxImportSource, - }), + transformWithOptions( + _id.split('?')[0], + code, + 'esnext', + options, + viteCacheRoot, + { + runtime: 'automatic', + importSource: options.jsxImportSource, + }, + ), } : { name: 'vite:react-swc', @@ -194,6 +250,9 @@ const react = (_options?: Options): PluginOption[] => { }, }, }), + configResolved(config) { + viteCacheRoot = config.cacheDir + }, }, ] } @@ -203,6 +262,7 @@ const transformWithOptions = async ( code: string, target: JscTarget, options: Options, + viteCacheRoot: string | undefined, reactConfig: ReactConfig, ) => { const decorators = options?.tsDecorators ?? false @@ -230,7 +290,10 @@ const transformWithOptions = async ( jsc: { target, parser, - experimental: { plugins: options.plugins }, + experimental: { + plugins: options.plugins, + cacheRoot: join(viteCacheRoot ?? 'node_modules/.vite', '.swc'), + }, transform: { useDefineForClassFields: true, react: reactConfig, @@ -258,3 +321,12 @@ const transformWithOptions = async ( } export default react + +// Compat for require +function pluginForCjs(this: unknown, options: Options): Plugin[] { + return react.call(this, options) +} +Object.assign(pluginForCjs, { + default: pluginForCjs, +}) +export { pluginForCjs as 'module.exports' } diff --git a/packages/plugin-react-swc/tsconfig.json b/packages/plugin-react-swc/tsconfig.json index fb429cf57..ac687cd46 100644 --- a/packages/plugin-react-swc/tsconfig.json +++ b/packages/plugin-react-swc/tsconfig.json @@ -1,31 +1,7 @@ { - "include": [ - "src", - "scripts", - "playwright.config.ts", - "playground/utils.ts", - "playground/*/__tests__" - ], - "compilerOptions": { - /* Target node 22 */ - "module": "ESNext", - "lib": ["ES2023", "DOM"], - "target": "ES2023", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "useUnknownInCatchVariables": true, - "noUncheckedSideEffectImports": true, - "noPropertyAccessFromIndexSignature": true - } + "include": [], + "references": [ + { "path": "./tsconfig.src.json" }, + { "path": "./tsconfig.test.json" } + ] } diff --git a/packages/plugin-react-swc/tsconfig.src.json b/packages/plugin-react-swc/tsconfig.src.json new file mode 100644 index 000000000..4194367e6 --- /dev/null +++ b/packages/plugin-react-swc/tsconfig.src.json @@ -0,0 +1,27 @@ +{ + "include": ["src"], + "compilerOptions": { + /* Target node 22 */ + "module": "ESNext", + "lib": ["ES2023", "DOM"], + "target": "ES2023", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "declaration": true, + "isolatedDeclarations": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true, + "noUncheckedSideEffectImports": true, + "noPropertyAccessFromIndexSignature": true + } +} diff --git a/packages/plugin-react-swc/tsconfig.test.json b/packages/plugin-react-swc/tsconfig.test.json new file mode 100644 index 000000000..8bc9cb430 --- /dev/null +++ b/packages/plugin-react-swc/tsconfig.test.json @@ -0,0 +1,25 @@ +{ + "include": ["playwright.config.ts", "playground"], + "compilerOptions": { + /* Target node 22 */ + "module": "ESNext", + "lib": ["ES2023", "DOM"], + "target": "ES2023", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true, + "noUncheckedSideEffectImports": true, + "noPropertyAccessFromIndexSignature": true + } +} diff --git a/packages/plugin-react-swc/tsdown.config.ts b/packages/plugin-react-swc/tsdown.config.ts new file mode 100644 index 000000000..354d5f2e6 --- /dev/null +++ b/packages/plugin-react-swc/tsdown.config.ts @@ -0,0 +1,44 @@ +import { writeFileSync } from 'node:fs' +import { defineConfig } from 'tsdown' +import packageJSON from './package.json' with { type: 'json' } + +export default defineConfig({ + entry: 'src/index.ts', + dts: true, + tsconfig: './tsconfig.src.json', // https://github.com/sxzz/rolldown-plugin-dts/issues/55 + ignoreWatch: ['playground', 'playground-temp', 'test-results'], + copy: [ + { + from: 'node_modules/@vitejs/react-common/refresh-runtime.js', + to: 'dist/refresh-runtime.js', + }, + { + from: 'LICENSE', + to: 'dist/LICENSE', + }, + { + from: 'README.md', + to: 'dist/README.md', + }, + ], + onSuccess() { + writeFileSync( + 'dist/package.json', + JSON.stringify( + { + ...Object.fromEntries( + Object.entries(packageJSON).filter( + ([key, _val]) => + key !== 'devDependencies' && + key !== 'scripts' && + key !== 'private', + ), + ), + exports: './index.js', + }, + null, + 2, + ), + ) + }, +}) diff --git a/packages/plugin-react/CHANGELOG.md b/packages/plugin-react/CHANGELOG.md index 86c6efd09..466bcca61 100644 --- a/packages/plugin-react/CHANGELOG.md +++ b/packages/plugin-react/CHANGELOG.md @@ -2,6 +2,117 @@ ## Unreleased +### Perf: simplify refresh wrapper generation ([#835](https://github.com/vitejs/vite-plugin-react/pull/835)) + +## 5.0.2 (2025-08-28) + +### Skip transform hook completely in rolldown-vite in dev if possible ([#783](https://github.com/vitejs/vite-plugin-react/pull/783)) + +## 5.0.1 (2025-08-19) + +### Set `optimizeDeps.rollupOptions.transform.jsx` instead of `optimizeDeps.rollupOptions.jsx` for rolldown-vite ([#735](https://github.com/vitejs/vite-plugin-react/pull/735)) + +`optimizeDeps.rollupOptions.jsx` is going to be deprecated in favor of `optimizeDeps.rollupOptions.transform.jsx`. + +### Perf: skip `babel-plugin-react-compiler` if code has no `"use memo"` when `{ compilationMode: "annotation" }` ([#734](https://github.com/vitejs/vite-plugin-react/pull/734)) + +### Respect tsconfig `jsxImportSource` ([#726](https://github.com/vitejs/vite-plugin-react/pull/726)) + +### Fix `reactRefreshHost` option on rolldown-vite ([#716](https://github.com/vitejs/vite-plugin-react/pull/716)) + +### Fix `RefreshRuntime` being injected twice for class components on rolldown-vite ([#708](https://github.com/vitejs/vite-plugin-react/pull/708)) + +### Skip `babel-plugin-react-compiler` on non client environment ([689](https://github.com/vitejs/vite-plugin-react/pull/689)) + +## 5.0.0 (2025-08-07) + +## 5.0.0-beta.0 (2025-07-28) + +### Use Oxc for react refresh transform in rolldown-vite + +When used with rolldown-vite, this plugin now uses Oxc for react refresh transform. + +Since this behavior is what `@vitejs/plugin-react-oxc` did, `@vitejs/plugin-react-oxc` is now deprecated and the `disableOxcRecommendation` option is removed. + +Also, while `@vitejs/plugin-react-oxc` used the production JSX transform even for `NODE_ENV=development` build, `@vitejs/plugin-react` uses the development JSX transform for `NODE_ENV=development` build. + +### Allow processing files in `node_modules` + +The default value of `exclude` options is now `[/\/node_modules\//]` to allow processing files in `node_modules` directory. It was previously `[]` and files in `node_modules` was always excluded regardless of the value of `exclude` option. + +### `react` and `react-dom` is no longer added to [`resolve.dedupe`](https://vite.dev/config/#resolve-dedupe) automatically + +Adding values to `resolve.dedupe` forces Vite to resolve them differently from how Node.js does, which can be confusing and may not be expected. This plugin no longer adds `react` and `react-dom` to `resolve.dedupe` automatically. + +If you encounter errors after upgrading, check your package.json for version mismatches in `dependencies` or `devDependencies`, as well as your package manager’s configuration. If you prefer the previous behavior, you can manually add `react` and `react-dom` to `resolve.dedupe`. + +### Remove old `babel-plugin-react-compiler` support that requires `runtimeModule` option + +`runtimeModule` option is no longer needed in newer `babel-plugin-react-compiler` versions. Make sure to use a newer version of `babel-plugin-react-compiler` that supports `target` option. + +### Require Node 20.19+, 22.12+ + +This plugin now requires Node 20.19+ or 22.12+. + +## 4.7.0 (2025-07-18) + +### Add HMR support for compound components ([#518](https://github.com/vitejs/vite-plugin-react/pull/518)) + +HMR now works for compound components like this: + +```tsx +const Root = () =>
Accordion Root
+const Item = () =>
Accordion Item
+ +export const Accordion = { Root, Item } +``` + +### Return `Plugin[]` instead of `PluginOption[]` ([#537](https://github.com/vitejs/vite-plugin-react/pull/537)) + +The return type has changed from `react(): PluginOption[]` to more specialized type `react(): Plugin[]`. This allows for type-safe manipulation of plugins, for example: + +```tsx +// previously this causes type errors +react({ babel: { plugins: ['babel-plugin-react-compiler'] } }) + .map(p => ({ ...p, applyToEnvironment: e => e.name === 'client' })) +``` + +## 4.6.0 (2025-06-23) + +### Add raw Rolldown support + +This plugin only worked with Vite. But now it can also be used with raw Rolldown. The main purpose for using this plugin with Rolldown is to use react compiler. + +## 4.5.2 (2025-06-10) + +### Suggest `@vitejs/plugin-react-oxc` if rolldown-vite is detected [#491](https://github.com/vitejs/vite-plugin-react/pull/491) + +Emit a log which recommends `@vitejs/plugin-react-oxc` when `rolldown-vite` is detected to improve performance and use Oxc under the hood. The warning can be disabled by setting `disableOxcRecommendation: true` in the plugin options. + +### Use `optimizeDeps.rollupOptions` instead of `optimizeDeps.esbuildOptions` for rolldown-vite [#489](https://github.com/vitejs/vite-plugin-react/pull/489) + +This suppresses the warning about `optimizeDeps.esbuildOptions` being deprecated in rolldown-vite. + +### Add Vite 7-beta to peerDependencies range [#497](https://github.com/vitejs/vite-plugin-react/pull/497) + +React plugins are compatible with Vite 7, this removes the warning when testing the beta. + +## 4.5.1 (2025-06-03) + +### Add explicit semicolon in preambleCode [#485](https://github.com/vitejs/vite-plugin-react/pull/485) + +This fixes an edge case when using HTML minifiers that strips line breaks aggressively. + +## 4.5.0 (2025-05-23) + +### Add `filter` for rolldown-vite [#470](https://github.com/vitejs/vite-plugin-react/pull/470) + +Added `filter` so that it is more performant when running this plugin with rolldown-powered version of Vite. + +### Skip HMR for JSX files with hooks [#480](https://github.com/vitejs/vite-plugin-react/pull/480) + +This removes the HMR warning for hooks with JSX. + ## 4.4.1 (2025-04-19) Fix type issue when using `moduleResolution: "node"` in tsconfig [#462](https://github.com/vitejs/vite-plugin-react/pull/462) diff --git a/packages/plugin-react/README.md b/packages/plugin-react/README.md index 7eaa29ab4..b7ee2f3f7 100644 --- a/packages/plugin-react/README.md +++ b/packages/plugin-react/README.md @@ -21,7 +21,7 @@ export default defineConfig({ ### include/exclude -Includes `.js`, `.jsx`, `.ts` & `.tsx` by default. This option can be used to add fast refresh to `.mdx` files: +Includes `.js`, `.jsx`, `.ts` & `.tsx` and excludes `/node_modules/` by default. This option can be used to add fast refresh to `.mdx` files: ```js import { defineConfig } from 'vite' @@ -36,11 +36,9 @@ export default defineConfig({ }) ``` -> `node_modules` are never processed by this plugin (but esbuild will) - ### jsxImportSource -Control where the JSX factory is imported from. Default to `'react'` +Control where the JSX factory is imported from. By default, this is inferred from `jsxImportSource` from corresponding a tsconfig file for a transformed file. ```js react({ jsxImportSource: '@emotion/react' }) @@ -129,6 +127,10 @@ Otherwise, you'll probably get this error: Uncaught Error: @vitejs/plugin-react can't detect preamble. Something is wrong. ``` +### disableOxcRecommendation + +If set, disables the recommendation to use `@vitejs/plugin-react-oxc` (which is shown when `rolldown-vite` is detected and `babel` is not configured). + ## Consistent components exports For React refresh to work correctly, your file should only export React components. You can find a good explanation in the [Gatsby docs](https://www.gatsbyjs.com/docs/reference/local-development/fast-refresh/#how-it-works). diff --git a/packages/plugin-react/build.config.ts b/packages/plugin-react/build.config.ts deleted file mode 100644 index 98285c8d0..000000000 --- a/packages/plugin-react/build.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineBuildConfig } from 'unbuild' - -export default defineBuildConfig({ - entries: ['src/index'], - externals: ['vite'], - clean: true, - declaration: true, - rollup: { - emitCJS: true, - inlineDependencies: true, - }, - replace: { - 'globalThis.__IS_BUILD__': 'true', - }, -}) diff --git a/packages/plugin-react/package.json b/packages/plugin-react/package.json index e893b948b..98eb1fc6f 100644 --- a/packages/plugin-react/package.json +++ b/packages/plugin-react/package.json @@ -1,6 +1,6 @@ { "name": "@vitejs/plugin-react", - "version": "4.4.1", + "version": "5.0.2", "license": "MIT", "author": "Evan You", "description": "The default Vite plugin for React projects", @@ -20,23 +20,15 @@ "dist" ], "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.mts", - "exports": { - ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.cjs" - } - }, + "exports": "./dist/index.js", "scripts": { - "dev": "unbuild --stub", - "build": "unbuild && pnpm run patch-cjs && tsx scripts/copyRefreshRuntime.ts", - "patch-cjs": "tsx ../../scripts/patchCJS.ts", - "prepublishOnly": "npm run build" + "dev": "tsdown --watch ./src --watch ../common", + "build": "tsdown", + "prepublishOnly": "npm run build", + "test-unit": "vitest run" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "repository": { "type": "git", @@ -48,17 +40,23 @@ }, "homepage": "https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#readme", "dependencies": { - "@babel/core": "^7.26.10", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.35", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "devDependencies": { "@vitejs/react-common": "workspace:*", - "unbuild": "^3.5.0" + "babel-plugin-react-compiler": "19.1.0-rc.3", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "rolldown": "1.0.0-beta.35", + "tsdown": "^0.14.2", + "vitest": "^3.2.4" } } diff --git a/packages/plugin-react/scripts/copyRefreshRuntime.ts b/packages/plugin-react/scripts/copyRefreshRuntime.ts deleted file mode 100644 index 2666e968e..000000000 --- a/packages/plugin-react/scripts/copyRefreshRuntime.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { copyFileSync } from 'node:fs' - -copyFileSync( - 'node_modules/@vitejs/react-common/refresh-runtime.js', - 'dist/refresh-runtime.js', -) diff --git a/packages/plugin-react/src/build.d.ts b/packages/plugin-react/src/build.d.ts deleted file mode 100644 index 262bd804b..000000000 --- a/packages/plugin-react/src/build.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare global { - /** replaced by unbuild only in build */ - // eslint-disable-next-line no-var --- top level var has to be var - var __IS_BUILD__: boolean | void -} - -export {} diff --git a/packages/plugin-react/src/index.ts b/packages/plugin-react/src/index.ts index 66d280f93..bed627738 100644 --- a/packages/plugin-react/src/index.ts +++ b/packages/plugin-react/src/index.ts @@ -5,7 +5,7 @@ import type * as babelCore from '@babel/core' import type { ParserOptions, TransformOptions } from '@babel/core' import { createFilter } from 'vite' import * as vite from 'vite' -import type { Plugin, PluginOption, ResolvedConfig } from 'vite' +import type { Plugin, ResolvedConfig } from 'vite' import { addRefreshWrapper, getPreambleCode, @@ -13,13 +13,13 @@ import { runtimePublicPath, silenceUseClientWarning, } from '@vitejs/react-common' +import { + exactRegex, + makeIdFiltersToMatchWithQuery, +} from '@rolldown/pluginutils' const _dirname = dirname(fileURLToPath(import.meta.url)) - -const refreshRuntimePath = globalThis.__IS_BUILD__ - ? join(_dirname, 'refresh-runtime.js') - : // eslint-disable-next-line n/no-unsupported-features/node-builtins -- only used in dev - fileURLToPath(import.meta.resolve('@vitejs/react-common/refresh-runtime')) +const refreshRuntimePath = join(_dirname, 'refresh-runtime.js') // lazy load babel since it's not used during build if plugins are not used let babel: typeof babelCore | undefined @@ -99,21 +99,29 @@ export type ViteReactPluginApi = { } const defaultIncludeRE = /\.[tj]sx?$/ +const defaultExcludeRE = /\/node_modules\// const tsRE = /\.tsx?$/ +const compilerAnnotationRE = /['"]use memo['"]/ + +export default function viteReact(opts: Options = {}): Plugin[] { + const include = opts.include ?? defaultIncludeRE + const exclude = opts.exclude ?? defaultExcludeRE + const filter = createFilter(include, exclude) -export default function viteReact(opts: Options = {}): PluginOption[] { - const filter = createFilter(opts.include ?? defaultIncludeRE, opts.exclude) const jsxImportSource = opts.jsxImportSource ?? 'react' const jsxImportRuntime = `${jsxImportSource}/jsx-runtime` const jsxImportDevRuntime = `${jsxImportSource}/jsx-dev-runtime` + + const isRolldownVite = 'rolldownVersion' in vite + let runningInVite = false let isProduction = true let projectRoot = process.cwd() - let skipFastRefresh = false + let skipFastRefresh = true + let base: string let runPluginOverrides: | ((options: ReactBabelOptions, context: ReactBabelHookContext) => void) | undefined let staticBabelOptions: ReactBabelOptions | undefined - // Support patterns like: // - import * as React from 'react'; // - import React from 'react'; @@ -123,31 +131,52 @@ export default function viteReact(opts: Options = {}): PluginOption[] { const viteBabel: Plugin = { name: 'vite:react-babel', enforce: 'pre', - config() { - if (opts.jsxRuntime === 'classic') { - if ('rolldownVersion' in vite) { + config(_userConfig, { command }) { + if ('rolldownVersion' in vite) { + if (opts.jsxRuntime === 'classic') { return { oxc: { jsx: { runtime: 'classic', + refresh: command === 'serve', // disable __self and __source injection even in dev // as this plugin injects them by babel and oxc will throw // if development is enabled and those properties are already present development: false, }, + jsxRefreshInclude: include, + jsxRefreshExclude: exclude, }, } } else { return { - esbuild: { - jsx: 'transform', + oxc: { + jsx: { + runtime: 'automatic', + importSource: opts.jsxImportSource, + refresh: command === 'serve', + }, + jsxRefreshInclude: include, + jsxRefreshExclude: exclude, + }, + optimizeDeps: { + rollupOptions: { transform: { jsx: { runtime: 'automatic' } } }, }, } } + } + + if (opts.jsxRuntime === 'classic') { + return { + esbuild: { + jsx: 'transform', + }, + } } else { return { esbuild: { jsx: 'automatic', + // keep undefined by default so that vite's esbuild transform can prioritize jsxImportSource from tsconfig jsxImportSource: opts.jsxImportSource, }, optimizeDeps: { esbuildOptions: { jsx: 'automatic' } }, @@ -155,6 +184,8 @@ export default function viteReact(opts: Options = {}): PluginOption[] { } }, configResolved(config) { + base = config.base + runningInVite = true projectRoot = config.root isProduction = config.isProduction skipFastRefresh = @@ -162,12 +193,6 @@ export default function viteReact(opts: Options = {}): PluginOption[] { config.command === 'build' || config.server.hmr === false - if ('jsxPure' in opts) { - config.logger.warnOnce( - '[@vitejs/plugin-react] jsxPure was removed. You can configure esbuild.jsxSideEffects directly.', - ) - } - const hooks: ReactBabelHook[] = config.plugins .map((plugin) => plugin.api?.reactBabel) .filter(defined) @@ -181,113 +206,229 @@ export default function viteReact(opts: Options = {}): PluginOption[] { // we only create static option in this case and re-create them // each time otherwise staticBabelOptions = createBabelOptions(opts.babel) + + if ( + (isRolldownVite || skipFastRefresh) && + canSkipBabel(staticBabelOptions.plugins, staticBabelOptions) && + (opts.jsxRuntime === 'classic' ? isProduction : true) + ) { + delete viteBabel.transform + } } }, - async transform(code, id, options) { - if (id.includes('/node_modules/')) return - - const [filepath] = id.split('?') - if (!filter(filepath)) return - - const ssr = options?.ssr === true - const babelOptions = (() => { - if (staticBabelOptions) return staticBabelOptions - const newBabelOptions = createBabelOptions( - typeof opts.babel === 'function' - ? opts.babel(id, { ssr }) - : opts.babel, - ) - runPluginOverrides?.(newBabelOptions, { id, ssr }) - return newBabelOptions - })() - const plugins = [...babelOptions.plugins] - - const isJSX = filepath.endsWith('x') - const useFastRefresh = - !skipFastRefresh && - !ssr && - (isJSX || - (opts.jsxRuntime === 'classic' - ? importReactRE.test(code) - : code.includes(jsxImportDevRuntime) || - code.includes(jsxImportRuntime))) - if (useFastRefresh) { - plugins.push([ - await loadPlugin('react-refresh/babel'), - { skipEnvCheck: true }, - ]) + options(options) { + if (!runningInVite) { + options.jsx = { + mode: opts.jsxRuntime, + importSource: opts.jsxImportSource, + } + return options } - - if (opts.jsxRuntime === 'classic' && isJSX) { - if (!isProduction) { - // These development plugins are only needed for the classic runtime. - plugins.push( - await loadPlugin('@babel/plugin-transform-react-jsx-self'), - await loadPlugin('@babel/plugin-transform-react-jsx-source'), + }, + transform: { + filter: { + id: { + include: makeIdFiltersToMatchWithQuery(include), + exclude: makeIdFiltersToMatchWithQuery(exclude), + }, + }, + async handler(code, id, options) { + const [filepath] = id.split('?') + if (!filter(filepath)) return + + const ssr = options?.ssr === true + const babelOptions = (() => { + if (staticBabelOptions) return staticBabelOptions + const newBabelOptions = createBabelOptions( + typeof opts.babel === 'function' + ? opts.babel(id, { ssr }) + : opts.babel, ) + runPluginOverrides?.(newBabelOptions, { id, ssr }) + return newBabelOptions + })() + const plugins = [...babelOptions.plugins] + + // remove react-compiler plugin on non client environment + let reactCompilerPlugin = getReactCompilerPlugin(plugins) + if (reactCompilerPlugin && ssr) { + plugins.splice(plugins.indexOf(reactCompilerPlugin), 1) + reactCompilerPlugin = undefined } - } - // Avoid parsing if no special transformation is needed - if ( - !plugins.length && - !babelOptions.presets.length && - !babelOptions.configFile && - !babelOptions.babelrc - ) { - return - } + // filter by "use memo" when react-compiler { compilationMode: "annotation" } + // https://react.dev/learn/react-compiler/incremental-adoption#annotation-mode-configuration + if ( + Array.isArray(reactCompilerPlugin) && + reactCompilerPlugin[1]?.compilationMode === 'annotation' && + !compilerAnnotationRE.test(code) + ) { + plugins.splice(plugins.indexOf(reactCompilerPlugin), 1) + reactCompilerPlugin = undefined + } - const parserPlugins = [...babelOptions.parserOpts.plugins] + const isJSX = filepath.endsWith('x') + const useFastRefresh = + !(isRolldownVite || skipFastRefresh) && + !ssr && + (isJSX || + (opts.jsxRuntime === 'classic' + ? importReactRE.test(code) + : code.includes(jsxImportDevRuntime) || + code.includes(jsxImportRuntime))) + if (useFastRefresh) { + plugins.push([ + await loadPlugin('react-refresh/babel'), + { skipEnvCheck: true }, + ]) + } - if (!filepath.endsWith('.ts')) { - parserPlugins.push('jsx') - } + if (opts.jsxRuntime === 'classic' && isJSX) { + if (!isProduction) { + // These development plugins are only needed for the classic runtime. + plugins.push( + await loadPlugin('@babel/plugin-transform-react-jsx-self'), + await loadPlugin('@babel/plugin-transform-react-jsx-source'), + ) + } + } - if (tsRE.test(filepath)) { - parserPlugins.push('typescript') - } + // Avoid parsing if no special transformation is needed + if (canSkipBabel(plugins, babelOptions)) { + return + } + + const parserPlugins = [...babelOptions.parserOpts.plugins] + + if (!filepath.endsWith('.ts')) { + parserPlugins.push('jsx') + } + + if (tsRE.test(filepath)) { + parserPlugins.push('typescript') + } - const babel = await loadBabel() - const result = await babel.transformAsync(code, { - ...babelOptions, - root: projectRoot, - filename: id, - sourceFileName: filepath, - // Required for esbuild.jsxDev to provide correct line numbers - // This creates issues the react compiler because the re-order is too important - // People should use @babel/plugin-transform-react-jsx-development to get back good line numbers - retainLines: - getReactCompilerPlugin(plugins) != null + const babel = await loadBabel() + const result = await babel.transformAsync(code, { + ...babelOptions, + root: projectRoot, + filename: id, + sourceFileName: filepath, + // Required for esbuild.jsxDev to provide correct line numbers + // This creates issues the react compiler because the re-order is too important + // People should use @babel/plugin-transform-react-jsx-development to get back good line numbers + retainLines: reactCompilerPlugin ? false : !isProduction && isJSX && opts.jsxRuntime !== 'classic', - parserOpts: { - ...babelOptions.parserOpts, - sourceType: 'module', - allowAwaitOutsideFunction: true, - plugins: parserPlugins, - }, - generatorOpts: { - ...babelOptions.generatorOpts, - // import attributes parsing available without plugin since 7.26 - importAttributesKeyword: 'with', - decoratorsBeforeExport: true, - }, - plugins, - sourceMaps: true, - }) + parserOpts: { + ...babelOptions.parserOpts, + sourceType: 'module', + allowAwaitOutsideFunction: true, + plugins: parserPlugins, + }, + generatorOpts: { + ...babelOptions.generatorOpts, + // import attributes parsing available without plugin since 7.26 + importAttributesKeyword: 'with', + decoratorsBeforeExport: true, + }, + plugins, + sourceMaps: true, + }) - if (result) { - if (!useFastRefresh) { - return { code: result.code!, map: result.map } + if (result) { + if (!useFastRefresh) { + return { code: result.code!, map: result.map } + } + const code = addRefreshWrapper( + result.code!, + '@vitejs/plugin-react', + id, + opts.reactRefreshHost, + ) + return { code: code ?? result.code!, map: result.map } } - return addRefreshWrapper( - result.code!, - result.map!, + }, + }, + } + + // for rolldown-vite + const viteRefreshWrapper: Plugin = { + name: 'vite:react:refresh-wrapper', + apply: 'serve', + async applyToEnvironment(env) { + if (env.config.consumer !== 'client' || skipFastRefresh) { + return false + } + + let nativePlugin: ((options: any) => Plugin) | undefined + try { + nativePlugin = (await import('vite/internal')).reactRefreshWrapperPlugin + } catch {} + if ( + !nativePlugin || + vite.version === '7.1.10' || + vite.version === '7.1.11' + ) { + // the native plugin in 7.1.10 and 7.1.11 does not support dev + return true + } + + delete viteRefreshWrapper.transform + + return nativePlugin({ + include, + exclude, + jsxImportSource, + reactRefreshHost: opts.reactRefreshHost ?? '', + }) as unknown as boolean + }, + // we can remove this transform hook when we drop support for rolldown-vite 7.1.11 and below + transform: { + filter: { + id: { + include: makeIdFiltersToMatchWithQuery(include), + exclude: makeIdFiltersToMatchWithQuery(exclude), + }, + }, + handler(code, id, options) { + const ssr = options?.ssr === true + + const [filepath] = id.split('?') + const isJSX = filepath.endsWith('x') + const useFastRefresh = + !skipFastRefresh && + !ssr && + (isJSX || + code.includes(jsxImportDevRuntime) || + code.includes(jsxImportRuntime)) + if (!useFastRefresh) return + + const newCode = addRefreshWrapper( + code, '@vitejs/plugin-react', id, opts.reactRefreshHost, ) + return newCode ? { code: newCode, map: null } : undefined + }, + }, + } + + // for rolldown-vite + const viteConfigPost: Plugin = { + name: 'vite:react:config-post', + enforce: 'post', + config(userConfig) { + if (userConfig.server?.hmr === false) { + return { + oxc: { + jsx: { + refresh: false, + }, + }, + // oxc option is only available in rolldown-vite + } as any } }, } @@ -299,7 +440,7 @@ export default function viteReact(opts: Options = {}): PluginOption[] { jsxImportRuntime, ] const staticBabelPlugins = - typeof opts.babel === 'object' ? opts.babel?.plugins ?? [] : [] + typeof opts.babel === 'object' ? (opts.babel?.plugins ?? []) : [] const reactCompilerPlugin = getReactCompilerPlugin(staticBabelPlugins) if (reactCompilerPlugin != null) { const reactCompilerRuntimeModule = @@ -315,40 +456,74 @@ export default function viteReact(opts: Options = {}): PluginOption[] { optimizeDeps: { include: dependencies, }, - resolve: { - dedupe: ['react', 'react-dom'], - }, }), - resolveId(id) { - if (id === runtimePublicPath) { - return id - } + resolveId: { + filter: { id: exactRegex(runtimePublicPath) }, + handler(id) { + if (id === runtimePublicPath) { + return id + } + }, }, - load(id) { - if (id === runtimePublicPath) { - return readFileSync(refreshRuntimePath, 'utf-8').replace( - /__README_URL__/g, - 'https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react', - ) - } + load: { + filter: { id: exactRegex(runtimePublicPath) }, + handler(id) { + if (id === runtimePublicPath) { + return readFileSync(refreshRuntimePath, 'utf-8').replace( + /__README_URL__/g, + 'https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react', + ) + } + }, }, - transformIndexHtml(_, config) { - if (!skipFastRefresh) - return [ - { - tag: 'script', - attrs: { type: 'module' }, - children: getPreambleCode(config.server!.config.base), - }, - ] + transformIndexHtml: { + // TODO: maybe we can inject this to entrypoints instead of index.html? + handler() { + if (!skipFastRefresh) + return [ + { + tag: 'script', + attrs: { type: 'module' }, + children: getPreambleCode(base), + }, + ] + }, + // In unbundled mode, Vite transforms any requests. + // But in full bundled mode, Vite only transforms / bundles the scripts injected in `order: 'pre'`. + order: 'pre', }, } - return [viteBabel, viteReactRefresh] + return [ + viteBabel, + ...(isRolldownVite ? [viteRefreshWrapper, viteConfigPost] : []), + viteReactRefresh, + ] } viteReact.preambleCode = preambleCode +// Compat for require +function viteReactForCjs(this: unknown, options: Options): Plugin[] { + return viteReact.call(this, options) +} +Object.assign(viteReactForCjs, { + default: viteReactForCjs, +}) +export { viteReactForCjs as 'module.exports' } + +function canSkipBabel( + plugins: ReactBabelOptions['plugins'], + babelOptions: ReactBabelOptions, +) { + return !( + plugins.length || + babelOptions.presets.length || + babelOptions.configFile || + babelOptions.babelrc + ) +} + const loadedPlugin = new Map() function loadPlugin(path: string): any { const cached = loadedPlugin.get(path) @@ -401,9 +576,6 @@ function getReactCompilerRuntimeModule( if (Array.isArray(plugin)) { if (plugin[1]?.target === '17' || plugin[1]?.target === '18') { moduleName = 'react-compiler-runtime' - } else if (typeof plugin[1]?.runtimeModule === 'string') { - // backward compatibility from (#374), can be removed in next major - moduleName = plugin[1]?.runtimeModule } } return moduleName diff --git a/packages/plugin-react/tests/rolldown.test.ts b/packages/plugin-react/tests/rolldown.test.ts new file mode 100644 index 000000000..e62e7cb53 --- /dev/null +++ b/packages/plugin-react/tests/rolldown.test.ts @@ -0,0 +1,66 @@ +import path from 'node:path' +import { expect, test } from 'vitest' +import { type Plugin, rolldown } from 'rolldown' +import pluginReact, { type Options } from '../src/index.ts' + +test('HMR related code should not be included when using rolldown', async () => { + const { output } = await bundleWithRolldown() + + expect(output[0].code).toBeDefined() + expect(output[0].code).not.toContain('import.meta.hot') +}) + +test('HMR related code should not be included when using rolldown with babel plugin', async () => { + const { output } = await bundleWithRolldown({ + babel: { + plugins: [['babel-plugin-react-compiler', {}]], + }, + }) + + expect(output[0].code).toBeDefined() + expect(output[0].code).not.toContain('import.meta.hot') +}) + +async function bundleWithRolldown(pluginReactOptions: Options = {}) { + const ENTRY = '/entry.tsx' + const files: Record = { + [ENTRY]: /* tsx */ ` + import React from "react" + import { hydrateRoot } from "react-dom/client" + import App from "./App.tsx" + + const container = document.getElementById("root"); + hydrateRoot(container, ); + `, + '/App.tsx': /* tsx */ ` + export default function App() { + return
Hello World
+ } + `, + } + + const bundle = await rolldown({ + input: ENTRY, + plugins: [virtualFilesPlugin(files), pluginReact(pluginReactOptions)], + external: [/^react(\/|$)/, /^react-dom(\/|$)/], + }) + return await bundle.generate({ format: 'esm' }) +} + +function virtualFilesPlugin(files: Record): Plugin { + return { + name: 'virtual-files', + resolveId(id, importer) { + const baseDir = importer ? path.posix.dirname(importer) : '/' + const result = path.posix.resolve(baseDir, id) + if (result in files) { + return result + } + }, + load(id) { + if (id in files) { + return files[id] + } + }, + } +} diff --git a/packages/plugin-react/tsconfig.json b/packages/plugin-react/tsconfig.json index e2b17f9c7..70c7eacff 100644 --- a/packages/plugin-react/tsconfig.json +++ b/packages/plugin-react/tsconfig.json @@ -1,9 +1,9 @@ { - "include": ["src", "scripts"], + "include": ["src"], "compilerOptions": { "outDir": "dist", - "target": "ES2020", - "module": "ES2020", + "target": "es2023", + "module": "preserve", "moduleResolution": "bundler", "strict": true, "declaration": true, diff --git a/packages/plugin-react/tsdown.config.ts b/packages/plugin-react/tsdown.config.ts new file mode 100644 index 000000000..3e38aa5d7 --- /dev/null +++ b/packages/plugin-react/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: 'src/index.ts', + dts: true, + copy: [ + { + from: 'node_modules/@vitejs/react-common/refresh-runtime.js', + to: 'dist/refresh-runtime.js', + }, + ], +}) diff --git a/packages/plugin-rsc/.gitignore b/packages/plugin-rsc/.gitignore new file mode 100644 index 000000000..416330c31 --- /dev/null +++ b/packages/plugin-rsc/.gitignore @@ -0,0 +1,13 @@ +node_modules +dist +.vercel +.vite-node +.wrangler +.netlify +*.log +*.tgz +test-results +*.tsbuildinfo +.debug +.vite-inspect +.claude diff --git a/packages/plugin-rsc/AGENTS.md b/packages/plugin-rsc/AGENTS.md new file mode 100644 index 000000000..1b75ef7b1 --- /dev/null +++ b/packages/plugin-rsc/AGENTS.md @@ -0,0 +1,19 @@ +# AI Agent Guide for @vitejs/plugin-rsc + +This document provides AI-agent-specific guidance for the React Server Components (RSC) plugin. For comprehensive documentation, see: + +- **[README.md](README.md)** - Plugin overview, concepts, and examples +- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Development setup and testing guidelines + +## Quick Reference for AI Agents + +### Essential Commands + +```bash +# inside packages/plugin-rsc directory +pnpm build # build package +pnpm tsc # typecheck +pnpm dev # Watch mode development +pnpm test-e2e # Run e2e tests +pnpm test-e2e basic # Test specific example +``` diff --git a/packages/plugin-rsc/CHANGELOG.md b/packages/plugin-rsc/CHANGELOG.md new file mode 100644 index 000000000..0a008dc2c --- /dev/null +++ b/packages/plugin-rsc/CHANGELOG.md @@ -0,0 +1,534 @@ +## [0.4.29](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.28...plugin-rsc@0.4.29) (2025-09-09) +### Features + +* **rsc:** expose `transforms` utils ([#828](https://github.com/vitejs/vite-plugin-react/issues/828)) ([0a8e4dc](https://github.com/vitejs/vite-plugin-react/commit/0a8e4dcb664d728dbb41bd3ec12b3d258176dd7b)) + +### Bug Fixes + +* **deps:** update all non-major dependencies ([#823](https://github.com/vitejs/vite-plugin-react/issues/823)) ([afa28f1](https://github.com/vitejs/vite-plugin-react/commit/afa28f1675e8169f6494413b2bb69577b9cbf6f5)) +* **rsc:** fix build error when entire client reference module is tree-shaken ([#827](https://github.com/vitejs/vite-plugin-react/issues/827)) ([f515bd8](https://github.com/vitejs/vite-plugin-react/commit/f515bd8d82122ba4a2a80886978270182fd7bcbb)) + +### Code Refactoring + +* **rsc:** remove top-level `transformHoistInlineDirective` export in favor of `@vitejs/plugin-rsc/transforms` ([#829](https://github.com/vitejs/vite-plugin-react/issues/829)) ([3122b0d](https://github.com/vitejs/vite-plugin-react/commit/3122b0d25e01206fb52e8c9eb30cc894126f02cf)) + +## [0.4.28](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.27...plugin-rsc@0.4.28) (2025-09-08) +### Features + +* **rsc:** support browser mode build ([#801](https://github.com/vitejs/vite-plugin-react/issues/801)) ([b81bf6a](https://github.com/vitejs/vite-plugin-react/commit/b81bf6ac8a273855c5e9f39d71a32d76fd31b61c)) + +### Bug Fixes + +* **rsc:** support `rsc.loadModuleDevProxy` top-level config ([#825](https://github.com/vitejs/vite-plugin-react/issues/825)) ([d673dd0](https://github.com/vitejs/vite-plugin-react/commit/d673dd0a525a9baf6644a89f28cd1537847741bb)) + +### Miscellaneous Chores + +* add AGENTS.md documentation for AI agent development guidance ([#820](https://github.com/vitejs/vite-plugin-react/issues/820)) ([d1627cb](https://github.com/vitejs/vite-plugin-react/commit/d1627cbdd20ac2ce1f91185ef0ba1be882a0186b)) + +### Tests + +* **rsc:** test `useId` ([#818](https://github.com/vitejs/vite-plugin-react/issues/818)) ([768cfd3](https://github.com/vitejs/vite-plugin-react/commit/768cfd3c7fd956497ec5e39734c0c1a62a2a441c)) +* **rsc:** test middleware mode ([#817](https://github.com/vitejs/vite-plugin-react/issues/817)) ([4672651](https://github.com/vitejs/vite-plugin-react/commit/467265104995f9b07058269f2905a78a9cc0c2ce)) + +## [0.4.27](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.26...plugin-rsc@0.4.27) (2025-09-01) +### Features + +* **rsc:** enable `buildApp` plugin hook by default for Vite 7 ([#815](https://github.com/vitejs/vite-plugin-react/issues/815)) ([0a02b83](https://github.com/vitejs/vite-plugin-react/commit/0a02b835efb8de7ff2f95008a5321738b9b6a0b0)) +* **rsc:** support `UserConfig.rsc: RscPluginOptions` ([#810](https://github.com/vitejs/vite-plugin-react/issues/810)) ([07a64c2](https://github.com/vitejs/vite-plugin-react/commit/07a64c25ab056689c99ce348810aa721a7f1926b)) + +### Bug Fixes + +* **deps:** update all non-major dependencies ([#809](https://github.com/vitejs/vite-plugin-react/issues/809)) ([437bab2](https://github.com/vitejs/vite-plugin-react/commit/437bab254d1f1fa3542dd335c6763ee36c8826be)) +* **rsc:** delay `validateImportPlugin` setup ([#813](https://github.com/vitejs/vite-plugin-react/issues/813)) ([4da5810](https://github.com/vitejs/vite-plugin-react/commit/4da58106e9c2ba1258ff3f97e853324af24f4ed8)) + +### Documentation + +* **rsc:** mention `@vitejs/plugin-rsc/types` ([#816](https://github.com/vitejs/vite-plugin-react/issues/816)) ([3568e89](https://github.com/vitejs/vite-plugin-react/commit/3568e890d21c8cc80ef901222f1f04ca0dbdc1c5)) + +### Miscellaneous Chores + +* fix type in `README.md` ([#804](https://github.com/vitejs/vite-plugin-react/issues/804)) ([f9d7cd9](https://github.com/vitejs/vite-plugin-react/commit/f9d7cd96bdd86b63dc028daf6731860e13a5d3bf)) + +### Code Refactoring + +* **rsc:** simplify `validateImportPlugin` ([#814](https://github.com/vitejs/vite-plugin-react/issues/814)) ([3969f86](https://github.com/vitejs/vite-plugin-react/commit/3969f8602cf43de95d6ae086a0612188d56a239d)) + +## [0.4.26](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.25...plugin-rsc@0.4.26) (2025-08-29) +### Features + +* **rsc:** enable server-chunk-based client chunks ([#794](https://github.com/vitejs/vite-plugin-react/issues/794)) ([377a273](https://github.com/vitejs/vite-plugin-react/commit/377a273b27d0996ae9d2be50a74dc372d91cdc9c)) + +### Bug Fixes + +* **rsc:** use `req.originalUrl` for server handler ([#797](https://github.com/vitejs/vite-plugin-react/issues/797)) ([3250231](https://github.com/vitejs/vite-plugin-react/commit/3250231b7537daf6946a27ec8bd8dc47a646d034)) + +### Documentation + +* **rsc:** how to use `@vitejs/plugin-rsc` as framework's `dependencies` ([#796](https://github.com/vitejs/vite-plugin-react/issues/796)) ([907b9d8](https://github.com/vitejs/vite-plugin-react/commit/907b9d8323e7a21160a58d328d6ac444e5fa31da)) + +### Miscellaneous Chores + +* **rsc:** typo in viteRscAsyncHooks naming ([#793](https://github.com/vitejs/vite-plugin-react/issues/793)) ([95e4091](https://github.com/vitejs/vite-plugin-react/commit/95e4091dcb973506136bd1564000916e8a38c440)) + +### Code Refactoring + +* **rsc:** organize internal plugins ([#791](https://github.com/vitejs/vite-plugin-react/issues/791)) ([d8cfdfa](https://github.com/vitejs/vite-plugin-react/commit/d8cfdfa1b8aca65fae2e555b0ae8a66eb9276ed6)) + +## [0.4.25](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.24...plugin-rsc@0.4.25) (2025-08-28) +### Bug Fixes + +* **rsc:** inject `AsyncLocalStorage` global via transform ([#785](https://github.com/vitejs/vite-plugin-react/issues/785)) ([2f255ad](https://github.com/vitejs/vite-plugin-react/commit/2f255ad694b976ff0b6f826f5fe8c27da5852df1)) +* **rsc:** optimize `react-dom/static.edge` ([#786](https://github.com/vitejs/vite-plugin-react/issues/786)) ([e3bf733](https://github.com/vitejs/vite-plugin-react/commit/e3bf73356bf307d68e5e62c06987815afb1a1f44)) +* **rsc:** propagate client reference invalidation to server ([#788](https://github.com/vitejs/vite-plugin-react/issues/788)) ([a8dc3fe](https://github.com/vitejs/vite-plugin-react/commit/a8dc3feade6fc64b1cfd851d90b39d4d7ba98b02)) + +### Miscellaneous Chores + +* **deps:** update `@types/react-dom` to fix `formState` ([#782](https://github.com/vitejs/vite-plugin-react/issues/782)) ([af9139f](https://github.com/vitejs/vite-plugin-react/commit/af9139f0bf1e30d4ffbd23b065001b0284cfda05)) + +### Tests + +* **rsc:** test `hydrateRoot(..., { formState })` ([#781](https://github.com/vitejs/vite-plugin-react/issues/781)) ([e622a6a](https://github.com/vitejs/vite-plugin-react/commit/e622a6a06b4d021430a42defe893353940931915)) + +## [0.4.24](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.23...plugin-rsc@0.4.24) (2025-08-25) +### Features + +* **rsc:** ability to merge client reference chunks ([#766](https://github.com/vitejs/vite-plugin-react/issues/766)) ([c40234e](https://github.com/vitejs/vite-plugin-react/commit/c40234ef079e5e27e86acf88c8c987db8bb1b16c)) +* **rsc:** ability to merge client reference chunks based on server chunk usage ([#767](https://github.com/vitejs/vite-plugin-react/issues/767)) ([c69f0f6](https://github.com/vitejs/vite-plugin-react/commit/c69f0f6b834ac518f183b0a76851d17ddb7a81d0)) + +### Bug Fixes + +* **deps:** update all non-major dependencies ([#773](https://github.com/vitejs/vite-plugin-react/issues/773)) ([9989897](https://github.com/vitejs/vite-plugin-react/commit/9989897fd102ba2d46bee0961e43aacb1e4f9436)) +* **rsc:** fix client reference preload when group chunk re-exports client components from entry chunk ([#768](https://github.com/vitejs/vite-plugin-react/issues/768)) ([41e4bf5](https://github.com/vitejs/vite-plugin-react/commit/41e4bf586c7ebd81fba9e25e72c90386b5e88a4d)) +* **rsc:** fix CSS HMR with `?url` ([#776](https://github.com/vitejs/vite-plugin-react/issues/776)) ([4c4879b](https://github.com/vitejs/vite-plugin-react/commit/4c4879b0c1080b536ac6521a7030691a06469b3a)) +* **rsc:** normalize group chunk virtual id properly ([#770](https://github.com/vitejs/vite-plugin-react/issues/770)) ([9869e2c](https://github.com/vitejs/vite-plugin-react/commit/9869e2c7c51b3f001389255dbc40beafb76cac7b)) + +### Miscellaneous Chores + +* **rsc:** custom client chunks example ([#765](https://github.com/vitejs/vite-plugin-react/issues/765)) ([6924db4](https://github.com/vitejs/vite-plugin-react/commit/6924db40f5cbfb9e02f4e4c5beacc2671f4df0ee)) +* **rsc:** fix `useBuildAppHook: true` with cloudflare plugin ([#780](https://github.com/vitejs/vite-plugin-react/issues/780)) ([8fec8e3](https://github.com/vitejs/vite-plugin-react/commit/8fec8e3b79cce570fb369b6bddd35938ad2ec37a)) + +### Code Refactoring + +* **rsc:** add `toRelativeId` util ([#771](https://github.com/vitejs/vite-plugin-react/issues/771)) ([d9da80f](https://github.com/vitejs/vite-plugin-react/commit/d9da80ffa804ea839a99e331b2dd33b9478a7d76)) +* **rsc:** organize plugin utils ([#779](https://github.com/vitejs/vite-plugin-react/issues/779)) ([789e359](https://github.com/vitejs/vite-plugin-react/commit/789e3592d756227739b2285bda95a5d5dc9e5e93)) + +### Tests + +* **rsc:** organize css tests ([#778](https://github.com/vitejs/vite-plugin-react/issues/778)) ([e71da84](https://github.com/vitejs/vite-plugin-react/commit/e71da842f89fdb0c549e874205e65601109f41b9)) + +## [0.4.23](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.22...plugin-rsc@0.4.23) (2025-08-23) +### Bug Fixes + +* **rsc:** replace `'rolldownVersion' in this.meta` with `'rolldownVersion' in vite` for Vite 6 compat ([#761](https://github.com/vitejs/vite-plugin-react/issues/761)) ([af4e16d](https://github.com/vitejs/vite-plugin-react/commit/af4e16da970f2808e0ab4484500f0a038c8b176a)) + +### Miscellaneous Chores + +* **rsc:** remove custom `react-dom/server.edge` types ([#757](https://github.com/vitejs/vite-plugin-react/issues/757)) ([a7ca366](https://github.com/vitejs/vite-plugin-react/commit/a7ca366f57f97ea0ab540dce645095ed9efedce8)) +* **rsc:** simplify react-router example ([#763](https://github.com/vitejs/vite-plugin-react/issues/763)) ([22f6538](https://github.com/vitejs/vite-plugin-react/commit/22f6538ea1536700da8588f4d9960787f51f1bcd)) +* **rsc:** use `prerender` in ssg example ([#758](https://github.com/vitejs/vite-plugin-react/issues/758)) ([df8b800](https://github.com/vitejs/vite-plugin-react/commit/df8b80055c567b0248c506e2c57fb613d9da128f)) + +### Tests + +* **rsc:** test vite 6 ([#762](https://github.com/vitejs/vite-plugin-react/issues/762)) ([a46bdf4](https://github.com/vitejs/vite-plugin-react/commit/a46bdf45712e144c07797844b31e98bec5154be4)) + +## [0.4.22](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.21...plugin-rsc@0.4.22) (2025-08-22) +### Bug Fixes + +* **rsc:** ensure `.js` suffix for internal virtual modules ([#744](https://github.com/vitejs/vite-plugin-react/issues/744)) ([bffc82e](https://github.com/vitejs/vite-plugin-react/commit/bffc82e12c3e8f369442eb4616db934d4bb10916)) +* **rsc:** expose only `"use server"` as server functions ([#752](https://github.com/vitejs/vite-plugin-react/issues/752)) ([d2f2e71](https://github.com/vitejs/vite-plugin-react/commit/d2f2e716773a0c95c32f5e65f0a0d7b016fc3250)) +* **rsc:** handle added/removed `"use client"` during dev ([#750](https://github.com/vitejs/vite-plugin-react/issues/750)) ([232be7b](https://github.com/vitejs/vite-plugin-react/commit/232be7bd65c7b0db8f6ecd41db6a97f47a9a9c26)) +* **rsc:** include non-entry optimized modules for `optimizeDeps.exclude` suggestion ([#740](https://github.com/vitejs/vite-plugin-react/issues/740)) ([2640add](https://github.com/vitejs/vite-plugin-react/commit/2640add3bfc9d0709de590b76599da59a131e506)) +* **rsc:** inject `__vite_rsc_importer_resources` import only once ([#742](https://github.com/vitejs/vite-plugin-react/issues/742)) ([5b28ba5](https://github.com/vitejs/vite-plugin-react/commit/5b28ba540cdeba511d7699df7331dec844893fc1)) +* **rsc:** isolate plugin state per plugin instance ([#747](https://github.com/vitejs/vite-plugin-react/issues/747)) ([596c76b](https://github.com/vitejs/vite-plugin-react/commit/596c76bfb919b668694c3768cb1126f9dbf7f878)) +* **rsc:** relax async function requirement for `"use server"` module directive ([#754](https://github.com/vitejs/vite-plugin-react/issues/754)) ([08986dd](https://github.com/vitejs/vite-plugin-react/commit/08986dd4d23d8881ed9852837508d64d38ff2129)) + +### Code Refactoring + +* **rsc:** handle added/removed `"use server"` during dev ([#753](https://github.com/vitejs/vite-plugin-react/issues/753)) ([7542e6f](https://github.com/vitejs/vite-plugin-react/commit/7542e6f3b99054d065a8dc213a6ed62e3edde531)) +* **rsc:** organize internal plugins ([#745](https://github.com/vitejs/vite-plugin-react/issues/745)) ([0a6cfdf](https://github.com/vitejs/vite-plugin-react/commit/0a6cfdf874b47cee511cf308b9dae08b123eac70)) +* **rsc:** organize plugin utils ([#755](https://github.com/vitejs/vite-plugin-react/issues/755)) ([53b3f48](https://github.com/vitejs/vite-plugin-react/commit/53b3f485f6e06a34ddd70f3b1ffe35f4bebab3b3)) +* **rsc:** remove `__fix_cloudflare` plugin ([#746](https://github.com/vitejs/vite-plugin-react/issues/746)) ([bec6c82](https://github.com/vitejs/vite-plugin-react/commit/bec6c829e84d9ed36330ce9a16b602c0d6b73cf1)) +* **rsc:** simplify plugin state for server reference ([#751](https://github.com/vitejs/vite-plugin-react/issues/751)) ([9988f54](https://github.com/vitejs/vite-plugin-react/commit/9988f5494dd49e18a51fab9017a487da4843e4b0)) + +## [0.4.21](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.20...plugin-rsc@0.4.21) (2025-08-19) +### Bug Fixes + +* **deps:** update all non-major dependencies ([#729](https://github.com/vitejs/vite-plugin-react/issues/729)) ([ba0323c](https://github.com/vitejs/vite-plugin-react/commit/ba0323cfcd7343362e64f782c5aae02ed9ee3273)) +* **rsc:** exclude CSS imports with special queries from automatic injection ([#580](https://github.com/vitejs/vite-plugin-react/issues/580)) ([71bb49c](https://github.com/vitejs/vite-plugin-react/commit/71bb49c7fe5c8362426d59ee8a99ea660b631b66)) +* **rsc:** fix custom `root` ([#717](https://github.com/vitejs/vite-plugin-react/issues/717)) ([c7bc716](https://github.com/vitejs/vite-plugin-react/commit/c7bc716e54070a35263dad1a978635c48f6c1720)) +* **rsc:** keep `import.meta.glob` during scan build for rolldown-vite ([#721](https://github.com/vitejs/vite-plugin-react/issues/721)) ([74ec0e0](https://github.com/vitejs/vite-plugin-react/commit/74ec0e0e0e21355884b0aff26ca0919404cef3f2)) + +### Documentation + +* **rsc:** improve plugin-rsc README organization and clarity ([#723](https://github.com/vitejs/vite-plugin-react/issues/723)) ([e6d7392](https://github.com/vitejs/vite-plugin-react/commit/e6d7392f4c2b052db6ba719217641099cfa8f817)) + +### Miscellaneous Chores + +* remove vite-plugin-inspect dependency from examples ([#730](https://github.com/vitejs/vite-plugin-react/issues/730)) ([feb5553](https://github.com/vitejs/vite-plugin-react/commit/feb55537d036dcd6f9008cb13a9748ca5ef57925)) +* **rsc:** fix `examples/basic` on stackblitz ([#724](https://github.com/vitejs/vite-plugin-react/issues/724)) ([1abe044](https://github.com/vitejs/vite-plugin-react/commit/1abe044668a13d55ea5549c558f666baa6196f15)) +* **rsc:** rework ssg example ([#713](https://github.com/vitejs/vite-plugin-react/issues/713)) ([28e723b](https://github.com/vitejs/vite-plugin-react/commit/28e723b6ad38c3aa15d6defb83c0b8acb6748f66)) +* **rsc:** tweak React.cache example ([#725](https://github.com/vitejs/vite-plugin-react/issues/725)) ([cc1bcdf](https://github.com/vitejs/vite-plugin-react/commit/cc1bcdfce4323119d0d918f72226168abbfadb4f)) +* **rsc:** use named imports ([#727](https://github.com/vitejs/vite-plugin-react/issues/727)) ([ba25233](https://github.com/vitejs/vite-plugin-react/commit/ba25233b3afafa20916ad35e4c7f1d3ecda0d0da)) + +### Tests + +* **rsc:** fix invalid code ([#722](https://github.com/vitejs/vite-plugin-react/issues/722)) ([a39d837](https://github.com/vitejs/vite-plugin-react/commit/a39d8375cd0da1bd1e608894124bc7bfbffe6fa9)) +* **rsc:** test assets ([#733](https://github.com/vitejs/vite-plugin-react/issues/733)) ([fd96308](https://github.com/vitejs/vite-plugin-react/commit/fd96308a6cde57a132b3d9e434e711aac15c6486)) + +## [0.4.20](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.19...plugin-rsc@0.4.20) (2025-08-13) +### Bug Fixes + +* **rsc:** deprecate opt-out `ignoredPackageWarnings` option in favor of ont-in `DEBUG` env ([#697](https://github.com/vitejs/vite-plugin-react/issues/697)) ([5d5edd4](https://github.com/vitejs/vite-plugin-react/commit/5d5edd4d896fe6d064dddd5a3cf76594b0b0171c)) +* **rsc:** keep hoisted require order ([#706](https://github.com/vitejs/vite-plugin-react/issues/706)) ([ad7584a](https://github.com/vitejs/vite-plugin-react/commit/ad7584a29b02238d685504ff356515e6f78275dc)) +* **rsc:** remove duplicate server css on initial render ([#702](https://github.com/vitejs/vite-plugin-react/issues/702)) ([3114e88](https://github.com/vitejs/vite-plugin-react/commit/3114e88bcd8303d7c42da29eb7215c54ed43ce0d)) +* **rsc:** warn dual module of optimized and non-optimized client reference ([#705](https://github.com/vitejs/vite-plugin-react/issues/705)) ([e5c3517](https://github.com/vitejs/vite-plugin-react/commit/e5c351776e9a6269a37a171c830a902381af8011)) + +### Miscellaneous Chores + +* **rsc:** fix csp example for Vite server ping SharedWorker ([#704](https://github.com/vitejs/vite-plugin-react/issues/704)) ([5b73cbe](https://github.com/vitejs/vite-plugin-react/commit/5b73cbe134466650a7aabc02dc794e7d6e35b135)) +* **rsc:** update package.json for starter-cf-single ([#707](https://github.com/vitejs/vite-plugin-react/issues/707)) ([2d93ee4](https://github.com/vitejs/vite-plugin-react/commit/2d93ee42cf8b4b544fd09400f1c6ed1dfdb6652d)) + +### Code Refactoring + +* move @vitejs/plugin-rsc to devDependencies in examples ([#699](https://github.com/vitejs/vite-plugin-react/issues/699)) ([a1f4311](https://github.com/vitejs/vite-plugin-react/commit/a1f4311f87d0f983b8332ab393514e0d71263374)) + +## [0.4.19](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.18...plugin-rsc@0.4.19) (2025-08-11) +### Bug Fixes + +* **rsc:** fix cjs default import on module runner ([#695](https://github.com/vitejs/vite-plugin-react/issues/695)) ([c329914](https://github.com/vitejs/vite-plugin-react/commit/c329914c572473d4f09261fa0eba77484e720d2e)) +* **rsc:** replace `?v=` check with more robust `node_modules` detection ([#696](https://github.com/vitejs/vite-plugin-react/issues/696)) ([f0359c4](https://github.com/vitejs/vite-plugin-react/commit/f0359c4eca48ca6eb2ba98254a272949a13f149e)) +* **rsc:** replace non-optimized server cjs warning with debug only log ([#698](https://github.com/vitejs/vite-plugin-react/issues/698)) ([a88fb2d](https://github.com/vitejs/vite-plugin-react/commit/a88fb2ded4c8b9f42f2fee70a482615f331122f4)) + +## [0.4.18](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.17...plugin-rsc@0.4.18) (2025-08-11) +### Bug Fixes + +* **deps:** update all non-major dependencies ([#694](https://github.com/vitejs/vite-plugin-react/issues/694)) ([5057858](https://github.com/vitejs/vite-plugin-react/commit/50578587472d23125980a46ff993fedaabca28d2)) +* **react:** always skip react-compiler on non client envrionment ([#689](https://github.com/vitejs/vite-plugin-react/issues/689)) ([2f62dc0](https://github.com/vitejs/vite-plugin-react/commit/2f62dc0778e8c527c7951d6e35b0658a07f1e6fc)) +* **rsc:** support cjs on module runner ([#687](https://github.com/vitejs/vite-plugin-react/issues/687)) ([7a92083](https://github.com/vitejs/vite-plugin-react/commit/7a92083eadb6ad8d92e6e560de414bc600e977c0)) + +### Miscellaneous Chores + +* **rsc:** add .gitignore to create-vite example ([#686](https://github.com/vitejs/vite-plugin-react/issues/686)) ([6df7192](https://github.com/vitejs/vite-plugin-react/commit/6df71929ea5c2176408054bc40bcb8dfbb370018)) +* **rsc:** mention deploy example ([#685](https://github.com/vitejs/vite-plugin-react/issues/685)) ([dea484a](https://github.com/vitejs/vite-plugin-react/commit/dea484ab8c740babab89da0f716bb929e57ba2af)) + +## [0.4.17](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.16...plugin-rsc@0.4.17) (2025-08-05) +### Bug Fixes + +* **deps:** update all non-major dependencies ([#670](https://github.com/vitejs/vite-plugin-react/issues/670)) ([61d777d](https://github.com/vitejs/vite-plugin-react/commit/61d777ddc8524256f890f43a2a78dbfbfd1e97ac)) +* **rsc:** keep manually added link stylesheet during dev ([#663](https://github.com/vitejs/vite-plugin-react/issues/663)) ([ac20b31](https://github.com/vitejs/vite-plugin-react/commit/ac20b31279f6884169503ef6e5786639c93251df)) +* **rsc:** optimize `use-sync-external-store` ([#674](https://github.com/vitejs/vite-plugin-react/issues/674)) ([556de15](https://github.com/vitejs/vite-plugin-react/commit/556de15191eb2dfa26d9c0ba396c219d4b4a2dd4)) + +### Documentation + +* **rsc:** notes on CSS support ([#673](https://github.com/vitejs/vite-plugin-react/issues/673)) ([9b2741f](https://github.com/vitejs/vite-plugin-react/commit/9b2741f3dc3da8e9e2ef486ab8d7eaa317230f7d)) + +### Miscellaneous Chores + +* **rsc:** tweak types and examples ([#682](https://github.com/vitejs/vite-plugin-react/issues/682)) ([7b07098](https://github.com/vitejs/vite-plugin-react/commit/7b07098746a672950f278ea7edffd04834133d1f)) + +### Code Refactoring + +* **rsc:** update `@mjackson/node-fetch-server` to `@remix-run/node-fetch-server` ([#680](https://github.com/vitejs/vite-plugin-react/issues/680)) ([97b5f1b](https://github.com/vitejs/vite-plugin-react/commit/97b5f1b26c2260825447c7e9781f1b168bebbe62)) + +### Tests + +* **rsc:** test `React.cache` ([#668](https://github.com/vitejs/vite-plugin-react/issues/668)) ([26ad4ad](https://github.com/vitejs/vite-plugin-react/commit/26ad4adcb69affb8932151f245b25a8fcf95c85a)) +* **rsc:** test shared module hmr ([#671](https://github.com/vitejs/vite-plugin-react/issues/671)) ([775ac61](https://github.com/vitejs/vite-plugin-react/commit/775ac6157ef7af545b4cb03ff116a01c7cffa815)) + +## [0.4.16](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.15...plugin-rsc@0.4.16) (2025-08-01) +### Features + +* merge `plugin-react-oxc` into `plugin-react` ([#609](https://github.com/vitejs/vite-plugin-react/issues/609)) ([133d786](https://github.com/vitejs/vite-plugin-react/commit/133d7865f42aa3376b5d3119fdb6a71eaf600275)) +* **rsc:** add `useBuildAppHook` option to switch `plugin.buildApp` or `builder.buildApp` ([#653](https://github.com/vitejs/vite-plugin-react/issues/653)) ([83a5741](https://github.com/vitejs/vite-plugin-react/commit/83a57414169684bc705a5f6ca13cf097225117d8)) +* **rsc:** support `client` environment as `react-server` ([#657](https://github.com/vitejs/vite-plugin-react/issues/657)) ([5df0070](https://github.com/vitejs/vite-plugin-react/commit/5df00707522ecbcda40f2c53c620f46b517e68e6)) + +### Bug Fixes + +* **react:** use development jsx transform for `NODE_ENV=development` build ([#649](https://github.com/vitejs/vite-plugin-react/issues/649)) ([9ffd86d](https://github.com/vitejs/vite-plugin-react/commit/9ffd86df3c0cfc2060669cac7cc0b86144158b1b)) +* **rsc:** avoid unnecessary server hmr due to tailwind module deps ([#658](https://github.com/vitejs/vite-plugin-react/issues/658)) ([c1383f8](https://github.com/vitejs/vite-plugin-react/commit/c1383f870137c0f152d7687250e8095635a1177c)) + +### Miscellaneous Chores + +* **deps:** update all non-major dependencies ([#639](https://github.com/vitejs/vite-plugin-react/issues/639)) ([1a02ba7](https://github.com/vitejs/vite-plugin-react/commit/1a02ba7f4d3fe4a1696b43bc5161d6d466802faf)) + +### Code Refactoring + +* **rsc:** move `writeManifest` inside `buildApp` hook ([#659](https://github.com/vitejs/vite-plugin-react/issues/659)) ([a34f8c5](https://github.com/vitejs/vite-plugin-react/commit/a34f8c537df2efc27d55a510bfd3597c639842f6)) +* **rsc:** split encryption runtime exports ([#660](https://github.com/vitejs/vite-plugin-react/issues/660)) ([ff44ae4](https://github.com/vitejs/vite-plugin-react/commit/ff44ae49697e6ebca4ae4b241ab8337ebe659b5e)) + +### Tests + +* **rsc:** port transform tests from waku ([#655](https://github.com/vitejs/vite-plugin-react/issues/655)) ([c602225](https://github.com/vitejs/vite-plugin-react/commit/c602225271d4acf462ba00f8d6d8a2e42492c5cd)) +* **rsc:** split more independent tests ([#652](https://github.com/vitejs/vite-plugin-react/issues/652)) ([ac0cac7](https://github.com/vitejs/vite-plugin-react/commit/ac0cac7465cc94e91e8ac40269f36e91599b8162)) + +## [0.4.15](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.14...plugin-rsc@0.4.15) (2025-07-28) +### Features + +* **rsc:** show warning for non optimized cjs ([#635](https://github.com/vitejs/vite-plugin-react/issues/635)) ([da0a786](https://github.com/vitejs/vite-plugin-react/commit/da0a78607d18be534232fba5ea95bb96cc987449)) + +### Bug Fixes + +* **rsc:** improve auto css heuristics ([#643](https://github.com/vitejs/vite-plugin-react/issues/643)) ([f0b4cff](https://github.com/vitejs/vite-plugin-react/commit/f0b4cff636558a27ed4e5527ed4ea68a2243e40e)) + +## [0.4.14](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.13...plugin-rsc@0.4.14) (2025-07-27) +### Features + +* **rsc:** validate `client-only` and `server-only` import during resolve ([#624](https://github.com/vitejs/vite-plugin-react/issues/624)) ([47d02d0](https://github.com/vitejs/vite-plugin-react/commit/47d02d0643cecc8243c72fddd9e125cc3d020847)) + +### Bug Fixes + +* **rsc:** add `getEntrySource` assertion error message ([#633](https://github.com/vitejs/vite-plugin-react/issues/633)) ([4568556](https://github.com/vitejs/vite-plugin-react/commit/45685561d7e85cd6e2f77dc383cc6728d5fc916f)) +* **rsc:** handle transform errors before server hmr ([#626](https://github.com/vitejs/vite-plugin-react/issues/626)) ([d28356f](https://github.com/vitejs/vite-plugin-react/commit/d28356f5caca2867ced9af3a02a3f441ff4a5238)) + +### Documentation + +* **rsc:** fix jsdoc ([#623](https://github.com/vitejs/vite-plugin-react/issues/623)) ([73d457b](https://github.com/vitejs/vite-plugin-react/commit/73d457b2774c26a9fd1ec0f53aee8b4ff60dacd6)) + +### Miscellaneous Chores + +* **deps:** update react-router ([#632](https://github.com/vitejs/vite-plugin-react/issues/632)) ([b077c4a](https://github.com/vitejs/vite-plugin-react/commit/b077c4a774ebe4a059902f3e0cb043c7194cceeb)) + +### Tests + +* **rsc:** parallel e2e ([#628](https://github.com/vitejs/vite-plugin-react/issues/628)) ([24ddea4](https://github.com/vitejs/vite-plugin-react/commit/24ddea46d016311a8efe34314a4faa9d61af0d9d)) +* **rsc:** split starter tests into multiple files ([#629](https://github.com/vitejs/vite-plugin-react/issues/629)) ([707f35b](https://github.com/vitejs/vite-plugin-react/commit/707f35bfe1fb047a453fca6281885bc1565303fc)) + +### Continuous Integration + +* **rsc:** test react nightly ([#630](https://github.com/vitejs/vite-plugin-react/issues/630)) ([3e2f5a9](https://github.com/vitejs/vite-plugin-react/commit/3e2f5a9e03f56d1a218f030a71be72ef28b91a43)) + +## [0.4.13](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.12...plugin-rsc@0.4.13) (2025-07-24) +### Features + +* **rsc:** add support for `experimental.renderBuiltUrl` on assets metadata ([#612](https://github.com/vitejs/vite-plugin-react/issues/612)) ([5314ed6](https://github.com/vitejs/vite-plugin-react/commit/5314ed60572e2c89963e5a720d21bcad17687382)) + +### Bug Fixes + +* **deps:** update all non-major dependencies ([#568](https://github.com/vitejs/vite-plugin-react/issues/568)) ([d14f31d](https://github.com/vitejs/vite-plugin-react/commit/d14f31d3bf8487346ae6f9db7e6ca7263c93066b)) +* **deps:** update all non-major dependencies ([#593](https://github.com/vitejs/vite-plugin-react/issues/593)) ([9ce3b22](https://github.com/vitejs/vite-plugin-react/commit/9ce3b22e4bc7db28f549b9c9b9195d2bd82ff736)) +* **rsc:** await handler to avoid unhandled rejection ([#576](https://github.com/vitejs/vite-plugin-react/issues/576)) ([fa60127](https://github.com/vitejs/vite-plugin-react/commit/fa60127be46d48ecd8a8b0d0e7e6751ed11303e2)) +* **rsc:** ensure trailing slash of `BASE_URL` ([#589](https://github.com/vitejs/vite-plugin-react/issues/589)) ([fa1d260](https://github.com/vitejs/vite-plugin-react/commit/fa1d260ef384d986284aaec6e0984967f3b436ad)) +* **rsc:** update rsc-html-stream v0.0.7 ([#578](https://github.com/vitejs/vite-plugin-react/issues/578)) ([df6a38e](https://github.com/vitejs/vite-plugin-react/commit/df6a38e42339cf5deecd3f1b6c0aa4dd838833c5)) + +### Documentation + +* **rsc:** add `CONTRIBUTING.md` ([#613](https://github.com/vitejs/vite-plugin-react/issues/613)) ([4005dbe](https://github.com/vitejs/vite-plugin-react/commit/4005dbe1bb943b882d8199ef29ccaeb9d268784e)) + +### Miscellaneous Chores + +* replace `build --app` with `build` in examples ([#572](https://github.com/vitejs/vite-plugin-react/issues/572)) ([7c564ff](https://github.com/vitejs/vite-plugin-react/commit/7c564ff4f290a554927f2eef600e82bffee16e6b)) +* **rsc:** comment ([#599](https://github.com/vitejs/vite-plugin-react/issues/599)) ([b550b63](https://github.com/vitejs/vite-plugin-react/commit/b550b63fe7f6ef82588ff0d60389d11906c3cc4e)) +* **rsc:** deprecate `@vitejs/plugin-rsc/extra` API ([#592](https://github.com/vitejs/vite-plugin-react/issues/592)) ([bd6a2a1](https://github.com/vitejs/vite-plugin-react/commit/bd6a2a1ff272c8550f92bc1530c7b28fb81e1c60)) +* **rsc:** deprecate `rsc-html-stream` re-exports ([#602](https://github.com/vitejs/vite-plugin-react/issues/602)) ([8e0e8b6](https://github.com/vitejs/vite-plugin-react/commit/8e0e8b60c511f34df188a8e8b103cf273891d7ad)) +* **rsc:** fix temporary references in examples ([#603](https://github.com/vitejs/vite-plugin-react/issues/603)) ([22e5398](https://github.com/vitejs/vite-plugin-react/commit/22e53987a5548d237fcbe61377bd1da6e86947ef)) +* **rsc:** move comment ([#604](https://github.com/vitejs/vite-plugin-react/issues/604)) ([4d6c72f](https://github.com/vitejs/vite-plugin-react/commit/4d6c72f81d64972ac84735240d27516be81431f8)) +* **rsc:** remove `@vite/plugin-rsc/extra` API usages from examples ([#596](https://github.com/vitejs/vite-plugin-react/issues/596)) ([87319bf](https://github.com/vitejs/vite-plugin-react/commit/87319bf94ddb07061a1a80d3eefbfadb980f7008)) +* **rsc:** remove console.log ([#607](https://github.com/vitejs/vite-plugin-react/issues/607)) ([2a7ff5c](https://github.com/vitejs/vite-plugin-react/commit/2a7ff5c93e600b06aafc7ce1a6d8a11c2ad4cf2e)) +* **rsc:** tweak changelog ([#570](https://github.com/vitejs/vite-plugin-react/issues/570)) ([8804446](https://github.com/vitejs/vite-plugin-react/commit/88044469a6399c8a1d909b564f6ddc039782c066)) +* **rsc:** update React Router RSC references ([#581](https://github.com/vitejs/vite-plugin-react/issues/581)) ([d464e8f](https://github.com/vitejs/vite-plugin-react/commit/d464e8fc9e8e14bdc84051de9ffacec16317d2ae)) + +### Tests + +* **rsc:** add more basic tests to starter ([#600](https://github.com/vitejs/vite-plugin-react/issues/600)) ([d7fcdd8](https://github.com/vitejs/vite-plugin-react/commit/d7fcdd8550a7a11da01887cbf48a646af898b7f1)) +* **rsc:** add SSR thenable workaround in examples ([#591](https://github.com/vitejs/vite-plugin-react/issues/591)) ([bfd434f](https://github.com/vitejs/vite-plugin-react/commit/bfd434f7fdd063ad017aa3c3a41e42983efc0ef4)) +* **rsc:** add transitive cjs dep example ([#611](https://github.com/vitejs/vite-plugin-react/issues/611)) ([2a81b90](https://github.com/vitejs/vite-plugin-react/commit/2a81b9015286558c1463ab8079a7a6e40a82a5c6)) +* **rsc:** refactor variant tests ([#601](https://github.com/vitejs/vite-plugin-react/issues/601)) ([5167266](https://github.com/vitejs/vite-plugin-react/commit/5167266aff6671065cf5b49cf8ada3d0ace2bbb4)) +* **rsc:** remove global unhandled error handlers ([#597](https://github.com/vitejs/vite-plugin-react/issues/597)) ([c5f0bab](https://github.com/vitejs/vite-plugin-react/commit/c5f0babdc06c813bbef08d3c44ee696789416116)) +* **rsc:** support `fs:cp` command in `setupInlineFixture` ([#621](https://github.com/vitejs/vite-plugin-react/issues/621)) ([d9cb926](https://github.com/vitejs/vite-plugin-react/commit/d9cb92650b217abba4144d62737c5c696b55d0bb)) +* **rsc:** test build with `NODE_ENV=development` and vice versa ([#606](https://github.com/vitejs/vite-plugin-react/issues/606)) ([e8fa2d0](https://github.com/vitejs/vite-plugin-react/commit/e8fa2d0b4cb6e1dd3132fe8b7f45529a74d9be03)) +* **rsc:** test module runner `hmr: false` ([#595](https://github.com/vitejs/vite-plugin-react/issues/595)) ([7223093](https://github.com/vitejs/vite-plugin-react/commit/7223093d793242f3d1ef313bbfec692499f0659e)) + +## [0.4.12](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.11...plugin-rsc@0.4.12) (2025-07-14) +### Features + +* **rsc:** support regex directive for `transformHoistInlineDirective` ([#527](https://github.com/vitejs/vite-plugin-react/issues/527)) ([b598bb5](https://github.com/vitejs/vite-plugin-react/commit/b598bb57d6a7d76bb4ce41ae5990913461949ec3)) + +### Bug Fixes + +* **rsc:** support setups without an SSR environment ([#562](https://github.com/vitejs/vite-plugin-react/issues/562)) ([0fc7fcd](https://github.com/vitejs/vite-plugin-react/commit/0fc7fcdae31568dcd2568a10333ad1e79e2d5176)) + +## [0.4.11](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.10...plugin-rsc@0.4.11) (2025-07-07) +### Miscellaneous Chores + +* fix rsc release ([#543](https://github.com/vitejs/vite-plugin-react/issues/543)) ([58c8bfd](https://github.com/vitejs/vite-plugin-react/commit/58c8bfd1f4e9584d81cb5e85aa466119fd72bbbc)) + +## 0.4.10 (2025-07-07) +### Features + +* add `@vitejs/plugin-rsc` ([#521](https://github.com/vitejs/vite-plugin-react/issues/521)) ([0318334](https://github.com/vitejs/vite-plugin-react/commit/03183346630c73fa58ca4d403785a36913535bb6)) + +### Bug Fixes + +* **deps:** update all non-major dependencies ([#540](https://github.com/vitejs/vite-plugin-react/issues/540)) ([cfe2912](https://github.com/vitejs/vite-plugin-react/commit/cfe29122a8eec6c1e2ed9999531237dbce140e60)) +* return `Plugin[]` instead of `PluginOption[]` ([#537](https://github.com/vitejs/vite-plugin-react/issues/537)) ([11f56d6](https://github.com/vitejs/vite-plugin-react/commit/11f56d63a9ed082137732211db556c784cadb523)) + +## v0.4.10-alpha.1 (2025-07-04) + +- feat: add `@vitejs/plugin-rsc` ([#521](https://github.com/vitejs/vite-plugin-react/pull/521)) + +--- + +Older versions were released as [`@hi-ogawa/vite-rsc`](https://www.npmjs.com/package/@hiogawa/vite-rsc). + +## v0.4.9 (2025-07-03) + +- feat: re-export plugin from base exports entry ([#1125](https://github.com/hi-ogawa/vite-plugins/pull/1125)) +- feat: re-export `transformHoistInlineDirective` ([#1122](https://github.com/hi-ogawa/vite-plugins/pull/1122)) +- fix: don't copy vite manifest from rsc to client ([#1118](https://github.com/hi-ogawa/vite-plugins/pull/1118)) + +## v0.4.8 (2025-07-01) + +- fix: copy all server assets to client by default and output `__vite_rsc_encryption_key` to fs directly ([#1102](https://github.com/hi-ogawa/vite-plugins/pull/1102)) +- fix: stable client build ([#1094](https://github.com/hi-ogawa/vite-plugins/pull/1094)) + +## v0.4.7 (2025-06-28) + +- feat: re-export `encodeReply` and `createTemporaryReferenceSet` from `react-server-dom/client` in `rsc` ([#1089](https://github.com/hi-ogawa/vite-plugins/pull/1089)) +- chore: add `use cache` example ([#1089](https://github.com/hi-ogawa/vite-plugins/pull/1089)) +- refactor: output code without indent ([#1087](https://github.com/hi-ogawa/vite-plugins/pull/1087)) + +## v0.4.6 (2025-06-27) + +- fix: correctly resolve server function created by 3rd party package during dev ([#1067](https://github.com/hi-ogawa/vite-plugins/pull/1067)) +- fix: correctly resolve client boundary created by server package during dev ([#1050](https://github.com/hi-ogawa/vite-plugins/pull/1050)) +- fix: copy only css assets from server build to client build by default ([#1072](https://github.com/hi-ogawa/vite-plugins/pull/1072)) +- fix: fix single quote string in `loadModule('ssr', 'index')` ([#1064](https://github.com/hi-ogawa/vite-plugins/pull/1064)) +- fix: stabilize server build by externalizing encryption key file ([#1069](https://github.com/hi-ogawa/vite-plugins/pull/1069)) +- fix: check build instead of `import.meta.env.DEV` ([#1083](https://github.com/hi-ogawa/vite-plugins/pull/1083)) +- perf: strip code during scan build ([#1066](https://github.com/hi-ogawa/vite-plugins/pull/1066)) +- feat: support preserving client reference original value ([#1078](https://github.com/hi-ogawa/vite-plugins/pull/1078)) +- feat: add `enableActionEncryption` option for debugging purpose ([#1084](https://github.com/hi-ogawa/vite-plugins/pull/1084)) +- feat: add `ignoredClientInServerPackageWarning` option ([#1065](https://github.com/hi-ogawa/vite-plugins/pull/1065)) + +## v0.4.5 (2025-06-22) + +- feat: rsc css transform for default export identifier ([#1046](https://github.com/hi-ogawa/vite-plugins/pull/1046)) +- feat: add `import.meta.viteRsc.loadBootstrapScriptContent` ([#1042](https://github.com/hi-ogawa/vite-plugins/pull/1042)) +- fix: only include jsx/tsx for rsc css export transform ([#1034](https://github.com/hi-ogawa/vite-plugins/pull/1034)) +- fix: ensure server-only and client-only not externalized ([#1045](https://github.com/hi-ogawa/vite-plugins/pull/1045)) +- fix: use static import for `loadCss` virtuals during build ([#1043](https://github.com/hi-ogawa/vite-plugins/pull/1043)) + +## v0.4.4 (2025-06-20) + +- feat: automatic rsc css export transform ([#1030](https://github.com/hi-ogawa/vite-plugins/pull/1030)) +- feat: add plugin to workaround cloudflare error ([#1014](https://github.com/hi-ogawa/vite-plugins/pull/1014)) +- feat: add load module dev proxy ([#1012](https://github.com/hi-ogawa/vite-plugins/pull/1012)) +- feat: add `serverHandler` option to allow using ssr environment as main handler ([#1008](https://github.com/hi-ogawa/vite-plugins/pull/1008)) +- feat: support `loadModule(environment, entry)` ([#1007](https://github.com/hi-ogawa/vite-plugins/pull/1007)) +- refactor: tweak renderHtml types and naming ([#1029](https://github.com/hi-ogawa/vite-plugins/pull/1029)) + +## v0.4.3 (2025-06-18) + +- feat: add rsc css export transform helper ([#1002](https://github.com/hi-ogawa/vite-plugins/pull/1002)) +- feat: support `loadCss(importer)` ([#1001](https://github.com/hi-ogawa/vite-plugins/pull/1001)) + +## v0.4.2 (2025-06-17) + +- fix: allow custom `outDir` + chore: cloudflare single worker setup ([#990](https://github.com/hi-ogawa/vite-plugins/pull/990)) +- fix: transform `__webpack_require__` global ([#980](https://github.com/hi-ogawa/vite-plugins/pull/980)) +- fix: inline and optimize react deps in ssr environment ([#982](https://github.com/hi-ogawa/vite-plugins/pull/982)) +- refactor: resolve self runtime import instead of `dedupe` ([#975](https://github.com/hi-ogawa/vite-plugins/pull/975)) +- refactor: emit assets manifest during `writeBundle` ([#972](https://github.com/hi-ogawa/vite-plugins/pull/972)) +- refactor: use `../` instead of `./../` path in output ([#963](https://github.com/hi-ogawa/vite-plugins/pull/963)) + +## v0.4.1 (2025-06-15) + +- fix: re-publish to fix vendored dependency + +## v0.4.0 (2025-06-15) + +- refactor!: rework multi environment API (bootstrap script) ([#958](https://github.com/hi-ogawa/vite-plugins/pull/958)) +- refactor!: rework multi environment API (ssr module) ([#957](https://github.com/hi-ogawa/vite-plugins/pull/957)) +- refactor!: simplify plugin options in favor of `rollupOptions.input` ([#956](https://github.com/hi-ogawa/vite-plugins/pull/956)) +- feat: expose `rsc-html-stream` utils ([#950](https://github.com/hi-ogawa/vite-plugins/pull/950)) +- fix: fix missing rsc css on build ([#949](https://github.com/hi-ogawa/vite-plugins/pull/949)) + +## v0.3.4 (2025-06-12) + +- fix: fix internal import to allow stable react vendor chunk ([#824](https://github.com/hi-ogawa/vite-plugins/pull/824)) +- fix: compat for old react plugin ([#939](https://github.com/hi-ogawa/vite-plugins/pull/939)) + +## v0.3.3 (2025-06-12) + +- feat: support rolldown-vite ([#931](https://github.com/hi-ogawa/vite-plugins/pull/931)) +- fix: allow usage without react plugin ([#934](https://github.com/hi-ogawa/vite-plugins/pull/934)) +- chore: docs ([#921](https://github.com/hi-ogawa/vite-plugins/pull/921)) + +## v0.3.2 (2025-06-10) + +- feat: auto initialize ([#925](https://github.com/hi-ogawa/vite-plugins/pull/925)) +- fix: emit assets manifest only in server build ([#929](https://github.com/hi-ogawa/vite-plugins/pull/929)) +- refactor: inline react-server-dom in ssr (2) ([#927](https://github.com/hi-ogawa/vite-plugins/pull/927)) +- chore: add `@cloudflare/vite-plugin` example ([#926](https://github.com/hi-ogawa/vite-plugins/pull/926)) + +## v0.3.1 (2025-06-06) + +- refactor: vendor react-server-dom ([#854](https://github.com/hi-ogawa/vite-plugins/pull/854)) + +## v0.3.0 (2025-06-05) + +- feat!: rsc css code split ([#876](https://github.com/hi-ogawa/vite-plugins/pull/876)) +- feat: encrypt closure bind values ([#897](https://github.com/hi-ogawa/vite-plugins/pull/897)) +- fix: client element as bound arg encryption ([#905](https://github.com/hi-ogawa/vite-plugins/pull/905)) +- fix: throw on client reference call on server ([#900](https://github.com/hi-ogawa/vite-plugins/pull/900)) + +## v0.2.4 (2025-05-26) + +- fix: fix stale css import in non-boundary client module ([#887](https://github.com/hi-ogawa/vite-plugins/pull/887)) +- fix: fix non-client-boundary client module hmr in tailwind example ([#886](https://github.com/hi-ogawa/vite-plugins/pull/886)) + +## v0.2.3 (2025-05-22) + +- fix: support Windows ([#884](https://github.com/hi-ogawa/vite-plugins/pull/884)) +- fix: remove stale ssr styles during dev ([#879](https://github.com/hi-ogawa/vite-plugins/pull/879)) +- fix: add `vary` header to avoid rsc payload on tab re-open ([#877](https://github.com/hi-ogawa/vite-plugins/pull/877)) + +## v0.2.2 (2025-05-18) + +- fix: emit server assets and copy to client ([#861](https://github.com/hi-ogawa/vite-plugins/pull/861)) +- fix: css modules hmr ([#860](https://github.com/hi-ogawa/vite-plugins/pull/860)) +- fix: fix `collectCssByUrl` error ([#856](https://github.com/hi-ogawa/vite-plugins/pull/856)) +- fix: show invalid transform error with code frame ([#871](https://github.com/hi-ogawa/vite-plugins/pull/871)) +- perf: preload client reference deps before non-cached import ([#850](https://github.com/hi-ogawa/vite-plugins/pull/850)) + +## v0.2.1 (2025-05-13) + +- feat: automatic client package heuristics ([#830](https://github.com/hi-ogawa/vite-plugins/pull/830)) +- fix: add browser entry to `optimizeDeps.entries` ([#846](https://github.com/hi-ogawa/vite-plugins/pull/846)) +- fix: resolve self package from project root ([#845](https://github.com/hi-ogawa/vite-plugins/pull/845)) +- refactor: use `rsc-html-stream` ([#843](https://github.com/hi-ogawa/vite-plugins/pull/843)) + +## v0.2.0 (2025-05-12) + +- feat: apply tree-shaking to all client references (2nd approach) ([#838](https://github.com/hi-ogawa/vite-plugins/pull/838)) +- feat: support nonce ([#813](https://github.com/hi-ogawa/vite-plugins/pull/813)) +- feat: support css in rsc environment ([#825](https://github.com/hi-ogawa/vite-plugins/pull/825)) +- feat: support css in client references ([#823](https://github.com/hi-ogawa/vite-plugins/pull/823)) +- fix: handle html escape and binary data in ssr rsc payload ([#839](https://github.com/hi-ogawa/vite-plugins/pull/839)) +- fix: wrap virtual to workaround module runner entry issues ([#832](https://github.com/hi-ogawa/vite-plugins/pull/832)) +- fix: scan build in two environments ([#820](https://github.com/hi-ogawa/vite-plugins/pull/820)) +- refactor: simplify client reference mapping ([#836](https://github.com/hi-ogawa/vite-plugins/pull/836)) +- refactor!: remove `entries.css` ([#831](https://github.com/hi-ogawa/vite-plugins/pull/831)) +- refactor: client reference ssr preinit/preload via proxy and remove `prepareDestination` ([#828](https://github.com/hi-ogawa/vite-plugins/pull/828)) +- refactor: tweak asset links api ([#826](https://github.com/hi-ogawa/vite-plugins/pull/826)) + +## v0.1.1 (2025-05-07) + +- fix: statically import client references virtual ([#815](https://github.com/hi-ogawa/vite-plugins/pull/815)) +- fix: fix base for findSourceMapURL ([#812](https://github.com/hi-ogawa/vite-plugins/pull/812)) +- fix: fix module runner line offset in `findSourceMapURL` ([#810](https://github.com/hi-ogawa/vite-plugins/pull/810)) + +## v0.1.0 (2025-05-01) + +- feat: support `findSourceMapURL` for `createServerReference` ([#796](https://github.com/hi-ogawa/vite-plugins/pull/796)) +- feat: support `findSourceMapURL` for component stack and replay logs ([#779](https://github.com/hi-ogawa/vite-plugins/pull/779)) +- feat: support temporary references ([#776](https://github.com/hi-ogawa/vite-plugins/pull/776)) +- feat: support custom base ([#775](https://github.com/hi-ogawa/vite-plugins/pull/775)) +- feat: refactor assets manifest and expose it to rsc build ([#767](https://github.com/hi-ogawa/vite-plugins/pull/767)) +- feat: ssr modulepreload only for build ([#763](https://github.com/hi-ogawa/vite-plugins/pull/763)) +- feat: tree shake unused reference exports ([#761](https://github.com/hi-ogawa/vite-plugins/pull/761)) +- feat: re-export react-server-dom ([#744](https://github.com/hi-ogawa/vite-plugins/pull/744)) +- feat: support css entry ([#737](https://github.com/hi-ogawa/vite-plugins/pull/737)) +- feat wrap client packages in virtual (support `clientPackages` options) ([#718](https://github.com/hi-ogawa/vite-plugins/pull/718)) +- feat: modulepreload client reference on ssr ([#703](https://github.com/hi-ogawa/vite-plugins/pull/703)) +- feat: create vite-rsc ([#692](https://github.com/hi-ogawa/vite-plugins/pull/692)) diff --git a/packages/plugin-rsc/CONTRIBUTING.md b/packages/plugin-rsc/CONTRIBUTING.md new file mode 100644 index 000000000..7de848fb2 --- /dev/null +++ b/packages/plugin-rsc/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing to @vitejs/plugin-rsc + +This guide provides essential tips for contributors working on the RSC plugin. + +## Testing + +### E2E Test Setup + +Tests use Playwright and are located in `e2e/` and use `examples` as test apps. + +#### Test Fixture Patterns + +- `examples/basic` - comprehensive test suite for the RSC plugin +- `examples/starter` - lightweight base template for writing more targeted tests using `setupInlineFixture` utility +- `examples/e2e/temp/` - base directory for test projects + +### Adding New Test Cases + +**Expanding `examples/basic` (for comprehensive features)** +Best for features that should be part of the main test suite. `examples/basic` is mainly used for e2e testing: + +1. Add your test case files to `examples/basic/src/routes/` +2. Update the routing in `examples/basic/src/routes/root.tsx` +3. Add corresponding tests in `e2e/basic.test.ts` + +**Using `setupInlineFixture` (for specific edge cases)** +Best for testing specific edge cases or isolated features. See `e2e/ssr-thenable.test.ts` for the pattern. + + + +## Development Workflow + + + +```bash +# Build packages +pnpm dev # pnpm -C packages/plugin-rsc dev + +# Type check +pnpm -C packages/plugin-rsc tsc-dev + +# Run examples +pnpm -C packages/plugin-rsc/examples/basic dev # build / preview +pnpm -C packages/plugin-rsc/examples/starter dev # build / preview + +# Run all e2e tests +pnpm -C packages/plugin-rsc test-e2e + +# Run with UI (this allows filtering interactively) +pnpm -C packages/plugin-rsc test-e2e --ui + +# Run specific test file +pnpm -C packages/plugin-rsc test-e2e basic + +# Run with filter/grep +pnpm -C packages/plugin-rsc test-e2e -g "hmr" + +# Test projects created with `setupInlineFixture` are locally runnable. For example: +pnpm -C packages/plugin-rsc/examples/e2e/temp/react-compiler dev +``` + +## Tips + +- Prefer `setupInlineFixture` for new tests - it's more maintainable and faster +- The `examples/basic` project contains comprehensive test scenarios +- Dependencies for temp test projects are managed in `examples/e2e/package.json` diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md new file mode 100644 index 000000000..76575fe93 --- /dev/null +++ b/packages/plugin-rsc/README.md @@ -0,0 +1,506 @@ +# @vitejs/plugin-rsc + +This package provides [React Server Components](https://react.dev/reference/rsc/server-components) (RSC) support for Vite. + +## Features + +- **Framework-agnostic**: The plugin implements [RSC bundler features](https://react.dev/reference/rsc/server-components) and provides low level RSC runtime (`react-server-dom`) API without framework-specific abstractions. +- **Runtime-agnostic**: Built on [Vite environment API](https://vite.dev/guide/api-environment.html) and works with other runtimes (e.g., [`@cloudflare/vite-plugin`](https://github.com/cloudflare/workers-sdk/tree/main/packages/vite-plugin-cloudflare)). +- **HMR support**: Enables editing both client and server components without full page reloads. +- **CSS support**: CSS is automatically code-split both at client and server components and they are injected upon rendering. + +## Getting Started + +You can create a starter project by: + +```sh +npx degit vitejs/vite-plugin-react/packages/plugin-rsc/examples/starter my-app +``` + +## Examples + +**Start here:** [`./examples/starter`](./examples/starter) - Recommended for understanding the package + +- Provides an in-depth overview of API with inline comments to explain how they function within RSC-powered React application. + +**Integration examples:** + +- [`./examples/basic`](./examples/basic) - Advanced RSC features and testing + - This is mainly used for e2e testing and includes various advanced RSC usages (e.g. `"use cache"` example). +- [`./examples/ssg`](./examples/ssg) - Static site generation with MDX and client components for interactivity. +- [`./examples/react-router`](./examples/react-router) - React Router RSC integration + - Demonstrates how to integrate [experimental React Router RSC API](https://remix.run/blog/rsc-preview). React Router now provides [official RSC support](https://reactrouter.com/how-to/react-server-components), so it's recommended to follow React Router's official documentation for the latest integration. + +## Basic Concepts + +This example is a simplified version of [`./examples/starter`](./examples/starter). You can read [`./examples/starter/src/framework/entry.{rsc,ssr,browser}.tsx`](./examples/starter/src/framework) for more in-depth commentary, which includes server function handling and client-side RSC re-fetching/re-rendering. + +This is the diagram to show the basic flow of RSC rendering process. See also https://github.com/hi-ogawa/vite-plugins/discussions/606. + +```mermaid +graph TD + + subgraph "rsc environment" + A["React virtual dom tree"] --> |"[@vitejs/plugin-rsc/rsc]
renderToReadableStream"| B1["RSC Stream"]; + end + + B1 --> B2 + B1 --> B3 + + subgraph "ssr environment" + B2["RSC Stream"] --> |"[@vitejs/plugin-rsc/ssr]
createFromReadableStream"| C1["React virtual dom tree"]; + C1 --> |"[react-dom/server]
SSR"| E["HTML String/Stream"]; + end + + subgraph "client environment" + B3["RSC Stream"] --> |"[@vitejs/plugin-rsc/browser]
createFromReadableStream"| C2["React virtual dom tree"]; + C2 --> |"[react-dom/client]
CSR: mount, hydration"| D["DOM Elements"]; + end + + style A fill:#D6EAF8,stroke:#333,stroke-width:2px + style B1 fill:#FEF9E7,stroke:#333,stroke-width:2px + style B2 fill:#FEF9E7,stroke:#333,stroke-width:2px + style B3 fill:#FEF9E7,stroke:#333,stroke-width:2px + style C1 fill:#D6EAF8,stroke:#333,stroke-width:2px + style C2 fill:#D6EAF8,stroke:#333,stroke-width:2px + style D fill:#D5F5E3,stroke:#333,stroke-width:2px + style E fill:#FADBD8,stroke:#333,stroke-width:2px +``` + +- [`vite.config.ts`](./examples/starter/vite.config.ts) + +```js +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + // add plugin + rsc(), + ], + + // specify entry point for each environment. + environments: { + // `rsc` environment loads modules with `react-server` condition. + // this environment is responsible for: + // - RSC stream serialization (React VDOM -> RSC stream) + // - server functions handling + rsc: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.rsc.tsx', + }, + }, + }, + }, + + // `ssr` environment loads modules without `react-server` condition. + // this environment is responsible for: + // - RSC stream deserialization (RSC stream -> React VDOM) + // - traditional SSR (React VDOM -> HTML string/stream) + ssr: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.ssr.tsx', + }, + }, + }, + }, + + // client environment is used for hydration and client-side rendering + // this environment is responsible for: + // - RSC stream deserialization (RSC stream -> React VDOM) + // - traditional CSR (React VDOM -> Browser DOM tree mount/hydration) + // - refetch and re-render RSC + // - calling server functions + client: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.browser.tsx', + }, + }, + }, + }, + }, +}) +``` + +- [`entry.rsc.tsx`](./examples/starter/src/framework/entry.rsc.tsx) + +```tsx +import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc' + +// the plugin assumes `rsc` entry having default export of request handler +export default async function handler(request: Request): Promise { + // serialize React VDOM to RSC stream + const root = ( + + +

Test

+ + + ) + const rscStream = renderToReadableStream(root) + + // respond direct RSC stream request based on framework's convention + if (request.url.endsWith('.rsc')) { + return new Response(rscStream, { + headers: { + 'Content-type': 'text/x-component;charset=utf-8', + }, + }) + } + + // delegate to SSR environment for html rendering + // `loadModule` is a helper API provided by the plugin for multi environment interaction. + const ssrEntry = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + const htmlStream = await ssrEntry.handleSsr(rscStream) + + // respond html + return new Response(htmlStream, { + headers: { + 'Content-type': 'text/html', + }, + }) +} + +// add `import.meta.hot.accept` to handle server module change efficiently +if (import.meta.hot) { + import.meta.hot.accept() +} +``` + +- [`entry.ssr.tsx`](./examples/starter/src/framework/entry.ssr.tsx) + +```tsx +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import { renderToReadableStream } from 'react-dom/server.edge' + +export async function handleSsr(rscStream: ReadableStream) { + // deserialize RSC stream back to React VDOM + const root = await createFromReadableStream(rscStream) + + // helper API to allow referencing browser entry content from SSR environment + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + + // render html (traditional SSR) + const htmlStream = renderToReadableStream(root, { + bootstrapScriptContent, + }) + + return htmlStream +} +``` + +- [`entry.browser.tsx`](./examples/starter/src/framework/entry.browser.tsx) + +```tsx +import { createFromReadableStream } from '@vitejs/plugin-rsc/browser' +import { hydrateRoot } from 'react-dom/client' + +async function main() { + // fetch and deserialize RSC stream back to React VDOM + const rscResponse = await fetch(window.location.href + '.rsc') + const root = await createFromReadableStream(rscResponse.body) + + // hydration (traditional CSR) + hydrateRoot(document, root) +} + +main() +``` + +## Environment helper API + +The plugin provides an additional helper for multi environment interaction. + +### Available on `rsc` or `ssr` environment + +#### `import.meta.viteRsc.loadModule` + +- Type: `(environmentName: "ssr" | "rsc", entryName: string) => Promise` + +This allows importing `ssr` environment module specified by `environments.ssr.build.rollupOptions.input[entryName]` inside `rsc` environment and vice versa. + +During development, by default, this API assumes both `rsc` and `ssr` environments execute under the main Vite process. When enabling `rsc({ loadModuleDevProxy: true })` plugin option, the loaded module is implemented as a proxy with `fetch`-based RPC to call in node environment on the main Vite process, which for example, allows `rsc` environment inside cloudflare workers to access `ssr` environment on the main Vite process. + +During production build, this API will be rewritten into a static import of the specified entry of other environment build and the modules are executed inside the same runtime. + +For example, + +```js +// ./entry.rsc.tsx +const ssrModule = await import.meta.viteRsc.loadModule("ssr", "index"); +ssrModule.renderHTML(...); + +// ./entry.ssr.tsx (with environments.ssr.build.rollupOptions.input.index = "./entry.ssr.tsx") +export function renderHTML(...) {} +``` + +### Available on `rsc` environment + +#### `import.meta.viteRsc.loadCss` + +> [!NOTE] +> The plugin automatically injects CSS for server components. See the [CSS Support](#css-support) section for detailed information about automatic CSS injection. + +- Type: `(importer?: string) => React.ReactNode` + +This allows collecting css which is imported through a current server module and injecting them inside server components. + +```tsx +import './test.css' +import dep from './dep.tsx' + +export function ServerPage() { + // this will include css assets for "test.css" + // and any css transitively imported through "dep.tsx" + return ( + <> + {import.meta.viteRsc.loadCss()} + ... + + ) +} +``` + +When specifying `loadCss()`, it will collect css through the server module resolved by ``. + +```tsx +// virtual:my-framework-helper +export function Assets() { + return <> + {import.meta.viteRsc.loadCss("/routes/home.tsx")} + {import.meta.viteRsc.loadCss("/routes/about.tsx")} + {...} + +} + +// user-app.tsx +import { Assets } from "virtual:my-framework-helper"; + +export function UserApp() { + return + + + + ... + +} +``` + +### Available on `ssr` environment + +#### `import.meta.viteRsc.loadBootstrapScriptContent("index")` + +This provides a raw js code to execute a browser entry file specified by `environments.client.build.rollupOptions.input.index`. This is intended to be used with React DOM SSR API, such as [`renderToReadableStream`](https://react.dev/reference/react-dom/server/renderToReadableStream) + +```js +import { renderToReadableStream } from 'react-dom/server.edge' + +const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') +const htmlStream = await renderToReadableStream(reactNode, { + bootstrapScriptContent, +}) +``` + +### Available on `client` environment + +#### `rsc:update` event + +This event is fired when server modules are updated, which can be used to trigger re-fetching and re-rendering of RSC components on browser. + +```js +import { createFromFetch } from '@vitejs/plugin-rsc/browser' + +import.meta.hot.on('rsc:update', async () => { + // re-fetch RSC stream + const rscPayload = await createFromFetch(fetch(window.location.href + '.rsc')) + // re-render ... +}) +``` + +## Plugin API + +### `@vitejs/plugin-rsc` + +- Type: `rsc: (options?: RscPluginOptions) => Plugin[]`; + +```js +import rsc from '@vitejs/plugin-rsc' +import { defineConfig } from 'vite' + +export default defineConfig({ + plugins: [ + rsc({ + // this is only a shorthand of specifying each rollup input via + // `environments[name].build.rollupOptions.input.index` + entries: { + rsc: '...', + ssr: '...', + client: '...', + }, + + // by default, the plugin sets up middleware + // using `default` export of `rsc` environment `index` entry. + // this behavior can be customized by `serverHandler` option. + serverHandler: false, + + // when `loadModuleDevProxy: true`, `import.meta.viteRsc.loadModule` is implemented + // through `fetch` based RPC, which allows, for example, rsc environment inside + // cloudflare workers to communicate with node ssr environment on main Vite process. + loadModuleDevProxy: true, + + // by default, `loadCss()` helper is injected based on certain heuristics. + // if it breaks, it can be opt-out or selectively applied based on files. + rscCssTransform: { filter: (id) => id.includes('/my-app/') }, + + // by default, the plugin uses a build-time generated encryption key for + // "use server" closure argument binding. + // This can be overwritten by configuring `defineEncryptionKey` option, + // for example, to obtain a key through environment variable during runtime. + // cf. https://nextjs.org/docs/app/guides/data-security#overwriting-encryption-keys-advanced + defineEncryptionKey: 'process.env.MY_ENCRYPTION_KEY', + + // see `RscPluginOptions` for full options ... + }), + ], + // the same options can be also specified via top-level `rsc` property. + // this allows other plugin to set options via `config` hook. + rsc: { + // ... + }, +}) +``` + +## RSC runtime (react-server-dom) API + +### `@vitejs/plugin-rsc/rsc` + +This module re-exports RSC runtime API provided by `react-server-dom/server.edge` and `react-server-dom/client.edge` such as: + +- `renderToReadableStream`: RSC serialization (React VDOM -> RSC stream) +- `createFromReadableStream`: RSC deserialization (RSC stream -> React VDOM). This is also available on rsc environment itself. For example, it allows saving serialized RSC and deserializing it for later use. +- `decodeAction/decodeReply/decodeFormState/loadServerAction/createTemporaryReferenceSet` +- `encodeReply/createClientTemporaryReferenceSet` + +### `@vitejs/plugin-rsc/ssr` + +This module re-exports RSC runtime API provided by `react-server-dom/client.edge` + +- `createFromReadableStream`: RSC deserialization (RSC stream -> React VDOM) + +### `@vitejs/plugin-rsc/browser` + +This module re-exports RSC runtime API provided by `react-server-dom/client.browser` + +- `createFromReadableStream`: RSC deserialization (RSC stream -> React VDOM) +- `createFromFetch`: a robust way of `createFromReadableStream((await fetch("...")).body)` +- `encodeReply/setServerCallback`: server function related... + +## CSS Support + +The plugin automatically handles CSS code-splitting and injection for server components. This eliminates the need to manually call [`import.meta.viteRsc.loadCss()`](#importmetaviterscloadcss) in most cases. + +1. **Component Detection**: The plugin automatically detects server components by looking for: + - Function exports with capital letter names (e.g., `export function Page() {}`) + - Default exports that are functions with capital names (e.g., `export default function Page() {}`) + - Const exports assigned to functions with capital names (e.g., `export const Page = () => {}`) + +2. **CSS Import Detection**: For detected components, the plugin checks if the module imports any CSS files (`.css`, `.scss`, `.sass`, etc.) + +3. **Automatic Wrapping**: When both conditions are met, the plugin wraps the component with a CSS injection wrapper: + +```tsx +// Before transformation +import './styles.css' + +export function Page() { + return
Hello
+} + +// After transformation +import './styles.css' + +export function Page() { + return ( + <> + {import.meta.viteRsc.loadCss()} +
Hello
+ + ) +} +``` + +## Canary and Experimental channel releases + +See https://github.com/vitejs/vite-plugin-react/pull/524 for how to install the package for React [canary](https://react.dev/community/versioning-policy#canary-channel) and [experimental](https://react.dev/community/versioning-policy#all-release-channels) usages. + +## Using `@vitejs/plugin-rsc` as a framework package's `dependencies` + +By default, `@vitejs/plugin-rsc` is expected to be used as `peerDependencies` similar to `react` and `react-dom`. When `@vitejs/plugin-rsc` is not available at the project root (e.g., in `node_modules/@vitejs/plugin-rsc`), you will see warnings like: + +```sh +Failed to resolve dependency: @vitejs/plugin-rsc/vendor/react-server-dom/client.browser, present in client 'optimizeDeps.include' +``` + +This can be fixed by updating `optimizeDeps.include` to reference `@vitejs/plugin-rsc` through your framework package. For example, you can add the following plugin: + +```js +// package name is "my-rsc-framework" +export default function myRscFrameworkPlugin() { + return { + name: 'my-rsc-framework:config', + configEnvironment(_name, config) { + if (config.optimizeDeps?.include) { + config.optimizeDeps.include = config.optimizeDeps.include.map( + (entry) => { + if (entry.startsWith('@vitejs/plugin-rsc')) { + entry = `my-rsc-framework > ${entry}` + } + return entry + }, + ) + } + }, + } +} +``` + +## Typescript + +Types for global API are defined in `@vitejs/plugin-rsc/types`. For example, you can add it to `tsconfig.json` to have types for `import.meta.viteRsc` APIs: + +```json +{ + "compilerOptions": { + "types": ["vite/client", "@vitejs/plugin-rsc/types"] + } +} +``` + +```ts +import.meta.viteRsc.loadModule +// ^^^^^^^^^^ +// (environmentName: string, entryName: string) => Promise +``` + +See also [Vite documentation](https://vite.dev/guide/api-hmr.html#intellisense-for-typescript) for `vite/client` types. + +## Credits + +This project builds on fundamental techniques and insights from pioneering Vite RSC implementations. +Additionally, Parcel and React Router's work on standardizing the RSC bundler/app responsibility has guided this plugin's API design: + +- [Waku](https://github.com/wakujs/waku) +- [@lazarv/react-server](https://github.com/lazarv/react-server) +- [@jacob-ebey/vite-react-server-dom](https://github.com/jacob-ebey/vite-plugins/tree/main/packages/vite-react-server-dom) +- [React Router RSC](https://remix.run/blog/rsc-preview) +- [Parcel RSC](https://parceljs.org/recipes/rsc) diff --git a/packages/plugin-rsc/e2e/base.test.ts b/packages/plugin-rsc/e2e/base.test.ts new file mode 100644 index 000000000..1f651b9d0 --- /dev/null +++ b/packages/plugin-rsc/e2e/base.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test' +import { setupInlineFixture, useFixture, type Fixture } from './fixture' +import { defineStarterTest } from './starter' + +test.describe(() => { + const root = 'examples/e2e/temp/base' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'vite.config.base.ts': { cp: 'vite.config.ts' }, + 'vite.config.ts': /* js */ ` + import { defineConfig, mergeConfig } from 'vite' + import baseConfig from './vite.config.base.ts' + + const overrideConfig = defineConfig({ + base: '/custom-base/', + }) + + export default mergeConfig(baseConfig, overrideConfig) + `, + }, + }) + }) + + test.describe('dev-base', () => { + const f = useFixture({ root, mode: 'dev' }) + const f2: Fixture = { + ...f, + url: (url) => new URL(url ?? './', f.url('./custom-base/')).href, + } + defineStarterTest(f2) + testRequestUrl(f2) + }) + + test.describe('build-base', () => { + const f = useFixture({ root, mode: 'build' }) + const f2: Fixture = { + ...f, + url: (url) => new URL(url ?? './', f.url('./custom-base/')).href, + } + defineStarterTest(f2) + testRequestUrl(f2) + }) + + function testRequestUrl(f: Fixture) { + test('request url', async ({ page }) => { + await page.goto(f.url()) + await page.waitForSelector('#root') + await expect(page.locator('.card').nth(2)).toHaveText( + `Request URL: ${f.url()}`, + ) + }) + } +}) diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts new file mode 100644 index 000000000..7e9f48804 --- /dev/null +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -0,0 +1,1465 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { type Page, expect, test } from '@playwright/test' +import { type Fixture, useCreateEditor, useFixture } from './fixture' +import { + expectNoPageError, + expectNoReload, + testNoJs, + waitForHydration, +} from './helper' +import { x } from 'tinyexec' +import { normalizePath, type Rollup } from 'vite' +import path from 'node:path' + +test.describe('dev-default', () => { + const f = useFixture({ root: 'examples/basic', mode: 'dev' }) + defineTest(f) +}) + +test.describe('dev-initial', () => { + const f = useFixture({ root: 'examples/basic', mode: 'dev' }) + + // verify css is collected properly on server startup (i.e. empty module graph) + testNoJs('style', async ({ page }) => { + await page.goto(f.url('./')) + await expect(page.locator('.test-style-client')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + await expect(page.locator('.test-style-server')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + await expect(page.locator('.test-tw-client')).toHaveCSS( + 'color', + // blue-500 + 'rgb(0, 0, 255)', + ) + await expect(page.locator('.test-tw-server')).toHaveCSS( + 'color', + // red-500 + 'rgb(255, 0, 0)', + ) + }) +}) + +test.describe('build-default', () => { + const f = useFixture({ root: 'examples/basic', mode: 'build' }) + defineTest(f) + + test('server-chunk-based client chunks', async () => { + const { chunks }: { chunks: Rollup.OutputChunk[] } = JSON.parse( + f.createEditor('dist/client/.vite/test.json').read(), + ) + const expectedGroups = { + 'facade:src/routes/chunk2/client1.tsx': ['src/routes/chunk2/client1.tsx'], + 'facade:src/routes/chunk2/server2.tsx': [ + 'src/routes/chunk2/client2.tsx', + 'src/routes/chunk2/client2b.tsx', + ], + 'shared:src/routes/chunk2/client3.tsx': ['src/routes/chunk2/client3.tsx'], + } + const actualGroups: Record = {} + for (const key in expectedGroups) { + const groupId = `\0virtual:vite-rsc/client-references/group/${key}` + const groupChunk = chunks.find((c) => c.facadeModuleId === groupId) + if (groupChunk) { + actualGroups[key] = groupChunk.moduleIds + .filter((id) => id !== groupId) + .map((id) => normalizePath(path.relative(f.root, id))) + } + } + expect(actualGroups).toEqual(expectedGroups) + }) +}) + +test.describe('custom-client-chunks', () => { + const f = useFixture({ + root: 'examples/basic', + mode: 'build', + cliOptions: { + env: { + TEST_CUSTOM_CLIENT_CHUNKS: 'true', + }, + }, + }) + + test('basic', async () => { + const { chunks }: { chunks: Rollup.OutputChunk[] } = JSON.parse( + f.createEditor('dist/client/.vite/test.json').read(), + ) + const chunk = chunks.find((c) => c.name === 'custom-chunk') + const expected = [1, 2, 3].map((i) => + normalizePath(path.join(f.root, `src/routes/chunk/client${i}.tsx`)), + ) + expect(chunk?.moduleIds).toEqual(expect.arrayContaining(expected)) + }) +}) + +test.describe('dev-non-optimized-cjs', () => { + test.beforeAll(async () => { + // remove explicitly added optimizeDeps.include + const editor = f.createEditor('vite.config.ts') + editor.edit((s) => + s.replace( + `include: ['@vitejs/test-dep-transitive-cjs > @vitejs/test-dep-cjs'],`, + ``, + ), + ) + }) + + const f = useFixture({ + root: 'examples/basic', + mode: 'dev', + cliOptions: { + env: { + DEBUG: 'vite-rsc:cjs', + }, + }, + }) + + test('show warning', async ({ page }) => { + await page.goto(f.url()) + expect(f.proc().stderr()).toMatch( + /non-optimized CJS dependency in 'ssr' environment.*@vitejs\/test-dep-cjs\/index.js/, + ) + }) +}) + +test.describe('dev-inconsistent-client-optimization', () => { + test.beforeAll(async () => { + // remove explicitly added optimizeDeps.exclude + const editor = f.createEditor('vite.config.ts') + editor.edit((s) => + s.replace(`'@vitejs/test-dep-client-in-server2/client',`, ``), + ) + }) + + const f = useFixture({ + root: 'examples/basic', + mode: 'dev', + }) + + test('show warning', async ({ page }) => { + await page.goto(f.url()) + expect(f.proc().stderr()).toContain( + 'client component dependency is inconsistently optimized.', + ) + }) +}) + +test.describe('build-stable-chunks', () => { + const root = 'examples/basic' + const createEditor = useCreateEditor(root) + + test('basic', async () => { + // 1st build + await x('pnpm', ['build'], { + throwOnError: true, + nodeOptions: { + cwd: root, + }, + }) + const manifest1: import('vite').Manifest = JSON.parse( + createEditor('dist/client/.vite/manifest.json').read(), + ) + + // edit src/routes/client.tsx + const editor = createEditor('src/routes/client.tsx') + editor.edit((s) => s.replace('client-counter', 'client-counter-v2')) + + // 2nd build + await x('pnpm', ['build'], { + throwOnError: true, + nodeOptions: { + cwd: root, + }, + }) + const manifest2: import('vite').Manifest = JSON.parse( + createEditor('dist/client/.vite/manifest.json').read(), + ) + + // compare two mainfest.json + const files1 = new Set(Object.values(manifest1).map((v) => v.file)) + const files2 = new Set(Object.values(manifest2).map((v) => v.file)) + const oldChunks = Object.entries(manifest2) + .filter(([_k, v]) => !files1.has(v.file)) + .map(([k]) => k) + .sort() + const newChunks = Object.entries(manifest1) + .filter(([_k, v]) => !files2.has(v.file)) + .map(([k]) => k) + .sort() + expect(newChunks).toEqual([ + 'src/framework/entry.browser.tsx', + 'virtual:vite-rsc/client-references/group/facade:src/routes/root.tsx', + ]) + expect(oldChunks).toEqual(newChunks) + }) +}) + +function defineTest(f: Fixture) { + test('basic', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + expect(f.proc().stderr()).toBe('') + }) + + test('client component', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await page.getByRole('button', { name: 'client-counter: 0' }).click() + await page.getByRole('button', { name: 'client-counter: 1' }).click() + }) + + test('server action @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await testAction(page) + }) + + testNoJs('server action @nojs', async ({ page }) => { + await page.goto(f.url()) + await testAction(page) + }) + + async function testAction(page: Page) { + await page.getByRole('button', { name: 'server-counter: 0' }).click() + await page.getByRole('button', { name: 'server-counter: 1' }).click() + await expect( + page.getByRole('button', { name: 'server-counter: 2' }), + ).toBeVisible() + await page.getByRole('button', { name: 'server-counter-reset' }).click() + await expect( + page.getByRole('button', { name: 'server-counter: 0' }), + ).toBeVisible() + } + + test('useActionState @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await testUseActionState(page) + }) + + testNoJs('useActionState @nojs', async ({ page }) => { + await page.goto(f.url()) + await testUseActionState(page) + }) + + test('useActionState nojs to js', async ({ page, browserName }) => { + // firefox seems to cache html and route interception doesn't work + test.skip(browserName === 'firefox') + + // this test fails without `formState` passed to `hydrateRoot(..., { formState })` + + // intercept request to disable js + let js: boolean + await page.route(f.url(), async (route) => { + if (!js) { + await route.continue({ url: route.request().url() + '?__nojs' }) + return + } + await route.continue() + }) + + // no js + js = false + await page.goto(f.url()) + await expect(page.getByTestId('use-action-state')).toContainText( + 'test-useActionState: 0', + ) + await page.getByTestId('use-action-state').click() + await expect(page.getByTestId('use-action-state')).toContainText( + 'test-useActionState: 1', + ) + + // with js (hydration) + js = true + await page.getByTestId('use-action-state').click() + await waitForHydration(page) + await expect(page.getByTestId('use-action-state')).toContainText( + 'test-useActionState: 2', // this becomes "0" without formState + ) + }) + + async function testUseActionState(page: Page) { + await expect(page.getByTestId('use-action-state')).toContainText( + 'test-useActionState: 0', + ) + await page.getByTestId('use-action-state').click() + await expect(page.getByTestId('use-action-state')).toContainText( + 'test-useActionState: 1', + ) + await page.getByTestId('use-action-state').click() + await expect(page.getByTestId('use-action-state')).toContainText( + 'test-useActionState: 2', + ) + } + + test('useActionState with jsx @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await testUseActionStateJsx(page) + }) + + testNoJs('useActionState with jsx @nojs', async ({ page }) => { + await page.goto(f.url()) + await testUseActionStateJsx(page, { js: false }) + }) + + async function testUseActionStateJsx(page: Page, options?: { js?: boolean }) { + await page.getByTestId('use-action-state-jsx').getByRole('button').click() + await expect(page.getByTestId('use-action-state-jsx')).toContainText( + /\(ok\)/, + ) + + // 1st call "works" but it shows an error during reponse and it breaks 2nd call. + // Failed to serialize an action for progressive enhancement: + // Error: React Element cannot be passed to Server Functions from the Client without a temporary reference set. Pass a TemporaryReferenceSet to the options. + // [Promise, ] + if (!options?.js) return + + await page.getByTestId('use-action-state-jsx').getByRole('button').click() + await expect(page.getByTestId('use-action-state-jsx')).toContainText( + /\(ok\).*\(ok\)/, + ) + } + + test.describe(() => { + test.skip(f.mode !== 'build') + + testNoJs('module preload on ssr', async ({ page }) => { + await page.goto(f.url()) + const srcs = await page + .locator(`head >> link[rel="modulepreload"]`) + .evaluateAll((elements) => + elements.map((el) => el.getAttribute('href')), + ) + const manifest = JSON.parse( + readFileSync( + f.root + '/dist/ssr/__vite_rsc_assets_manifest.js', + 'utf-8', + ).slice('export default '.length), + ) + const hashString = (v: string) => + createHash('sha256').update(v).digest().toString('hex').slice(0, 12) + const deps = + manifest.clientReferenceDeps[hashString('src/routes/client.tsx')] + expect(srcs).toEqual(expect.arrayContaining(deps.js)) + }) + }) + + test.describe(() => { + test.skip(f.mode !== 'dev') + + test('server reference update @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await testServerActionUpdate(page, { js: true }) + }) + + test('server reference update @nojs', async ({ page }) => { + await page.goto(f.url()) + await testServerActionUpdate(page, { js: false }) + }) + }) + + async function testServerActionUpdate(page: Page, options: { js: boolean }) { + await page.getByRole('button', { name: 'server-counter: 0' }).click() + await expect( + page.getByRole('button', { name: 'server-counter: 1' }), + ).toBeVisible() + + // update server code + const editor = f.createEditor('src/routes/action/action.tsx') + editor.edit((s) => + s.replace('const TEST_UPDATE = 1\n', 'const TEST_UPDATE = 10\n'), + ) + await expect(async () => { + if (!options.js) await page.goto(f.url()) + await expect( + page.getByRole('button', { name: 'server-counter: 0' }), + ).toBeVisible({ timeout: 10 }) + }).toPass() + + await page.getByRole('button', { name: 'server-counter: 0' }).click() + await expect( + page.getByRole('button', { name: 'server-counter: 10' }), + ).toBeVisible() + + editor.reset() + await expect(async () => { + if (!options.js) await page.goto(f.url()) + await expect( + page.getByRole('button', { name: 'server-counter: 0' }), + ).toBeVisible({ timeout: 10 }) + }).toPass() + } + + test.describe(() => { + test.skip(f.mode !== 'dev') + + test('client hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await page.getByRole('button', { name: 'client-counter: 0' }).click() + await expect( + page.getByRole('button', { name: 'client-counter: 1' }), + ).toBeVisible() + + const editor = f.createEditor('src/routes/client.tsx') + editor.edit((s) => s.replace('client-counter', 'client-[edit]-counter')) + await expect( + page.getByRole('button', { name: 'client-[edit]-counter: 1' }), + ).toBeVisible() + + // check next ssr is also updated + const res = await page.goto(f.url()) + expect(await res?.text()).toContain('client-[edit]-counter') + await waitForHydration(page) + editor.reset() + await page.getByRole('button', { name: 'client-counter: 0' }).click() + }) + + test('non-client-reference client hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + + const locator = page.getByTestId('test-hmr-client-dep') + await expect(locator).toHaveText('test-hmr-client-dep: 0[ok]') + await locator.locator('button').click() + await expect(locator).toHaveText('test-hmr-client-dep: 1[ok]') + + const editor = f.createEditor('src/routes/hmr-client-dep/client-dep.tsx') + editor.edit((s) => s.replace('[ok]', '[ok-edit]')) + await expect(locator).toHaveText('test-hmr-client-dep: 1[ok-edit]') + + // check next rsc payload includes current client reference and preserves state + await page.locator("a[href='?test-hmr-client-dep-re-render']").click() + await expect( + page.locator("a[href='?test-hmr-client-dep-re-render']"), + ).toHaveText('re-render [ok]') + await expect(locator).toHaveText('test-hmr-client-dep: 1[ok-edit]') + + // check next ssr is also updated + const res = await page.request.get(f.url(), { + headers: { + accept: 'text/html', + }, + }) + expect(await res?.text()).toContain('[ok-edit]') + + editor.reset() + await expect(locator).toHaveText('test-hmr-client-dep: 1[ok]') + }) + + test('non-self-accepting client hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + + const locator = page.getByTestId('test-hmr-client-dep2') + await expect(locator).toHaveText('test-hmr-client-dep2: 0[ok]') + await locator.locator('button').click() + await expect(locator).toHaveText('test-hmr-client-dep2: 1[ok]') + + const editor = f.createEditor('src/routes/hmr-client-dep2/client-dep.ts') + editor.edit((s) => s.replace('[ok]', '[ok-edit]')) + await expect(locator).toHaveText('test-hmr-client-dep2: 1[ok-edit]') + + // check next rsc payload includes an updated client reference and preserves state + await page.locator("a[href='?test-hmr-client-dep2-re-render']").click() + await expect( + page.locator("a[href='?test-hmr-client-dep2-re-render']"), + ).toHaveText('re-render [ok]') + await expect(locator).toHaveText('test-hmr-client-dep2: 1[ok-edit]') + + // check next ssr is also updated + const res = await page.request.get(f.url(), { + headers: { + accept: 'text/html', + }, + }) + expect(await res?.text()).toContain('[ok-edit]') + + editor.reset() + await expect(locator).toHaveText('test-hmr-client-dep2: 1[ok]') + }) + + test('server hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + const editor = f.createEditor('src/routes/action/server.tsx') + editor.edit((s) => s.replace('server-counter', 'server-[edit]-counter')) + await expect( + page.getByRole('button', { name: 'server-[edit]-counter: 0' }), + ).toBeVisible() + editor.reset() + await expect( + page.getByRole('button', { name: 'server-counter: 0' }), + ).toBeVisible() + }) + + test('module invalidation', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + + // change child module state + const locator = page.getByTestId('test-module-invalidation-server') + await expect(locator).toContainText('[dep: 0]') + locator.getByRole('button').click() + await expect(locator).toContainText('[dep: 1]') + + // change parent module + const editor = f.createEditor('src/routes/module-invalidation/server.tsx') + editor.edit((s) => s.replace('[dep:', '[dep-edit:')) + + // preserve child module state + await expect(locator).toContainText('[dep-edit: 1]') + editor.reset() + await expect(locator).toContainText('[dep: 1]') + }) + + test('shared hmr basic', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + + // Test initial state + await expect(page.getByTestId('test-hmr-shared-server')).toContainText( + '(shared1, shared2)', + ) + await expect(page.getByTestId('test-hmr-shared-client')).toContainText( + '(shared1, shared2)', + ) + + // Test 1: Component HMR (shared1.tsx) + const editor1 = f.createEditor('src/routes/hmr-shared/shared1.tsx') + editor1.edit((s) => s.replace('shared1', 'shared1-edit')) + + // Verify both server and client components updated + await expect(page.getByTestId('test-hmr-shared-server')).toContainText( + '(shared1-edit, shared2)', + ) + await expect(page.getByTestId('test-hmr-shared-client')).toContainText( + '(shared1-edit, shared2)', + ) + + editor1.reset() + await expect(page.getByTestId('test-hmr-shared-server')).toContainText( + '(shared1, shared2)', + ) + await expect(page.getByTestId('test-hmr-shared-client')).toContainText( + '(shared1, shared2)', + ) + + // Test 2: Non-component HMR (shared2.tsx) + const editor2 = f.createEditor('src/routes/hmr-shared/shared2.tsx') + editor2.edit((s) => s.replace('shared2', 'shared2-edit')) + + // Verify both server and client components updated + await expect(page.getByTestId('test-hmr-shared-server')).toContainText( + '(shared1, shared2-edit)', + ) + await expect(page.getByTestId('test-hmr-shared-client')).toContainText( + '(shared1, shared2-edit)', + ) + + editor2.reset() + await expect(page.getByTestId('test-hmr-shared-server')).toContainText( + '(shared1, shared2)', + ) + await expect(page.getByTestId('test-hmr-shared-client')).toContainText( + '(shared1, shared2)', + ) + }) + + // for this use case to work, server refetch/render and client hmr needs to applied atomically + // at the same time. Next.js doesn't seem to support this either. + // https://github.com/hi-ogawa/reproductions/tree/main/next-rsc-hmr-shared-module + test('shared hmr not atomic', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('test-hmr-shared-atomic')).toContainText( + 'ok (test-shared)', + ) + + // non-atomic update causes an error + const editor = f.createEditor('src/routes/hmr-shared/atomic/shared.tsx') + editor.edit((s) => s.replace('test-shared', 'test-shared-edit')) + await expect(page.getByTestId('test-hmr-shared-atomic')).toContainText( + 'ErrorBoundary', + ) + + await page.reload() + await expect(page.getByText('ok (test-shared-edit)')).toBeVisible() + + // non-atomic update causes an error + editor.reset() + await expect(page.getByTestId('test-hmr-shared-atomic')).toContainText( + 'ErrorBoundary', + ) + + await page.reload() + await expect(page.getByText('ok (test-shared)')).toBeVisible() + }) + + test('hmr switch server to client', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + + await expect(page.getByTestId('test-hmr-switch-server')).toContainText( + '(useState: false)', + ) + const editor = f.createEditor('src/routes/hmr-switch/server.tsx') + editor.edit((s) => `"use client";\n` + s) + await expect(page.getByTestId('test-hmr-switch-server')).toContainText( + '(useState: true)', + ) + + await page.waitForTimeout(100) + editor.reset() + await expect(page.getByTestId('test-hmr-switch-server')).toContainText( + '(useState: false)', + ) + }) + + test('hmr switch client to server', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + + await expect(page.getByTestId('test-hmr-switch-client')).toContainText( + '(useState: true)', + ) + const editor = f.createEditor('src/routes/hmr-switch/client.tsx') + editor.edit((s) => s.replace(`'use client'`, '')) + await expect(page.getByTestId('test-hmr-switch-client')).toContainText( + '(useState: false)', + ) + + await page.waitForTimeout(100) + editor.reset() + await expect(page.getByTestId('test-hmr-switch-client')).toContainText( + '(useState: true)', + ) + }) + }) + + test('css @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await testCssBasic(page) + }) + + testNoJs('css @nojs', async ({ page }) => { + await page.goto(f.url()) + await testCss(page) + }) + + async function testCssBasic(page: Page) { + await testCss(page) + await expect(page.locator('.test-dep-css-in-server')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + await expect(page.locator('.test-style-server-manual')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + await expect(page.getByTestId('css-module-client')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + await expect(page.getByTestId('css-module-server')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + await expect(page.locator('.test-style-url-client')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + await expect(page.locator('.test-style-url-server')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + } + + async function testCss(page: Page, color = 'rgb(255, 165, 0)') { + await expect(page.locator('.test-style-client')).toHaveCSS('color', color) + await expect(page.locator('.test-style-server')).toHaveCSS('color', color) + } + + test.describe(() => { + test.skip(f.mode !== 'dev') + + test('css hmr client', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + + await using _ = await expectNoReload(page) + const editor = f.createEditor('src/routes/style-client/client.css') + editor.edit((s) => s.replaceAll('rgb(255, 165, 0)', 'rgb(0, 165, 255)')) + await expect(page.locator('.test-style-client')).toHaveCSS( + 'color', + 'rgb(0, 165, 255)', + ) + editor.edit((s) => + s.replaceAll( + `color: rgb(0, 165, 255);`, + `/* color: rgb(0, 165, 255); */`, + ), + ) + await expect(page.locator('.test-style-client')).toHaveCSS( + 'color', + 'rgb(0, 0, 0)', + ) + // wait longer for multiple edits + await page.waitForTimeout(100) + editor.reset() + await expect(page.locator('.test-style-client')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + await expectNoDuplicateServerCss(page) + }) + + async function expectNoDuplicateServerCss(page: Page) { + // check only manually inserted stylesheet link exists + await expect(page.locator('link[rel="stylesheet"]')).toHaveCount(3) + for (const locator of await page + .locator('link[rel="stylesheet"]') + .all()) { + await expect(locator).toHaveAttribute( + 'data-precedence', + 'test-style-manual-link', + ) + } + } + + test('no duplicate server css', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expectNoDuplicateServerCss(page) + }) + + test('adding/removing css client @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await testAddRemoveCssClient(page, { js: true }) + }) + + testNoJs('adding/removing css client @nojs', async ({ page }) => { + await page.goto(f.url()) + await testAddRemoveCssClient(page, { js: false }) + }) + + async function testAddRemoveCssClient( + page: Page, + options: { js: boolean }, + ) { + await expect(page.locator('.test-style-client-dep')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + + // remove css import + const editor = f.createEditor('src/routes/style-client/client-dep.tsx') + editor.edit((s) => + s.replaceAll( + `import './client-dep.css'`, + `/* import './client-dep.css' */`, + ), + ) + await page.waitForTimeout(100) + await expect(async () => { + if (!options.js) await page.reload() + await expect(page.locator('.test-style-client-dep')).toHaveCSS( + 'color', + 'rgb(0, 0, 0)', + { timeout: 10 }, + ) + }).toPass() + + // add back css import + editor.reset() + await page.waitForTimeout(100) + await expect(async () => { + if (!options.js) await page.reload() + await expect(page.locator('.test-style-client-dep')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + { timeout: 10 }, + ) + }).toPass() + } + + test('css hmr server', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + + await using _ = await expectNoReload(page) + const editor = f.createEditor('src/routes/style-server/server.css') + editor.edit((s) => s.replaceAll('rgb(255, 165, 0)', 'rgb(0, 165, 255)')) + await expect(page.locator('.test-style-server')).toHaveCSS( + 'color', + 'rgb(0, 165, 255)', + ) + editor.edit((s) => + s.replaceAll( + `color: rgb(0, 165, 255);`, + `/* color: rgb(0, 165, 255); */`, + ), + ) + await expect(page.locator('.test-style-server')).toHaveCSS( + 'color', + 'rgb(0, 0, 0)', + ) + editor.reset() + await expect(page.locator('.test-style-server')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + await expect(page.locator('.test-style-server-manual')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + await expectNoDuplicateServerCss(page) + }) + + // TODO: need a way to add/remove links on server hmr. for now, it requires a manually reload. + test.skip('adding/removing css server @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await testAddRemoveCssServer(page, { js: true }) + }) + + testNoJs('adding/removing css server @nojs', async ({ page }) => { + await page.goto(f.url()) + await testAddRemoveCssServer(page, { js: false }) + }) + + async function testAddRemoveCssServer( + page: Page, + options: { js: boolean }, + ) { + await expect(page.locator('.test-style-server')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + + // remove css import + const editor = f.createEditor('src/routes/style-server/server.tsx') + editor.edit((s) => + s.replaceAll(`import './server.css'`, `/* import './server.css' */`), + ) + await page.waitForTimeout(100) + await expect(async () => { + if (!options.js) await page.reload() + await expect(page.locator('.test-style-server')).toHaveCSS( + 'color', + 'rgb(0, 0, 0)', + { timeout: 10 }, + ) + }).toPass() + + // add back css import + editor.reset() + await page.waitForTimeout(100) + await expect(async () => { + if (!options.js) await page.reload() + await expect(page.locator('.test-style-server')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + { timeout: 10 }, + ) + }).toPass() + } + + test('css module client hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + const editor = f.createEditor('src/routes/style-client/client.module.css') + editor.edit((s) => s.replaceAll('rgb(255, 165, 0)', 'rgb(0, 165, 255)')) + await expect(page.getByTestId('css-module-client')).toHaveCSS( + 'color', + 'rgb(0, 165, 255)', + ) + editor.reset() + await expect(page.getByTestId('css-module-client')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + }) + + test('css module server hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + const editor = f.createEditor('src/routes/style-server/server.module.css') + editor.edit((s) => s.replaceAll('rgb(255, 165, 0)', 'rgb(0, 165, 255)')) + await expect(page.getByTestId('css-module-server')).toHaveCSS( + 'color', + 'rgb(0, 165, 255)', + ) + editor.reset() + await expect(page.getByTestId('css-module-server')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + }) + + test('css url client hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + const editor = f.createEditor('src/routes/style-client/client-url.css') + editor.edit((s) => s.replaceAll('rgb(255, 165, 0)', 'rgb(0, 165, 255)')) + await expect(page.locator('.test-style-url-client')).toHaveCSS( + 'color', + 'rgb(0, 165, 255)', + ) + editor.reset() + await expect(page.locator('.test-style-url-client')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + }) + + test('css url server hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + const editor = f.createEditor('src/routes/style-server/server-url.css') + editor.edit((s) => s.replaceAll('rgb(255, 165, 0)', 'rgb(0, 165, 255)')) + await expect(page.locator('.test-style-url-server')).toHaveCSS( + 'color', + 'rgb(0, 165, 255)', + ) + editor.reset() + await expect(page.locator('.test-style-url-server')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + }) + }) + + test('css client no ssr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await page.locator("a[href='?test-client-style-no-ssr']").click() + await expect(page.locator('.test-style-client-no-ssr')).toHaveCSS( + 'color', + 'rgb(0, 200, 100)', + ) + }) + + test('tailwind @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await testTailwind(page) + }) + + testNoJs('tailwind @nojs', async ({ page }) => { + await page.goto(f.url()) + await testTailwind(page) + }) + + async function testTailwind(page: Page) { + await expect(page.locator('.test-tw-client')).toHaveCSS( + 'color', + // blue-500 + 'rgb(0, 0, 255)', + ) + await expect(page.locator('.test-tw-server')).toHaveCSS( + 'color', + // red-500 + 'rgb(255, 0, 0)', + ) + } + + test.describe(() => { + test.skip(f.mode !== 'dev') + + test('tailwind hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await testTailwind(page) + + await using _ = await expectNoReload(page) + + const clientFile = f.createEditor('src/routes/tailwind/client.tsx') + clientFile.edit((s) => s.replaceAll('text-[#00f]', 'text-[#88f]')) + await expect(page.locator('.test-tw-client')).toHaveCSS( + 'color', + 'rgb(136, 136, 255)', + ) + clientFile.reset() + await expect(page.locator('.test-tw-client')).toHaveCSS( + 'color', + 'rgb(0, 0, 255)', + ) + + const serverFile = f.createEditor('src/routes/tailwind/server.tsx') + serverFile.edit((s) => s.replaceAll('text-[#f00]', 'text-[#f88]')) + await expect(page.locator('.test-tw-server')).toHaveCSS( + 'color', + 'rgb(255, 136, 136)', + ) + serverFile.reset() + await expect(page.locator('.test-tw-server')).toHaveCSS( + 'color', + 'rgb(255, 0, 0)', + ) + }) + + test('tailwind no redundant server hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + const logs: string[] = [] + page.on('console', (msg) => { + if (msg.type() === 'log') { + logs.push(msg.text()) + } + }) + f.createEditor('src/routes/tailwind/unused.tsx').resave() + await page.waitForTimeout(200) + f.createEditor('src/routes/tailwind/server.tsx').resave() + await page.waitForTimeout(200) + expect(logs).toEqual([ + expect.stringMatching(/\[vite-rsc:update\].*\/tailwind\/server.tsx/), + ]) + }) + }) + + test('temporary references @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await page.getByRole('button', { name: 'test-temporary-reference' }).click() + await expect(page.getByTestId('temporary-reference')).toContainText( + 'result: [server [client]]', + ) + }) + + test('server action error @js', async ({ page }) => { + // it doesn't seem possible to assert react error stack mapping on playwright. + // this need to be verified manually on browser devtools console. + await page.goto(f.url()) + await waitForHydration(page) + await page.getByRole('button', { name: 'test-server-action-error' }).click() + await expect(page.getByText('ErrorBoundary caught')).toBeVisible() + await page.getByRole('button', { name: 'reset-error' }).click() + await expect( + page.getByRole('button', { name: 'test-server-action-error' }), + ).toBeVisible() + }) + + test('hydrate while streaming @js', async ({ page }) => { + // client is interactive before suspense is resolved + await page.goto(f.url('./?test-suspense=1000'), { waitUntil: 'commit' }) + await waitForHydration(page) + await expect(page.getByTestId('suspense')).toContainText( + 'suspense-fallback', + ) + await expect(page.getByTestId('suspense')).toContainText( + 'suspense-resolved', + ) + }) + + test('ssr rsc payload encoding', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('ssr-rsc-payload')).toHaveText( + 'test1: true, test2: true, test3: false, test4: true', + ) + + await page.goto(f.url('./?test-payload-binary')) + await waitForHydration(page) + await expect(page.getByTestId('ssr-rsc-payload')).toHaveText( + 'test1: true, test2: true, test3: true, test4: true', + ) + }) + + test('action bind simple @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await testActionBindSimple(page) + }) + + testNoJs('action bind simple @nojs', async ({ page }) => { + await page.goto(f.url()) + await testActionBindSimple(page) + }) + + async function testActionBindSimple(page: Page) { + await expect(page.getByTestId('test-server-action-bind-simple')).toHaveText( + '[?]', + ) + await page + .getByRole('button', { name: 'test-server-action-bind-simple' }) + .click() + await expect(page.getByTestId('test-server-action-bind-simple')).toHaveText( + 'true', + ) + await page + .getByRole('button', { name: 'test-server-action-bind-reset' }) + .click() + } + + test('action bind client @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await testActionBindClient(page) + }) + + // this doesn't work on Next either https://github.com/hi-ogawa/reproductions/tree/main/next-rsc-client-action-bind + testNoJs.skip('action bind client @nojs', async ({ page }) => { + await page.goto(f.url()) + await testActionBindClient(page) + }) + + async function testActionBindClient(page: Page) { + await expect(page.getByTestId('test-server-action-bind-client')).toHaveText( + '[?]', + ) + await page + .getByRole('button', { name: 'test-server-action-bind-client' }) + .click() + await expect(page.getByTestId('test-server-action-bind-client')).toHaveText( + 'true', + ) + await page + .getByRole('button', { name: 'test-server-action-bind-reset' }) + .click() + } + + test('action bind action @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await testActionBindAction(page) + }) + + testNoJs('action bind action @nojs', async ({ page }) => { + await page.goto(f.url()) + await testActionBindAction(page) + }) + + async function testActionBindAction(page: Page) { + await expect(page.getByTestId('test-server-action-bind-action')).toHaveText( + '[?]', + ) + await page + .getByRole('button', { name: 'test-server-action-bind-action' }) + .click() + await expect(page.getByTestId('test-server-action-bind-action')).toHaveText( + '[true,true]', + ) + await page + .getByRole('button', { name: 'test-server-action-bind-reset' }) + .click() + } + + test('test serialization @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('serialization')).toHaveText('?') + await page.getByTestId('serialization').click() + await expect(page.getByTestId('serialization')).toHaveText('ok') + }) + + test('client-in-server package', async ({ page }) => { + await page.goto(f.url()) + await expect(page.getByTestId('client-in-server')).toHaveText( + '[test-client-in-server-dep: true]', + ) + await expect(page.getByTestId('provider-in-server')).toHaveText( + '[test-provider-in-server-dep: true]', + ) + }) + + test('server-in-server package', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('server-in-server')).toHaveText( + 'server-in-server: 0', + ) + await page.getByTestId('server-in-server').click() + await expect(page.getByTestId('server-in-server')).toHaveText( + 'server-in-server: 1', + ) + await page.reload() + await expect(page.getByTestId('server-in-server')).toHaveText( + 'server-in-server: 1', + ) + }) + + test('server-in-client package', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('server-in-client')).toHaveText( + 'server-in-client: ?', + ) + await page.getByTestId('server-in-client').click() + await expect(page.getByTestId('server-in-client')).toHaveText( + 'server-in-client: 1', + ) + await page.reload() + await waitForHydration(page) + await expect(page.getByTestId('server-in-client')).toHaveText( + 'server-in-client: ?', + ) + await page.getByTestId('server-in-client').click() + await expect(page.getByTestId('server-in-client')).toHaveText( + 'server-in-client: 2', + ) + }) + + test('transitive cjs dep', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('transitive-cjs-client')).toHaveText('ok') + await expect( + page.getByTestId('transitive-use-sync-external-store-client'), + ).toHaveText('ok:browser') + }) + + test('use cache function', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + const locator = page.getByTestId('test-use-cache-fn') + await expect(locator.locator('span')).toHaveText( + '(actionCount: 0, cacheFnCount: 0)', + ) + await locator.getByRole('button').click() + await expect(locator.locator('span')).toHaveText( + '(actionCount: 1, cacheFnCount: 1)', + ) + await locator.getByRole('button').click() + await expect(locator.locator('span')).toHaveText( + '(actionCount: 2, cacheFnCount: 1)', + ) + await locator.getByRole('textbox').fill('test') + await locator.getByRole('button').click() + await expect(locator.locator('span')).toHaveText( + '(actionCount: 3, cacheFnCount: 2)', + ) + await locator.getByRole('textbox').fill('test') + await locator.getByRole('button').click() + await expect(locator.locator('span')).toHaveText( + '(actionCount: 4, cacheFnCount: 2)', + ) + + // revalidate cache + await locator.getByRole('textbox').fill('revalidate') + await locator.getByRole('button').click() + await expect(locator.locator('span')).toHaveText( + '(actionCount: 5, cacheFnCount: 3)', + ) + await locator.getByRole('textbox').fill('test') + await locator.getByRole('button').click() + await expect(locator.locator('span')).toHaveText( + '(actionCount: 6, cacheFnCount: 4)', + ) + }) + + test('use cache component', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + const static1 = await page + .getByTestId('test-use-cache-component-static') + .textContent() + const dynamic1 = await page + .getByTestId('test-use-cache-component-dynamic') + .textContent() + await page.waitForTimeout(100) + await page.reload() + const static2 = await page + .getByTestId('test-use-cache-component-static') + .textContent() + const dynamic2 = await page + .getByTestId('test-use-cache-component-dynamic') + .textContent() + expect({ static2, dynamic2 }).toEqual({ + static2: expect.stringMatching(static1!), + dynamic2: expect.not.stringMatching(dynamic1!), + }) + }) + + test('use cache closure', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + const locator = page.getByTestId('test-use-cache-closure') + await expect(locator.locator('span')).toHaveText( + '(actionCount: 0, innerFnCount: 0)', + ) + + // (x, y) + await locator.getByPlaceholder('outer').fill('x') + await locator.getByPlaceholder('inner').fill('y') + await locator.getByRole('button').click() + await expect(locator.locator('span')).toHaveText( + '(actionCount: 1, innerFnCount: 1)', + ) + + // (x, y) + await locator.getByPlaceholder('outer').fill('x') + await locator.getByPlaceholder('inner').fill('y') + await locator.getByRole('button').click() + await expect(locator.locator('span')).toHaveText( + '(actionCount: 2, innerFnCount: 1)', + ) + + // (xx, y) + await locator.getByPlaceholder('outer').fill('xx') + await locator.getByPlaceholder('inner').fill('y') + await locator.getByRole('button').click() + await expect(locator.locator('span')).toHaveText( + '(actionCount: 3, innerFnCount: 2)', + ) + + // (xx, y) + await locator.getByPlaceholder('outer').fill('xx') + await locator.getByPlaceholder('inner').fill('y') + await locator.getByRole('button').click() + await expect(locator.locator('span')).toHaveText( + '(actionCount: 4, innerFnCount: 2)', + ) + + // (xx, yy) + await locator.getByPlaceholder('outer').fill('xx') + await locator.getByPlaceholder('inner').fill('yy') + await locator.getByRole('button').click() + await expect(locator.locator('span')).toHaveText( + '(actionCount: 5, innerFnCount: 3)', + ) + }) + + test('hydration mismatch', async ({ page }) => { + const errors: Error[] = [] + page.on('pageerror', (error) => { + errors.push(error) + }) + await page.goto(f.url('/?test-hydration-mismatch')) + await waitForHydration(page) + expect(errors).toMatchObject([ + { + message: expect.stringContaining( + f.mode === 'dev' ? `Hydration failed` : `Minified React error #418`, + ), + }, + ]) + + errors.length = 0 + await page.goto(f.url()) + await waitForHydration(page) + expect(errors).toEqual([]) + }) + + test('browser only', async ({ page, browser }) => { + await page.goto(f.url()) + await expect(page.getByTestId('test-browser-only')).toHaveText( + 'test-browser-only: true', + ) + + const pageNoJs = await browser.newPage({ javaScriptEnabled: false }) + await pageNoJs.goto(f.url()) + await expect(pageNoJs.getByTestId('test-browser-only')).toHaveText( + 'test-browser-only: loading...', + ) + }) + + test('React.cache', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await page.getByRole('link', { name: 'test-react-cache' }).click() + await expect(page.getByTestId('test-react-cache-result')).toHaveText( + '(cacheFnCount = 2, nonCacheFnCount = 3)', + ) + await page.reload() + await expect(page.getByTestId('test-react-cache-result')).toHaveText( + '(cacheFnCount = 4, nonCacheFnCount = 6)', + ) + }) + + test('css queries', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + + const tests = [ + ['.test-css-url-client', 'rgb(255, 100, 0)'], + ['.test-css-inline-client', 'rgb(255, 50, 0)'], + ['.test-css-raw-client', 'rgb(255, 0, 0)'], + ['.test-css-url-server', 'rgb(0, 255, 100)'], + ['.test-css-inline-server', 'rgb(0, 255, 50)'], + ['.test-css-raw-server', 'rgb(0, 255, 0)'], + ] as const + + // css with queries are not injected automatically + for (const [selector] of tests) { + await expect(page.locator(selector)).toHaveCSS('color', 'rgb(0, 0, 0)') + } + + // inject css manually + await page.getByRole('button', { name: 'test-css-queries' }).click() + + // verify styles + for (const [selector, color] of tests) { + await expect(page.locator(selector)).toHaveCSS('color', color) + } + }) + + test('assets', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect( + page.getByTestId('test-assets-server-import'), + ).not.toHaveJSProperty('naturalWidth', 0) + await expect( + page.getByTestId('test-assets-client-import'), + ).not.toHaveJSProperty('naturalWidth', 0) + + async function testBackgroundImage(selector: string) { + const url = await page + .locator(selector) + .evaluate((el) => getComputedStyle(el).backgroundImage) + expect(url).toMatch(/^url\(.*\)$/) + const response = await page.request.get(url.slice(5, -2)) + expect(response.ok()).toBeTruthy() + expect(response.headers()['content-type']).toBe('image/svg+xml') + } + + await testBackgroundImage('.test-assets-server-css') + await testBackgroundImage('.test-assets-client-css') + }) + + test('lazy', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('test-chunk2')).toHaveText( + 'test-chunk1|test-chunk2|test-chunk2b|test-chunk3|test-chunk3', + ) + }) + + test('tree-shake2', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('test-tree-shake2')).toHaveText( + 'test-tree-shake2:lib-client1|lib-server1', + ) + }) +} diff --git a/packages/plugin-rsc/e2e/browser-mode.test.ts b/packages/plugin-rsc/e2e/browser-mode.test.ts new file mode 100644 index 000000000..84280fd14 --- /dev/null +++ b/packages/plugin-rsc/e2e/browser-mode.test.ts @@ -0,0 +1,83 @@ +import { expect, test, type Page } from '@playwright/test' +import { useFixture } from './fixture' +import { defineStarterTest } from './starter' + +// Webkit fails by +// > TypeError: ReadableByteStreamController is not implemented +test.skip(({ browserName }) => browserName === 'webkit') + +test.describe('dev-browser-mode', () => { + const f = useFixture({ root: 'examples/browser-mode', mode: 'dev' }) + defineStarterTest(f, 'browser-mode') + defineBrowserModeTest(f) +}) + +test.describe('build-browser-mode', () => { + const f = useFixture({ root: 'examples/browser-mode', mode: 'build' }) + defineStarterTest(f, 'browser-mode') + defineBrowserModeTest(f) +}) + +function defineBrowserModeTest(f: ReturnType) { + // action-bind tests copied from basic.test.ts + + test('action bind simple', async ({ page }) => { + await page.goto(f.url()) + await testActionBindSimple(page) + }) + + async function testActionBindSimple(page: Page) { + await expect(page.getByTestId('test-server-action-bind-simple')).toHaveText( + '[?]', + ) + await page + .getByRole('button', { name: 'test-server-action-bind-simple' }) + .click() + await expect(page.getByTestId('test-server-action-bind-simple')).toHaveText( + 'true', + ) + await page + .getByRole('button', { name: 'test-server-action-bind-reset' }) + .click() + } + + test('action bind client', async ({ page }) => { + await page.goto(f.url()) + await testActionBindClient(page) + }) + + async function testActionBindClient(page: Page) { + await expect(page.getByTestId('test-server-action-bind-client')).toHaveText( + '[?]', + ) + await page + .getByRole('button', { name: 'test-server-action-bind-client' }) + .click() + await expect(page.getByTestId('test-server-action-bind-client')).toHaveText( + 'true', + ) + await page + .getByRole('button', { name: 'test-server-action-bind-reset' }) + .click() + } + + test('action bind action', async ({ page }) => { + await page.goto(f.url()) + await testActionBindAction(page) + }) + + async function testActionBindAction(page: Page) { + await expect(page.getByTestId('test-server-action-bind-action')).toHaveText( + '[?]', + ) + await page + .getByRole('button', { name: 'test-server-action-bind-action' }) + .click() + await expect(page.getByTestId('test-server-action-bind-action')).toHaveText( + '[true,true]', + ) + await page + .getByRole('button', { name: 'test-server-action-bind-reset' }) + .click() + } +} diff --git a/packages/plugin-rsc/e2e/build-app.test.ts b/packages/plugin-rsc/e2e/build-app.test.ts new file mode 100644 index 000000000..d4b831cf8 --- /dev/null +++ b/packages/plugin-rsc/e2e/build-app.test.ts @@ -0,0 +1,110 @@ +import { test, expect } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { x } from 'tinyexec' +import { waitForHydration } from './helper' + +test.describe('buildApp hook', () => { + const root = 'examples/e2e/temp/buildApp' + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'vite.config.base.ts': { cp: 'vite.config.ts' }, + 'vite.config.ts': /* js */ ` + import rsc from '@vitejs/plugin-rsc' + import react from '@vitejs/plugin-react' + import { defineConfig, mergeConfig } from 'vite' + import baseConfig from './vite.config.base.ts' + + delete baseConfig.plugins + + const overrideConfig = defineConfig({ + plugins: [ + { + name: 'buildApp-prafter', + buildApp: async () => { + console.log('++++ buildApp:before ++++') + }, + }, + rsc({ + useBuildAppHook: process.env.TEST_USE_BUILD_APP_HOOK === 'true', + }), + { + name: 'buildApp-after', + buildApp: async () => { + console.log('++++ buildApp:after ++++') + }, + }, + react(), + ], + }) + + export default mergeConfig(baseConfig, overrideConfig) + `, + }, + }) + }) + + function verifyMatchOrder(s: string, matches: string[]) { + const found = matches + .map((match) => ({ match, index: s.indexOf(match) })) + .filter((item) => item.index !== -1) + .sort((a, b) => a.index - b.index) + .map((item) => item.match) + expect(found).toEqual(matches) + } + + test('useBuildAppHook: true', async () => { + const result = await x('pnpm', ['build'], { + nodeOptions: { + cwd: root, + env: { + TEST_USE_BUILD_APP_HOOK: 'true', + }, + }, + throwOnError: true, + }) + verifyMatchOrder(result.stdout, [ + '++++ buildApp:before ++++', + 'building for production...', + '++++ buildApp:after ++++', + ]) + expect(result.exitCode).toBe(0) + }) + + test('useBuildAppHook: false', async () => { + const result = await x('pnpm', ['build'], { + nodeOptions: { + cwd: root, + env: { + TEST_USE_BUILD_APP_HOOK: 'false', + }, + }, + throwOnError: true, + }) + verifyMatchOrder(result.stdout, [ + '++++ buildApp:before ++++', + '++++ buildApp:after ++++', + 'building for production...', + ]) + expect(result.exitCode).toBe(0) + }) + + test.describe('build', () => { + const f = useFixture({ + root, + mode: 'build', + cliOptions: { + env: { + TEST_USE_BUILD_APP_HOOK: 'true', + }, + }, + }) + + test('basic', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + }) + }) +}) diff --git a/packages/plugin-rsc/e2e/cloudflare.test.ts b/packages/plugin-rsc/e2e/cloudflare.test.ts new file mode 100644 index 000000000..0f26214fe --- /dev/null +++ b/packages/plugin-rsc/e2e/cloudflare.test.ts @@ -0,0 +1,13 @@ +import { test } from '@playwright/test' +import { useFixture } from './fixture' +import { defineStarterTest } from './starter' + +test.describe('dev-cloudflare', () => { + const f = useFixture({ root: 'examples/starter-cf-single', mode: 'dev' }) + defineStarterTest(f) +}) + +test.describe('build-cloudflare', () => { + const f = useFixture({ root: 'examples/starter-cf-single', mode: 'build' }) + defineStarterTest(f) +}) diff --git a/packages/plugin-rsc/e2e/fixture.ts b/packages/plugin-rsc/e2e/fixture.ts new file mode 100644 index 000000000..a34d298da --- /dev/null +++ b/packages/plugin-rsc/e2e/fixture.ts @@ -0,0 +1,274 @@ +import assert from 'node:assert' +import { type SpawnOptions, spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { stripVTControlCharacters, styleText } from 'node:util' +import test from '@playwright/test' +import { x } from 'tinyexec' + +function runCli(options: { command: string; label?: string } & SpawnOptions) { + const [name, ...args] = options.command.split(' ') + const child = x(name!, args, { nodeOptions: options }).process! + const label = `[${options.label ?? 'cli'}]` + let stdout = '' + let stderr = '' + child.stdout!.on('data', (data) => { + stdout += stripVTControlCharacters(String(data)) + if (process.env.TEST_DEBUG) { + console.log(styleText('cyan', label), data.toString()) + } + }) + child.stderr!.on('data', (data) => { + stderr += stripVTControlCharacters(String(data)) + console.log(styleText('magenta', label), data.toString()) + }) + const done = new Promise((resolve) => { + child.on('exit', (code) => { + if (code !== 0 && code !== 143 && process.platform !== 'win32') { + console.log(styleText('magenta', `${label}`), `exit code ${code}`) + } + resolve() + }) + }) + + async function findPort(): Promise { + let stdout = '' + return new Promise((resolve) => { + child.stdout!.on('data', (data) => { + stdout += stripVTControlCharacters(String(data)) + const match = stdout.match(/http:\/\/localhost:(\d+)/) + if (match) { + resolve(Number(match[1])) + } + }) + }) + } + + function kill() { + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', String(child.pid), '/t', '/f']) + } else { + child.kill() + } + } + + return { + proc: child, + done, + findPort, + kill, + stdout: () => stdout, + stderr: () => stderr, + } +} + +export type Fixture = ReturnType + +export function useFixture(options: { + root: string + mode?: 'dev' | 'build' + command?: string + buildCommand?: string + cliOptions?: SpawnOptions +}) { + let cleanup: (() => Promise) | undefined + let baseURL!: string + + const cwd = path.resolve(options.root) + let proc!: ReturnType + + // TODO: `beforeAll` is called again on any test failure. + // https://playwright.dev/docs/test-retries + test.beforeAll(async () => { + if (options.mode === 'dev') { + proc = runCli({ + command: options.command ?? `pnpm dev`, + label: `${options.root}:dev`, + cwd, + ...options.cliOptions, + }) + const port = await proc.findPort() + // TODO: use `test.extend` to set `baseURL`? + baseURL = `http://localhost:${port}` + cleanup = async () => { + proc.kill() + await proc.done + } + } + if (options.mode === 'build') { + if (!process.env.TEST_SKIP_BUILD) { + const proc = runCli({ + command: options.buildCommand ?? `pnpm build`, + label: `${options.root}:build`, + cwd, + ...options.cliOptions, + }) + await proc.done + assert(proc.proc.exitCode === 0) + } + proc = runCli({ + command: options.command ?? `pnpm preview`, + label: `${options.root}:preview`, + cwd, + ...options.cliOptions, + }) + const port = await proc.findPort() + baseURL = `http://localhost:${port}` + cleanup = async () => { + proc.kill() + await proc.done + } + } + }) + + test.afterAll(async () => { + await cleanup?.() + }) + + const createEditor = useCreateEditor(cwd) + + return { + mode: options.mode, + root: cwd, + url: (url: string = './') => new URL(url, baseURL).href, + createEditor, + proc: () => proc, + } +} + +export function useCreateEditor(cwd: string) { + const originalFiles: Record = {} + + test.afterAll(async () => { + for (const [filepath, content] of Object.entries(originalFiles)) { + fs.writeFileSync(filepath, content) + } + }) + + function createEditor(filepath: string) { + filepath = path.resolve(cwd, filepath) + const init = fs.readFileSync(filepath, 'utf-8') + originalFiles[filepath] ??= init + let current = init + return { + read: () => current, + edit(editFn: (data: string) => string): void { + const next = editFn(current) + assert(next !== current, 'Edit function did not change the content') + current = next + fs.writeFileSync(filepath, next) + }, + reset(): void { + fs.writeFileSync(filepath, originalFiles[filepath]!) + }, + resave(): void { + fs.writeFileSync(filepath, current) + }, + } + } + + return createEditor +} + +export async function setupIsolatedFixture(options: { + src: string + dest: string + overrides?: Record +}) { + // copy fixture + fs.rmSync(options.dest, { recursive: true, force: true }) + fs.cpSync(options.src, options.dest, { + recursive: true, + filter: (src) => !src.includes('node_modules'), + }) + + // extract workspace overrides + const rootDir = path.join(import.meta.dirname, '..', '..', '..') + const workspaceYaml = fs.readFileSync( + path.join(rootDir, 'pnpm-workspace.yaml'), + 'utf-8', + ) + const overridesMatch = workspaceYaml.match( + /overrides:\s*([\s\S]*?)(?=\n\w|\n*$)/, + ) + const overridesSection = overridesMatch ? overridesMatch[0] : 'overrides:' + const overrides = { + '@vitejs/plugin-rsc': `file:${path.join(rootDir, 'packages/plugin-rsc')}`, + '@vitejs/plugin-react': `file:${path.join(rootDir, 'packages/plugin-react')}`, + ...options.overrides, + } + const tempWorkspaceYaml = `\ +${overridesSection} +${Object.entries(overrides) + .map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`) + .join('\n')} +` + fs.writeFileSync( + path.join(options.dest, 'pnpm-workspace.yaml'), + tempWorkspaceYaml, + ) + + // install + await x('pnpm', ['i'], { + throwOnError: true, + nodeOptions: { + cwd: options.dest, + stdio: [ + 'ignore', + process.env.TEST_DEBUG ? 'inherit' : 'ignore', + 'inherit', + ], + }, + }) +} + +// inspired by +// https://github.com/remix-run/react-router/blob/433872f6ab098eaf946cc6c9cf80abf137420ad2/integration/helpers/vite.ts#L239 +// for syntax highlighting of /* js */, use this extension +// https://github.com/mjbvz/vscode-comment-tagged-templates +export async function setupInlineFixture(options: { + src: string + dest: string + files?: Record< + string, + string | { cp: string } | { edit: (s: string) => string } + > +}) { + fs.rmSync(options.dest, { recursive: true, force: true }) + fs.mkdirSync(options.dest, { recursive: true }) + + // copy src + fs.cpSync(options.src, options.dest, { + recursive: true, + filter: (src) => !src.includes('node_modules') && !src.includes('dist'), + }) + + // write additional files + if (options.files) { + for (let [filename, contents] of Object.entries(options.files)) { + const destFile = path.join(options.dest, filename) + fs.mkdirSync(path.dirname(destFile), { recursive: true }) + + // custom command + if (typeof contents === 'object' && 'cp' in contents) { + const srcFile = path.join(options.dest, contents.cp) + fs.copyFileSync(srcFile, destFile) + continue + } + if (typeof contents === 'object' && 'edit' in contents) { + const editted = contents.edit(fs.readFileSync(destFile, 'utf-8')) + fs.writeFileSync(destFile, editted) + continue + } + + // write a new file + contents = contents.replace(/^\n*/, '').replace(/\s*$/, '\n') + const indent = contents.match(/^\s*/)?.[0] ?? '' + const strippedContents = contents + .split('\n') + .map((line) => line.replace(new RegExp(`^${indent}`), '')) + .join('\n') + fs.writeFileSync(destFile, strippedContents) + } + } +} diff --git a/packages/plugin-rsc/e2e/helper.ts b/packages/plugin-rsc/e2e/helper.ts new file mode 100644 index 000000000..60c3aa4f2 --- /dev/null +++ b/packages/plugin-rsc/e2e/helper.ts @@ -0,0 +1,56 @@ +import test, { type Page, expect } from '@playwright/test' + +export const testNoJs = test.extend({ + javaScriptEnabled: ({}, use) => use(false), +}) + +export async function waitForHydration(page: Page, locator: string = 'body') { + await expect + .poll( + () => + page + .locator(locator) + .evaluate( + (el) => + el && + Object.keys(el).some((key) => key.startsWith('__reactFiber')), + ), + { timeout: 10000 }, + ) + .toBeTruthy() +} + +export async function expectNoReload(page: Page) { + // inject custom meta + await page.evaluate(() => { + const el = document.createElement('meta') + el.setAttribute('name', 'x-reload-check') + document.head.append(el) + }) + + // TODO: playwright prints a weird error on dispose error, + // so maybe we shouldn't abuse this pattern :( + return { + [Symbol.asyncDispose]: async () => { + // check if meta is preserved + await expect(page.locator(`meta[name="x-reload-check"]`)).toBeAttached({ + timeout: 1, + }) + await page.evaluate(() => { + document.querySelector(`meta[name="x-reload-check"]`)!.remove() + }) + }, + } +} + +export function expectNoPageError(page: Page) { + const errors: Error[] = [] + page.on('pageerror', (error) => { + errors.push(error) + }) + return { + [Symbol.dispose]: () => { + expect(errors).toEqual([]) + }, + } +} diff --git a/packages/plugin-rsc/e2e/isolated.test.ts b/packages/plugin-rsc/e2e/isolated.test.ts new file mode 100644 index 000000000..85e4d4fac --- /dev/null +++ b/packages/plugin-rsc/e2e/isolated.test.ts @@ -0,0 +1,56 @@ +import { test } from '@playwright/test' +import { setupIsolatedFixture, useFixture } from './fixture' +import { defineStarterTest } from './starter' +import path from 'node:path' +import os from 'node:os' +import * as vite from 'vite' + +test.describe(() => { + // use RUNNER_TEMP on Github Actions + // https://github.com/actions/toolkit/issues/518 + const tmpRoot = path.join( + process.env['RUNNER_TEMP'] || os.tmpdir(), + 'test-vite-rsc', + ) + test.beforeAll(async () => { + await setupIsolatedFixture({ src: 'examples/starter', dest: tmpRoot }) + }) + + test.describe('dev-isolated', () => { + const f = useFixture({ root: tmpRoot, mode: 'dev' }) + defineStarterTest(f) + }) + + test.describe('build-isolated', () => { + const f = useFixture({ root: tmpRoot, mode: 'build' }) + defineStarterTest(f) + }) +}) + +test.describe('vite 6', () => { + test.skip(!!process.env.ECOSYSTEM_CI || 'rolldownVersion' in vite) + + const tmpRoot = path.join( + process.env['RUNNER_TEMP'] || os.tmpdir(), + 'test-vite-rsc-vite-6', + ) + test.beforeAll(async () => { + await setupIsolatedFixture({ + src: 'examples/starter', + dest: tmpRoot, + overrides: { + vite: '^6', + }, + }) + }) + + test.describe('dev', () => { + const f = useFixture({ root: tmpRoot, mode: 'dev' }) + defineStarterTest(f) + }) + + test.describe('build', () => { + const f = useFixture({ root: tmpRoot, mode: 'build' }) + defineStarterTest(f) + }) +}) diff --git a/packages/plugin-rsc/e2e/middleware-mode.test.ts b/packages/plugin-rsc/e2e/middleware-mode.test.ts new file mode 100644 index 000000000..12b75e0ef --- /dev/null +++ b/packages/plugin-rsc/e2e/middleware-mode.test.ts @@ -0,0 +1,37 @@ +import { test } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { defineStarterTest } from './starter' + +test.describe(() => { + const root = 'examples/e2e/temp/middleware-mode' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + }) + }) + + test.describe('dev-middleware-mode', () => { + const f = useFixture({ + root, + mode: 'dev', + command: 'node ../../middleware-mode.ts dev', + }) + defineStarterTest(f) + }) + + test.describe('build-middleware-mode', () => { + const f = useFixture({ + root, + mode: 'build', + command: 'node ../../middleware-mode.ts start', + cliOptions: { + env: { + NODE_ENV: 'production', + }, + }, + }) + defineStarterTest(f) + }) +}) diff --git a/packages/plugin-rsc/e2e/module-runner.test.ts b/packages/plugin-rsc/e2e/module-runner.test.ts new file mode 100644 index 000000000..382495acb --- /dev/null +++ b/packages/plugin-rsc/e2e/module-runner.test.ts @@ -0,0 +1,55 @@ +import { test } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { defineStarterTest } from './starter' + +test.describe(() => { + const root = 'examples/e2e/temp/module-runner-hmr-false' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'vite.config.base.ts': { cp: 'vite.config.ts' }, + 'vite.config.ts': /* js */ ` + import { defineConfig, mergeConfig, createRunnableDevEnvironment } from 'vite' + import baseConfig from './vite.config.base.ts' + + const overrideConfig = defineConfig({ + environments: { + ssr: { + dev: { + createEnvironment(name, config) { + return createRunnableDevEnvironment(name, config, { + runnerOptions: { + hmr: false, + }, + }) + }, + }, + }, + rsc: { + dev: { + createEnvironment(name, config) { + return createRunnableDevEnvironment(name, config, { + runnerOptions: { + hmr: false, + }, + }) + }, + }, + }, + }, + }) + + export default mergeConfig(baseConfig, overrideConfig) + `, + }, + }) + }) + + test.describe('dev-module-runner-hmr-false', () => { + const f = useFixture({ root, mode: 'dev' }) + defineStarterTest(f) + }) +}) diff --git a/packages/plugin-rsc/e2e/no-ssr.test.ts b/packages/plugin-rsc/e2e/no-ssr.test.ts new file mode 100644 index 000000000..16e814162 --- /dev/null +++ b/packages/plugin-rsc/e2e/no-ssr.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test' +import { useFixture } from './fixture' +import { defineStarterTest } from './starter' +import path from 'node:path' +import fs from 'node:fs' + +test.describe('dev-no-ssr', () => { + const f = useFixture({ root: 'examples/no-ssr', mode: 'dev' }) + defineStarterTest(f, 'no-ssr') +}) + +test.describe('build-no-ssr', () => { + const f = useFixture({ root: 'examples/no-ssr', mode: 'build' }) + defineStarterTest(f, 'no-ssr') + + test('no ssr build', () => { + expect(fs.existsSync(path.join(f.root, 'dist/ssr'))).toBe(false) + }) +}) diff --git a/packages/plugin-rsc/e2e/react-compiler.test.ts b/packages/plugin-rsc/e2e/react-compiler.test.ts new file mode 100644 index 000000000..2c36e24b5 --- /dev/null +++ b/packages/plugin-rsc/e2e/react-compiler.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { defineStarterTest } from './starter' +import { waitForHydration } from './helper' + +test.describe(() => { + const root = 'examples/e2e/temp/react-compiler' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'vite.config.base.ts': { cp: 'vite.config.ts' }, + 'vite.config.ts': /* js */ ` + import rsc from '@vitejs/plugin-rsc' + import react from '@vitejs/plugin-react' + import { defineConfig, mergeConfig } from 'vite' + import baseConfig from './vite.config.base.ts' + + delete baseConfig.plugins + + const overrideConfig = defineConfig({ + plugins: [ + react({ babel: { plugins: ['babel-plugin-react-compiler'] } }), + rsc(), + ], + }) + + export default mergeConfig(baseConfig, overrideConfig) + `, + }, + }) + }) + + test.describe('dev-react-compiler', () => { + const f = useFixture({ root, mode: 'dev' }) + defineStarterTest(f) + + test('verify react compiler', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + const res = await page.request.get(f.url('src/client.tsx')) + expect(await res.text()).toContain('react.memo_cache_sentinel') + }) + }) + + test.describe('build-react-compiler', () => { + const f = useFixture({ root, mode: 'build' }) + defineStarterTest(f) + }) +}) diff --git a/packages/plugin-rsc/e2e/react-router.test.ts b/packages/plugin-rsc/e2e/react-router.test.ts new file mode 100644 index 000000000..e62352e84 --- /dev/null +++ b/packages/plugin-rsc/e2e/react-router.test.ts @@ -0,0 +1,187 @@ +import { createHash } from 'node:crypto' +import { expect, test } from '@playwright/test' +import { type Fixture, useFixture } from './fixture' +import { expectNoReload, testNoJs, waitForHydration } from './helper' +import { readFileSync } from 'node:fs' +import React from 'react' + +test.describe('dev-default', () => { + test.skip(/canary|experimental/.test(React.version)) + + const f = useFixture({ root: 'examples/react-router', mode: 'dev' }) + defineTest(f) +}) + +test.describe('build-default', () => { + const f = useFixture({ root: 'examples/react-router', mode: 'build' }) + defineTest(f) +}) + +test.describe('dev-cloudflare', () => { + test.skip(/canary|experimental/.test(React.version)) + + const f = useFixture({ + root: 'examples/react-router', + mode: 'dev', + command: 'pnpm cf-dev', + }) + defineTest(f) +}) + +test.describe('build-cloudflare', () => { + const f = useFixture({ + root: 'examples/react-router', + mode: 'build', + buildCommand: 'pnpm cf-build', + command: 'pnpm cf-preview', + }) + defineTest(f) +}) + +function defineTest(f: Fixture) { + test('loader', async ({ page }) => { + await page.goto(f.url()) + await expect(page.getByText(`loaderData: {"name":"Unknown"}`)).toBeVisible() + }) + + test('client', async ({ page }) => { + await page.goto(f.url('./about')) + await waitForHydration(page) + await page.getByRole('button', { name: 'Client counter: 0' }).click() + await expect( + page.getByRole('button', { name: 'Client counter: 1' }), + ).toBeVisible() + }) + + test('navigation', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + + await page.getByText('This is the home page.').click() + + await page.getByRole('link', { name: 'About' }).click() + await page.waitForURL(f.url('./about')) + await page.getByText('This is the about page.').click() + + await page.getByRole('link', { name: 'Home' }).click() + await page.waitForURL(f.url()) + await page.getByText('This is the home page.').click() + }) + + test.describe(() => { + test.skip(f.mode !== 'build') + + testNoJs('ssr modulepreload', async ({ page }) => { + await page.goto(f.url()) + const srcs = await page + .locator(`head >> link[rel="modulepreload"]`) + .evaluateAll((elements) => + elements.map((el) => el.getAttribute('href')), + ) + const manifest = JSON.parse( + readFileSync( + f.root + '/dist/ssr/__vite_rsc_assets_manifest.js', + 'utf-8', + ).slice('export default '.length), + ) + const hashString = (v: string) => + createHash('sha256').update(v).digest().toString('hex').slice(0, 12) + const deps = + manifest.clientReferenceDeps[hashString('app/routes/home.client.tsx')] + expect(srcs).toEqual(expect.arrayContaining(deps.js)) + }) + }) + + test.describe(() => { + test.skip(f.mode !== 'dev') + + test('client hmr', async ({ page }) => { + await page.goto(f.url('./about')) + await waitForHydration(page) + await using _ = await expectNoReload(page) + + await page.getByRole('button', { name: 'Client counter: 0' }).click() + await expect( + page.getByRole('button', { name: 'Client counter: 1' }), + ).toBeVisible() + + const editor = f.createEditor('app/routes/about.tsx') + editor.edit((s) => s.replace('Client counter:', 'Client [edit] counter:')) + + await expect( + page.getByRole('button', { name: 'Client [edit] counter: 1' }), + ).toBeVisible() + }) + + test('server hmr', async ({ page }) => { + await page.goto(f.url('/')) + await waitForHydration(page) + await using _ = await expectNoReload(page) + + await page.getByText('This is the home page.').click() + + const editor = f.createEditor('app/routes/home.tsx') + editor.edit((s) => + s.replace('This is the home page.', 'This is the home [edit] page.'), + ) + + await page.getByText('This is the home [edit] page.').click() + }) + }) + + test('server css code split', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.locator('.test-style-home')).toHaveCSS( + 'color', + 'rgb(250, 150, 0)', + ) + + // client side navigation to "/about" keeps "/" styles + await page.getByRole('link', { name: 'About' }).click() + await page.waitForURL(f.url('./about')) + await expect(page.locator('.test-style-home')).toHaveCSS( + 'color', + 'rgb(250, 150, 0)', + ) + + // SSR of "/about" doesn't include "/" styles + await page.goto(f.url('./about')) + await waitForHydration(page) + await expect(page.locator('.test-style-home')).not.toHaveCSS( + 'color', + 'rgb(250, 150, 0)', + ) + + // client side navigation to "/" loads "/" styles + await page.getByRole('link', { name: 'Home' }).click() + await page.waitForURL(f.url()) + await expect(page.locator('.test-style-home')).toHaveCSS( + 'color', + 'rgb(250, 150, 0)', + ) + }) + + test('vite-rsc-css-export', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('root-style')).toHaveCSS( + 'color', + 'rgb(0, 0, 255)', + ) + }) + + test('useActionState', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await page.getByTestId('use-action-state-jsx').getByRole('button').click() + await expect(page.getByTestId('use-action-state-jsx')).toContainText( + /\(ok\)/, + ) + await page.getByTestId('use-action-state-jsx').getByRole('button').click() + await expect(page.getByTestId('use-action-state-jsx')).toContainText( + /\(ok\).*\(ok\)/, + ) + }) +} diff --git a/packages/plugin-rsc/e2e/render-built-url.test.ts b/packages/plugin-rsc/e2e/render-built-url.test.ts new file mode 100644 index 000000000..66721515a --- /dev/null +++ b/packages/plugin-rsc/e2e/render-built-url.test.ts @@ -0,0 +1,166 @@ +import { expect, test } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { defineStarterTest } from './starter' +import { expectNoPageError, waitForHydration } from './helper' +import fs from 'node:fs' + +test.describe(() => { + const root = 'examples/e2e/temp/renderBuiltUrl-runtime' + + test.beforeAll(async () => { + const renderBuiltUrl = (filename: string) => { + return { + runtime: `__dynamicBase + ${JSON.stringify(filename)}`, + } + } + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'vite.config.ts': /* js */ ` + import rsc from '@vitejs/plugin-rsc' + import react from '@vitejs/plugin-react' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [ + react(), + rsc({ + entries: { + client: './src/framework/entry.browser.tsx', + ssr: './src/framework/entry.ssr.tsx', + rsc: './src/framework/entry.rsc.tsx', + } + }), + { + // simulate custom asset server + name: 'custom-server', + config(_config, env) { + if (env.isPreview) { + globalThis.__dynamicBase = '/custom-server/'; + } + }, + configurePreviewServer(server) { + server.middlewares.use((req, res, next) => { + const url = new URL(req.url ?? '', "http://localhost"); + if (url.pathname.startsWith('/custom-server/')) { + req.url = url.pathname.replace('/custom-server/', '/'); + } + next(); + }); + } + } + ], + // tweak chunks to test "__dynamicBase" used on browser for "__vite__mapDeps" + environments: { + client: { + build: { + rollupOptions: { + output: { + manualChunks: (id) => { + if (id.includes('node_modules/react/')) { + return 'lib-react'; + } + } + }, + } + } + } + }, + experimental: { + renderBuiltUrl: ${renderBuiltUrl.toString()} + }, + }) + `, + 'src/root.tsx': { + // define __dynamicBase on browser via head script + edit: (s: string) => + s.replace( + '', + () => + ``, + ), + }, + }, + }) + }) + + test.describe('dev-renderBuiltUrl-runtime', () => { + const f = useFixture({ root, mode: 'dev' }) + + test('basic', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + }) + }) + + test.describe('build-renderBuiltUrl-runtime', () => { + const f = useFixture({ root, mode: 'build' }) + defineStarterTest(f) + + test('verify runtime url', () => { + const manifestFileContent = fs.readFileSync( + f.root + '/dist/ssr/__vite_rsc_assets_manifest.js', + 'utf-8', + ) + expect(manifestFileContent).toContain( + `__dynamicBase + "assets/entry.rsc-`, + ) + }) + }) +}) + +test.describe(() => { + const root = 'examples/e2e/temp/renderBuiltUrl-string' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'vite.config.ts': /* js */ ` + import rsc from '@vitejs/plugin-rsc' + import react from '@vitejs/plugin-react' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [ + react(), + rsc({ + entries: { + client: './src/framework/entry.browser.tsx', + ssr: './src/framework/entry.ssr.tsx', + rsc: './src/framework/entry.rsc.tsx', + } + }), + { + // simulate custom asset server + name: 'custom-server', + configurePreviewServer(server) { + server.middlewares.use((req, res, next) => { + const url = new URL(req.url ?? '', "http://localhost"); + if (url.pathname.startsWith('/custom-server/')) { + req.url = url.pathname.replace('/custom-server/', '/'); + } + next(); + }); + } + } + ], + experimental: { + renderBuiltUrl(filename) { + return '/custom-server/' + filename; + } + } + }) + `, + }, + }) + }) + + test.describe('build-renderBuiltUrl-string', () => { + const f = useFixture({ root, mode: 'build' }) + defineStarterTest(f) + }) +}) diff --git a/packages/plugin-rsc/e2e/root.test.ts b/packages/plugin-rsc/e2e/root.test.ts new file mode 100644 index 000000000..ae59a95de --- /dev/null +++ b/packages/plugin-rsc/e2e/root.test.ts @@ -0,0 +1,47 @@ +import { test } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { defineStarterTest } from './starter' +import fs from 'node:fs' +import path from 'node:path' + +test.describe(() => { + const root = 'examples/e2e/temp/root' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'vite.config.base.ts': { cp: 'vite.config.ts' }, + 'vite.config.ts': /* js */ ` + import baseConfig from './vite.config.base.ts' + import path from "node:path"; + baseConfig.root = "./custom-root"; + for (const e of Object.values(baseConfig.environments)) { + e.build.rollupOptions.input.index = path.resolve( + 'custom-root', + e.build.rollupOptions.input.index, + ); + } + export default baseConfig; + `, + }, + }) + fs.mkdirSync(`${root}/custom-root`, { recursive: true }) + fs.renameSync(`${root}/src`, `${root}/custom-root/src`) + fs.renameSync(`${root}/public`, `${root}/custom-root/public`) + }) + + test.describe('dev-root', () => { + const f = useFixture({ root, mode: 'dev' }) + const oldCreateEditor = f.createEditor + f.createEditor = (filePath: string) => + oldCreateEditor(path.resolve(root, 'custom-root', filePath)) + defineStarterTest(f) + }) + + test.describe('build-root', () => { + const f = useFixture({ root, mode: 'build' }) + defineStarterTest(f) + }) +}) diff --git a/packages/plugin-rsc/e2e/ssg.test.ts b/packages/plugin-rsc/e2e/ssg.test.ts new file mode 100644 index 000000000..b1b7d4fb1 --- /dev/null +++ b/packages/plugin-rsc/e2e/ssg.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' +import { type Fixture, useFixture } from './fixture' +import { waitForHydration } from './helper' + +test.describe('dev', () => { + const f = useFixture({ + root: 'examples/ssg', + mode: 'dev', + }) + defineTestSsg(f) +}) + +test.describe('build', () => { + const f = useFixture({ + root: 'examples/ssg', + mode: 'build', + }) + defineTestSsg(f) +}) + +function defineTestSsg(f: Fixture) { + test('basic', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + + if (f.mode === 'build') { + const t1 = await page.getByTestId('timestamp').textContent() + await page.waitForTimeout(100) + await page.reload() + await waitForHydration(page) + const t2 = await page.getByTestId('timestamp').textContent() + expect(t2).toBe(t1) + } + }) +} diff --git a/packages/plugin-rsc/e2e/ssr-thenable.test.ts b/packages/plugin-rsc/e2e/ssr-thenable.test.ts new file mode 100644 index 000000000..7bf9e14e4 --- /dev/null +++ b/packages/plugin-rsc/e2e/ssr-thenable.test.ts @@ -0,0 +1,64 @@ +import { test } from '@playwright/test' +import { setupInlineFixture, type Fixture, useFixture } from './fixture' +import { + expectNoPageError, + waitForHydration as waitForHydration_, +} from './helper' + +test.describe(() => { + const root = 'examples/e2e/temp/ssr-thenable' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/root.tsx': /* tsx */ ` + import { TestClientUse } from './client.tsx' + + export function Root() { + return ( + + + + + + + + + ) + } + `, + 'src/client.tsx': /* tsx */ ` + "use client"; + import React from 'react' + + const promise = Promise.resolve('ok') + + export function TestClientUse() { + const value = React.use(promise) + return {value} + } + `, + }, + }) + }) + + function defineSsrThenableTest(f: Fixture) { + test('ssr-thenable', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration_(page) + }) + } + + test.describe('dev', () => { + const f = useFixture({ root, mode: 'dev' }) + defineSsrThenableTest(f) + }) + + test.describe('build', () => { + const f = useFixture({ root, mode: 'build' }) + defineSsrThenableTest(f) + }) +}) diff --git a/packages/plugin-rsc/e2e/starter.test.ts b/packages/plugin-rsc/e2e/starter.test.ts new file mode 100644 index 000000000..1caff94db --- /dev/null +++ b/packages/plugin-rsc/e2e/starter.test.ts @@ -0,0 +1,143 @@ +import { expect, test } from '@playwright/test' +import { setupInlineFixture, useFixture, type Fixture } from './fixture' +import { defineStarterTest } from './starter' +import { expectNoPageError, waitForHydration } from './helper' +import { x } from 'tinyexec' + +test.describe('dev-default', () => { + const f = useFixture({ root: 'examples/starter', mode: 'dev' }) + defineStarterTest(f) +}) + +test.describe('build-default', () => { + const f = useFixture({ root: 'examples/starter', mode: 'build' }) + defineStarterTest(f) +}) + +test.describe('dev-production', () => { + const f = useFixture({ + root: 'examples/starter', + mode: 'dev', + cliOptions: { + env: { NODE_ENV: 'production' }, + }, + }) + defineStarterTest(f, 'dev-production') + + test('verify production', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + const res = await page.request.get(f.url('src/client.tsx')) + expect(await res.text()).not.toContain('jsxDEV') + }) +}) + +test.describe('build-development', () => { + const f = useFixture({ + root: 'examples/starter', + mode: 'build', + cliOptions: { + env: { NODE_ENV: 'development' }, + }, + }) + defineStarterTest(f) + + test('verify development', async ({ page }) => { + let output!: string + page.on('response', async (response) => { + if (response.url().match(/\/assets\/entry.rsc-[\w-]+\.js$/)) { + output = await response.text() + } + }) + await page.goto(f.url()) + await waitForHydration(page) + expect(output).toContain('jsxDEV') + }) +}) + +test.describe('duplicate loadCss', () => { + const root = 'examples/e2e/temp/duplicate-load-css' + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/root.tsx': { + edit: (s) => + s.replace( + '', + () => + `\ +{import.meta.viteRsc.loadCss()} +{import.meta.viteRsc.loadCss()} +`, + ), + }, + }, + }) + }) + + test.describe('dev', () => { + const f = useFixture({ root, mode: 'dev' }) + defineTest(f) + }) + + test.describe('build', () => { + const f = useFixture({ root, mode: 'build' }) + defineTest(f) + }) + + function defineTest(f: Fixture) { + test('basic', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + }) + } +}) + +test.describe('isolated build', () => { + const root = 'examples/e2e/temp/isolated-build' + + test.beforeAll(async () => { + // build twice programmatically to verify two plugin states are independent + async function testFn() { + const vite = await import('vite') + const fs = await import('node:fs') + + console.log('======== first build ========') + const builder1 = await vite.createBuilder() + await builder1.buildApp() + + // edit files to remove client references + fs.rmSync(`src/client.tsx`) + fs.writeFileSync( + `src/root.tsx`, + fs + .readFileSync(`src/root.tsx`, 'utf-8') + .replace(`import { ClientCounter } from './client.tsx'`, '') + .replace(``, ''), + ) + + console.log('======== second build ========') + const builder2 = await vite.createBuilder() + await builder2.buildApp() + } + + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'test.js': `await (${testFn.toString()})();\n`, + }, + }) + }) + + test('build', async () => { + const result = await x('node', ['./test.js'], { + nodeOptions: { cwd: root }, + }) + expect(result.stderr).not.toContain('Build failed') + expect(result.exitCode).toBe(0) + }) +}) diff --git a/packages/plugin-rsc/e2e/starter.ts b/packages/plugin-rsc/e2e/starter.ts new file mode 100644 index 000000000..3f2e02378 --- /dev/null +++ b/packages/plugin-rsc/e2e/starter.ts @@ -0,0 +1,137 @@ +import { expect, test } from '@playwright/test' +import { type Fixture } from './fixture' +import { + expectNoPageError, + expectNoReload, + testNoJs, + waitForHydration as waitForHydration_, +} from './helper' + +export function defineStarterTest( + f: Fixture, + variant?: 'no-ssr' | 'dev-production' | 'browser-mode', +) { + const waitForHydration: typeof waitForHydration_ = (page) => + waitForHydration_( + page, + variant === 'no-ssr' || variant === 'browser-mode' ? '#root' : 'body', + ) + + test('basic', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + }) + + test('client component', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await page.getByRole('button', { name: 'Client Counter: 0' }).click() + await expect( + page.getByRole('button', { name: 'Client Counter: 1' }), + ).toBeVisible() + }) + + test('server action @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await page.getByRole('button', { name: 'Server Counter: 0' }).click() + await expect( + page.getByRole('button', { name: 'Server Counter: 1' }), + ).toBeVisible() + }) + + testNoJs('server action @nojs', async ({ page }) => { + test.skip(variant === 'no-ssr' || variant === 'browser-mode') + + await page.goto(f.url()) + await page.getByRole('button', { name: 'Server Counter: 1' }).click() + await expect( + page.getByRole('button', { name: 'Server Counter: 2' }), + ).toBeVisible() + }) + + test('client hmr', async ({ page }) => { + test.skip( + f.mode === 'build' || + variant === 'dev-production' || + variant === 'browser-mode', + ) + + await page.goto(f.url()) + await waitForHydration(page) + await page.getByRole('button', { name: 'Client Counter: 0' }).click() + await expect( + page.getByRole('button', { name: 'Client Counter: 1' }), + ).toBeVisible() + + const editor = f.createEditor(`src/client.tsx`) + editor.edit((s) => s.replace('Client Counter', 'Client [edit] Counter')) + await expect( + page.getByRole('button', { name: 'Client [edit] Counter: 1' }), + ).toBeVisible() + + if (variant === 'no-ssr') { + editor.reset() + await page.getByRole('button', { name: 'Client Counter: 1' }).click() + return + } + + // check next ssr is also updated + const res = await page.goto(f.url()) + expect(await res?.text()).toContain('Client [edit] Counter') + await waitForHydration(page) + editor.reset() + await page.getByRole('button', { name: 'Client Counter: 0' }).click() + }) + + test.describe(() => { + test.skip(f.mode === 'build' || variant === 'browser-mode') + + test('server hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await expect(page.getByText('Vite + RSC')).toBeVisible() + const editor = f.createEditor('src/root.tsx') + editor.edit((s) => + s.replace('

Vite + RSC

', '

Vite x RSC

'), + ) + await expect(page.getByText('Vite x RSC')).toBeVisible() + editor.reset() + await expect(page.getByText('Vite + RSC')).toBeVisible() + }) + }) + + test('image assets', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByAltText('Vite logo')).not.toHaveJSProperty( + 'naturalWidth', + 0, + ) + await expect(page.getByAltText('React logo')).not.toHaveJSProperty( + 'naturalWidth', + 0, + ) + }) + + test('css @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.locator('.card').nth(0)).toHaveCSS('padding-left', '16px') + }) + + test.describe(() => { + test.skip(variant === 'no-ssr' || variant === 'browser-mode') + + testNoJs('css @nojs', async ({ page }) => { + await page.goto(f.url()) + await expect(page.locator('.card').nth(0)).toHaveCSS( + 'padding-left', + '16px', + ) + }) + }) +} diff --git a/packages/plugin-rsc/e2e/syntax-error.test.ts b/packages/plugin-rsc/e2e/syntax-error.test.ts new file mode 100644 index 000000000..a44980d95 --- /dev/null +++ b/packages/plugin-rsc/e2e/syntax-error.test.ts @@ -0,0 +1,207 @@ +import { test, expect } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { waitForHydration, expectNoReload } from './helper' + +test.describe(() => { + const root = 'examples/e2e/temp/syntax-error' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/root.tsx': /* tsx */ ` + import { TestSyntaxErrorClient } from './client.tsx' + + export function Root() { + return ( + + + + + + +
server:ok
+ + + ) + } + `, + 'src/client.tsx': /* tsx */ ` + "use client"; + import { useState } from 'react' + + export function TestSyntaxErrorClient() { + const [count, setCount] = useState(0) + + return ( +
+ +
client:ok
+
+ ) + } + `, + }, + }) + }) + + test.describe(() => { + const f = useFixture({ root, mode: 'dev' }) + + test('client hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await expect(page.getByTestId('client-content')).toHaveText('client:ok') + + // Set client state to verify preservation after HMR + await page.getByTestId('client-counter').click() + await expect(page.getByTestId('client-counter')).toHaveText( + 'Client Count: 1', + ) + + // add syntax error + const editor = f.createEditor('src/client.tsx') + editor.edit((s) => + s.replace( + '
client:ok
', + '
client:broken<
', + ), + ) + await expect(page.locator('vite-error-overlay')).toBeVisible() + + // fix syntax error + await page.waitForTimeout(200) + editor.edit((s) => + s.replace( + '
client:broken<
', + '
client:fixed
', + ), + ) + await expect(page.locator('vite-error-overlay')).not.toBeVisible() + await expect(page.getByTestId('client-syntax-ready')).toBeVisible() + await expect(page.getByTestId('client-content')).toHaveText( + 'client:fixed', + ) + await expect(page.getByTestId('client-counter')).toHaveText( + 'Client Count: 1', + ) + }) + }) + + test.describe(() => { + const f = useFixture({ root, mode: 'dev' }) + + test('server hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + + await expect(page.getByTestId('server-content')).toHaveText('server:ok') + + // Set client state to verify preservation during server HMR + await page.getByTestId('client-counter').click() + await expect(page.getByTestId('client-counter')).toHaveText( + 'Client Count: 1', + ) + + // add syntax error + const editor = f.createEditor('src/root.tsx') + editor.edit((s) => + s.replace( + '
server:ok
', + '
server:broken<
', + ), + ) + await expect(page.locator('vite-error-overlay')).toBeVisible() + + // fix syntax error + await page.waitForTimeout(200) + editor.edit((s) => + s.replace( + '
server:broken<
', + '
server:fixed
', + ), + ) + await expect(page.locator('vite-error-overlay')).not.toBeVisible() + await expect(page.getByTestId('server-content')).toHaveText( + 'server:fixed', + ) + await expect(page.getByTestId('client-counter')).toHaveText( + 'Client Count: 1', + ) + }) + }) + + test.describe(() => { + const f = useFixture({ root, mode: 'dev' }) + + test('client ssr', async ({ page }) => { + // add syntax error + const editor = f.createEditor('src/client.tsx') + editor.edit((s) => + s.replace( + '
client:ok
', + '
client:broken<
', + ), + ) + await page.goto(f.url()) + await expect(page.locator('body')).toContainText('src/client.tsx:15') + + // fix syntax error + await page.waitForTimeout(200) + editor.edit((s) => + s.replace( + '
client:broken<
', + '
client:fixed
', + ), + ) + await expect(async () => { + await page.goto(f.url()) + await expect(page.getByTestId('client-content')).toHaveText( + 'client:fixed', + ) + }).toPass() + await waitForHydration(page) + }) + }) + + test.describe(() => { + const f = useFixture({ root, mode: 'dev' }) + + test('server ssr', async ({ page }) => { + // add syntax error + const editor = f.createEditor('src/root.tsx') + editor.edit((s) => + s.replace( + '
server:ok
', + '
server:broken<
', + ), + ) + await page.goto(f.url()) + await expect(page.locator('body')).toContainText('src/root.tsx:11') + + // fix syntax error + await page.waitForTimeout(200) + editor.edit((s) => + s.replace( + '
server:broken<
', + '
server:fixed
', + ), + ) + await expect(async () => { + await page.goto(f.url()) + await expect(page.getByTestId('server-content')).toHaveText( + 'server:fixed', + ) + }).toPass() + await waitForHydration(page) + }) + }) +}) diff --git a/packages/plugin-rsc/e2e/tsconfig.json b/packages/plugin-rsc/e2e/tsconfig.json new file mode 100644 index 000000000..fbf20fed5 --- /dev/null +++ b/packages/plugin-rsc/e2e/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "noImplicitReturns": false, + "checkJs": false + } +} diff --git a/packages/plugin-rsc/e2e/validate-imports.test.ts b/packages/plugin-rsc/e2e/validate-imports.test.ts new file mode 100644 index 000000000..7f032bad4 --- /dev/null +++ b/packages/plugin-rsc/e2e/validate-imports.test.ts @@ -0,0 +1,160 @@ +import { test, expect } from '@playwright/test' +import { setupInlineFixture, useFixture, type Fixture } from './fixture' +import { x } from 'tinyexec' +import { expectNoPageError, waitForHydration } from './helper' + +test.describe('validate imports', () => { + test.describe('valid imports', () => { + const root = 'examples/e2e/temp/validate-imports' + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/client.tsx': /* tsx */ ` + "use client"; + import 'client-only'; + + export function TestClient() { + return
[test-client]
+ } + `, + 'src/root.tsx': /* tsx */ ` + import { TestClient } from './client.tsx' + import 'server-only'; + + export function Root() { + return ( + + + + + +
[test-server]
+ + + + ) + } + `, + }, + }) + }) + + test.describe('dev', () => { + const f = useFixture({ root, mode: 'dev' }) + defineTest(f) + }) + + test.describe('build', () => { + const f = useFixture({ root, mode: 'build' }) + defineTest(f) + }) + + function defineTest(f: Fixture) { + test('basic', async ({ page }) => { + using _ = expectNoPageError(page) + await page.goto(f.url()) + await waitForHydration(page) + }) + } + }) + + test.describe('server-only on client', () => { + const root = 'examples/e2e/temp/validate-server-only' + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/client.tsx': /* tsx */ ` + "use client"; + import 'server-only'; + + export function TestClient() { + return
[test-client]
+ } + `, + 'src/root.tsx': /* tsx */ ` + import { TestClient } from './client.tsx' + import 'server-only'; + + export function Root() { + return ( + + + + + +
[test-server]
+ + + + ) + } + `, + }, + }) + }) + + test('build', async () => { + const result = await x('pnpm', ['build'], { + throwOnError: false, + nodeOptions: { cwd: root }, + }) + expect(result.stderr).toContain( + `'server-only' cannot be imported in client build`, + ) + expect(result.exitCode).not.toBe(0) + }) + }) + + test.describe('client-only on server', () => { + const root = 'examples/e2e/temp/validate-client-only' + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/client.tsx': /* tsx */ ` + "use client"; + import 'client-only'; + + export function TestClient() { + return
[test-client]
+ } + `, + 'src/root.tsx': /* tsx */ ` + import { TestClient } from './client.tsx' + import 'client-only'; + + export function Root() { + return ( + + + + + +
[test-server]
+ + + + ) + } + `, + }, + }) + }) + + test('build', async () => { + const result = await x('pnpm', ['build'], { + throwOnError: false, + nodeOptions: { cwd: root }, + }) + expect(result.stderr).toContain( + `'client-only' cannot be imported in server build`, + ) + expect(result.exitCode).not.toBe(0) + }) + }) +}) diff --git a/packages/plugin-rsc/examples/basic/README.md b/packages/plugin-rsc/examples/basic/README.md new file mode 100644 index 000000000..03a0a54a3 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/README.md @@ -0,0 +1,9 @@ +# rsc basic + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/basic) + +https://vite-rsc-basic.hiro18181.workers.dev + +```sh +npx giget gh:vitejs/vite-plugin-react/packages/plugin-rsc/examples/basic my-app +``` diff --git a/packages/plugin-rsc/examples/basic/package.json b/packages/plugin-rsc/examples/basic/package.json new file mode 100644 index 000000000..246c8de7b --- /dev/null +++ b/packages/plugin-rsc/examples/basic/package.json @@ -0,0 +1,40 @@ +{ + "name": "@vitejs/plugin-rsc-examples-basic", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "cf-build": "CF_BUILD=1 pnpm build", + "cf-preview": "wrangler dev", + "cf-release": "wrangler deploy" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.13", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "@vitejs/test-dep-client-in-server": "file:./test-dep/client-in-server", + "@vitejs/test-dep-client-in-server2": "file:./test-dep/client-in-server2", + "@vitejs/test-dep-css-in-server": "file:./test-dep/css-in-server", + "@vitejs/test-dep-server-in-client": "file:./test-dep/server-in-client", + "@vitejs/test-dep-server-in-server": "file:./test-dep/server-in-server", + "@vitejs/test-dep-transitive-cjs": "file:./test-dep/transitive-cjs", + "@vitejs/test-dep-transitive-use-sync-external-store": "file:./test-dep/transitive-use-sync-external-store", + "rsc-html-stream": "^0.0.7", + "tailwindcss": "^4.1.13", + "vite": "^7.1.4", + "wrangler": "^4.34.0" + }, + "stackblitz": { + "installDependencies": false, + "startCommand": "pnpm i && pnpm dev" + } +} diff --git a/packages/plugin-rsc/examples/basic/public/favicon.ico b/packages/plugin-rsc/examples/basic/public/favicon.ico new file mode 100644 index 000000000..4aff07660 Binary files /dev/null and b/packages/plugin-rsc/examples/basic/public/favicon.ico differ diff --git a/packages/plugin-rsc/examples/basic/public/test-style-server-manual.css b/packages/plugin-rsc/examples/basic/public/test-style-server-manual.css new file mode 100644 index 000000000..f1c9489d9 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/public/test-style-server-manual.css @@ -0,0 +1,3 @@ +.test-style-server-manual { + color: orange; +} diff --git a/packages/plugin-rsc/examples/basic/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/basic/src/framework/entry.browser.tsx new file mode 100644 index 000000000..551f4aac9 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/framework/entry.browser.tsx @@ -0,0 +1,134 @@ +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import type { RscPayload } from './entry.rsc' + +async function main() { + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void + + // deserialize RSC stream back to React VDOM for CSR + const initialPayload = await createFromReadableStream( + // initial RSC stream is injected in SSR stream as + rscStream, + ) + + // browser root component to (re-)render RSC payload as state + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const payload = await createFromFetch( + fetch(window.location.href), + ) + setPayload(payload) + } + + // register a handler which will be internally called by React + // on server function request after hydration. + setServerCallback(async (id, args) => { + const url = new URL(window.location.href) + const temporaryReferences = createTemporaryReferenceSet() + const payload = await createFromFetch( + fetch(url, { + method: 'POST', + body: await encodeReply(args, { temporaryReferences }), + headers: { + 'x-rsc-action': id, + }, + }), + { temporaryReferences }, + ) + setPayload(payload) + return payload.returnValue + }) + + // hydration + const browserRoot = ( + + + + ) + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + + // implement server HMR by trigering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on('rsc:update', (e) => { + console.log('[vite-rsc:update]', e.file) + fetchRscPayload() + }) + } +} + +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +main() diff --git a/packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..853c4686e --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx @@ -0,0 +1,112 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import type React from 'react' + +// The schema of payload which is serialized into RSC stream on rsc environment +// and deserialized on ssr/client environments. +export type RscPayload = { + // this demo renders/serializes/deserizlies entire root html element + // but this mechanism can be changed to render/fetch different parts of components + // based on your own route conventions. + root: React.ReactNode + // server action return value of non-progressive enhancement case + returnValue?: unknown + // server action form state (e.g. useActionState) of progressive enhancement case + formState?: ReactFormState +} + +// the plugin by default assumes `rsc` entry having default export of request handler. +// however, how server entries are executed can be customized by registering +// own server handler e.g. `@cloudflare/vite-plugin`. +export async function handleRequest({ + request, + getRoot, + nonce, +}: { + request: Request + getRoot: () => React.ReactNode + nonce?: string +}): Promise { + // handle server function request + const isAction = request.method === 'POST' + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + if (isAction) { + // x-rsc-action header exists when action is called via `ReactClient.setServerCallback`. + const actionId = request.headers.get('x-rsc-action') + if (actionId) { + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(actionId) + returnValue = await action.apply(null, args) + } else { + // otherwise server function is called via `
` + // before hydration (e.g. when javascript is disabled). + // aka progressive enhancement. + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } + } + + const url = new URL(request.url) + const rscPayload: RscPayload = { root: getRoot(), formState, returnValue } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + // respond RSC stream without HTML rendering based on framework's convention. + // here we use request header `content-type`. + // additionally we allow `?__rsc` and `?__html` to easily view payload directly. + const isRscRequest = + (!request.headers.get('accept')?.includes('text/html') && + !url.searchParams.has('__html')) || + url.searchParams.has('__rsc') + + if (isRscRequest) { + return new Response(rscStream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + vary: 'accept', + }, + }) + } + + // Delegate to SSR environment for html rendering. + // The plugin provides `loadSsrModule` helper to allow loading SSR environment entry module + // in RSC environment. however this can be customized by implementing own runtime communication + // e.g. `@cloudflare/vite-plugin`'s service binding. + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + const htmlStream = await ssrEntryModule.renderHTML(rscStream, { + formState, + nonce, + // allow quick simulation of javscript disabled browser + debugNojs: url.searchParams.has('__nojs'), + }) + + // respond html + return new Response(htmlStream, { + headers: { + 'content-type': 'text/html;charset=utf-8', + vary: 'accept', + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..e5c539923 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx @@ -0,0 +1,56 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' +import type { ReactFormState } from 'react-dom/client' +import { renderToReadableStream } from 'react-dom/server.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' +import type { RscPayload } from './entry.rsc' + +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +) { + // duplicate one RSC stream into two. + // - one for SSR (ReactClient.createFromReadableStream below) + // - another for browser hydration payload by injecting . + const [rscStream1, rscStream2] = rscStream.tee() + + // deserialize RSC stream back to React VDOM + let payload: Promise + function SsrRoot() { + // deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDomServer preinit/preloading to work + payload ??= createFromReadableStream(rscStream1) + return {React.use(payload).root} + } + + function FixSsrThenable(props: React.PropsWithChildren) { + return props.children + } + + // render html (traditional SSR) + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + const htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return responseStream +} diff --git a/packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx b/packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx new file mode 100644 index 000000000..a25e56c6c --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/framework/use-cache-runtime.tsx @@ -0,0 +1,100 @@ +import { + createClientTemporaryReferenceSet, + encodeReply, + createTemporaryReferenceSet, + decodeReply, + renderToReadableStream, + createFromReadableStream, +} from '@vitejs/plugin-rsc/rsc' + +// based on +// https://github.com/vercel/next.js/pull/70435 +// https://github.com/vercel/next.js/blob/09a2167b0a970757606b7f91ff2d470f77f13f8c/packages/next/src/server/use-cache/use-cache-wrapper.ts + +const cachedFnMap = new WeakMap() +const cachedFnCacheEntries = new WeakMap< + Function, + Record> +>() + +export default function cacheWrapper(fn: (...args: any[]) => Promise) { + if (cachedFnMap.has(fn)) { + return cachedFnMap.get(fn)! + } + + async function cachedFn(...args: any[]): Promise { + let cacheEntries = cachedFnCacheEntries.get(cachedFn) + if (!cacheEntries) { + cacheEntries = {} + cachedFnCacheEntries.set(cachedFn, cacheEntries) + } + + // Serialize arguments to a cache key via `encodeReply` from `react-server-dom/client`. + // NOTE: using `renderToReadableStream` here for arguments serialization would end up + // serializing react elements (e.g. children props), which causes + // those arguments to be included as a cache key and it doesn't achieve + // "use cache static shell + dynamic children props" pattern. + // cf. https://nextjs.org/docs/app/api-reference/directives/use-cache#non-serializable-arguments + const clientTemporaryReferences = createClientTemporaryReferenceSet() + const encodedArguments = await encodeReply(args, { + temporaryReferences: clientTemporaryReferences, + }) + const serializedCacheKey = await replyToCacheKey(encodedArguments) + + // cache `fn` result as stream + // (cache value is promise so that it dedupes concurrent async calls) + const entryPromise = (cacheEntries[serializedCacheKey] ??= (async () => { + const temporaryReferences = createTemporaryReferenceSet() + const decodedArgs = await decodeReply(encodedArguments, { + temporaryReferences, + }) + + // run the original function + const result = await fn(...decodedArgs) + + // serialize result to a ReadableStream + const stream = renderToReadableStream(result, { + environmentName: 'Cache', + temporaryReferences, + }) + return new StreamCacher(stream) + })()) + + // deserialized cached stream + const stream = (await entryPromise).get() + const result = createFromReadableStream(stream, { + environmentName: 'Cache', + replayConsoleLogs: true, + temporaryReferences: clientTemporaryReferences, + }) + return result + } + + cachedFnMap.set(fn, cachedFn) + + return cachedFn +} + +export function revalidateCache(cachedFn: Function) { + cachedFnCacheEntries.delete(cachedFn) +} + +class StreamCacher { + constructor(private stream: ReadableStream) {} + get(): ReadableStream { + const [returnStream, savedStream] = this.stream.tee() + this.stream = savedStream + return returnStream + } +} + +async function replyToCacheKey(reply: string | FormData) { + if (typeof reply === 'string') { + return reply + } + const buffer = await crypto.subtle.digest( + 'SHA-256', + await new Response(reply).arrayBuffer(), + ) + return btoa(String.fromCharCode(...new Uint8Array(buffer))) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/action-bind/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/action-bind/client.tsx new file mode 100644 index 000000000..2fe0c81c6 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/action-bind/client.tsx @@ -0,0 +1,12 @@ +'use client' + +import React from 'react' + +export function ActionBindClient() { + const hydrated = React.useSyncExternalStore( + React.useCallback(() => () => {}, []), + () => true, + () => false, + ) + return <>{String(hydrated)} +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/action-bind/form.tsx b/packages/plugin-rsc/examples/basic/src/routes/action-bind/form.tsx new file mode 100644 index 000000000..1b1675c3a --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/action-bind/form.tsx @@ -0,0 +1,16 @@ +'use client' + +import React from 'react' + +export function TestServerActionBindClientForm(props: { + action: () => Promise +}) { + const [result, formAction] = React.useActionState(props.action, '[?]') + + return ( + + + {result} + + ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/action-bind/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/action-bind/server.tsx new file mode 100644 index 000000000..2de0f294a --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/action-bind/server.tsx @@ -0,0 +1,96 @@ +// based on test cases in +// https://github.com/vercel/next.js/blob/ad898de735c393d98960a68c8d9eaeee32206c57/test/e2e/app-dir/actions/app/encryption/page.js + +import { ActionBindClient } from './client' +import { TestServerActionBindClientForm } from './form' + +export function TestServerActionBindReset() { + return ( +
{ + 'use server' + testServerActionBindSimpleState = '[?]' + testServerActionBindActionState = '[?]' + testServerActionBindClientState++ + }} + > + +
+ ) +} + +let testServerActionBindSimpleState = '[?]' + +export function TestServerActionBindSimple() { + const outerValue = 'outerValue' + + return ( +
{ + 'use server' + const result = String(formData.get('value')) === outerValue + testServerActionBindSimpleState = JSON.stringify(result) + }} + > + + + + {testServerActionBindSimpleState} + +
+ ) +} + +let testServerActionBindClientState = 0 + +export function TestServerActionBindClient() { + // client element as server action bound argument + const client = + + const action = async () => { + 'use server' + return client + } + + return ( + + ) +} + +let testServerActionBindActionState = '[?]' + +export function TestServerActionBindAction() { + async function otherAction() { + 'use server' + return 'otherActionValue' + } + + function wrapAction(value: string, action: () => Promise) { + return async function (formValue: string) { + 'use server' + const actionValue = await action() + return [actionValue === 'otherActionValue', formValue === value] + } + } + + const action = wrapAction('ok', otherAction) + + return ( +
{ + 'use server' + const result = await action(String(formData.get('value'))) + testServerActionBindActionState = JSON.stringify(result) + }} + > + + + + {testServerActionBindActionState} + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/action-error/error-boundary.tsx b/packages/plugin-rsc/examples/basic/src/routes/action-error/error-boundary.tsx new file mode 100644 index 000000000..9b06078c3 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/action-error/error-boundary.tsx @@ -0,0 +1,40 @@ +'use client' + +import * as React from 'react' + +interface Props { + children?: React.ReactNode +} + +interface State { + error: Error | null +} + +export default class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props) + this.state = { error: null } + } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + render() { + if (this.state.error) { + return ( +
+ ErrorBoundary caught '{this.state.error.message}' + +
+ ) + } + return this.props.children + } +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/action-error/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/action-error/server.tsx new file mode 100644 index 000000000..07647569b --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/action-error/server.tsx @@ -0,0 +1,19 @@ +import ErrorBoundary from './error-boundary' + +// see browser console to verify that server action error shows +// server component stack with correct source map + +export function TestServerActionError() { + return ( + +
{ + 'use server' + throw new Error('boom!') + }} + > + +
+
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/action-from-client/action.tsx b/packages/plugin-rsc/examples/basic/src/routes/action-from-client/action.tsx new file mode 100644 index 000000000..d40fa1db3 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/action-from-client/action.tsx @@ -0,0 +1,27 @@ +'use server' + +// test findSourceMapURL for server action imported from client + +export async function notThis() { + // + // + // + notThis2() +} + +export async function testAction() { + console.log('[test-action-from-client]') +} + +function notThis2() { + // + // +} + +export async function testAction2() { + console.log('[test-action-from-client-2]') +} + +export async function testActionState(prev: number) { + return prev + 1 +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/action-from-client/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/action-from-client/client.tsx new file mode 100644 index 000000000..8f0bc2368 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/action-from-client/client.tsx @@ -0,0 +1,25 @@ +'use client' + +import React from 'react' +import { testAction, testAction2, testActionState } from './action' + +export function TestActionFromClient() { + return ( +
+ + +
+ ) +} + +export function TestUseActionState() { + const [state, formAction] = React.useActionState(testActionState, 0) + + return ( +
+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/action-state/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/action-state/client.tsx new file mode 100644 index 000000000..5c6b6e682 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/action-state/client.tsx @@ -0,0 +1,19 @@ +'use client' + +import React from 'react' + +export function TestActionStateClient(props: { + action: (prev: React.ReactNode) => Promise +}) { + const [state, formAction, isPending] = React.useActionState( + props.action, + null, + ) + + return ( +
+ + {isPending ? 'pending...' : state} +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/action-state/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/action-state/server.tsx new file mode 100644 index 000000000..fb7288313 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/action-state/server.tsx @@ -0,0 +1,21 @@ +import { TestActionStateClient } from './client' + +// Test case based on +// https://github.com/remix-run/react-router/issues/13882 + +export function TestActionStateServer() { + const time = new Date().toISOString() // test closure encryption + return ( + { + 'use server' + await new Promise((resolve) => setTimeout(resolve, 500)) + return ( + + [(ok) (time: {time})] {prev} + + ) + }} + /> + ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/action/action.tsx b/packages/plugin-rsc/examples/basic/src/routes/action/action.tsx new file mode 100644 index 000000000..5c6769a34 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/action/action.tsx @@ -0,0 +1,16 @@ +'use server' + +let serverCounter = 0 + +export async function getServerCounter(): Promise { + return serverCounter +} + +export async function changeServerCounter(formData: FormData): Promise { + const TEST_UPDATE = 1 + serverCounter += Number(formData.get('change')) * TEST_UPDATE +} + +export async function resetServerCounter(): Promise { + serverCounter = 0 +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/action/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/action/server.tsx new file mode 100644 index 000000000..6bb0646fc --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/action/server.tsx @@ -0,0 +1,15 @@ +import { + changeServerCounter, + getServerCounter, + resetServerCounter, +} from './action' + +export function ServerCounter() { + return ( +
+ + + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/assets/client-css.svg b/packages/plugin-rsc/examples/basic/src/routes/assets/client-css.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/assets/client-css.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/basic/src/routes/assets/client.css b/packages/plugin-rsc/examples/basic/src/routes/assets/client.css new file mode 100644 index 000000000..b9fe5b89f --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/assets/client.css @@ -0,0 +1,6 @@ +.test-assets-client-css { + background: url(./client-css.svg) no-repeat; + background-size: contain; + width: 20px; + height: 20px; +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/assets/client.svg b/packages/plugin-rsc/examples/basic/src/routes/assets/client.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/assets/client.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/basic/src/routes/assets/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/assets/client.tsx new file mode 100644 index 000000000..14d14c63c --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/assets/client.tsx @@ -0,0 +1,19 @@ +'use client' + +import './client.css' +import svg from './client.svg?no-inline' + +export function TestAssetsClient() { + return ( +
+ test-assets-client + + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/assets/server-css.svg b/packages/plugin-rsc/examples/basic/src/routes/assets/server-css.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/assets/server-css.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/basic/src/routes/assets/server.css b/packages/plugin-rsc/examples/basic/src/routes/assets/server.css new file mode 100644 index 000000000..d7fef5877 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/assets/server.css @@ -0,0 +1,6 @@ +.test-assets-server-css { + background: url(./server-css.svg) no-repeat; + background-size: contain; + width: 20px; + height: 20px; +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/assets/server.svg b/packages/plugin-rsc/examples/basic/src/routes/assets/server.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/assets/server.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/basic/src/routes/assets/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/assets/server.tsx new file mode 100644 index 000000000..1feddf7be --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/assets/server.tsx @@ -0,0 +1,21 @@ +import { TestAssetsClient } from './client' +import './server.css' +import svg from './server.svg?no-inline' + +export function TestAssetsServer() { + return ( + <> +
+ test-assets-server + + +
+ + + ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/browser-only/browser-dep.tsx b/packages/plugin-rsc/examples/basic/src/routes/browser-only/browser-dep.tsx new file mode 100644 index 000000000..b922367d1 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/browser-only/browser-dep.tsx @@ -0,0 +1,3 @@ +export default function BrowserDep() { + return <>{String(!!window)} +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/browser-only/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/browser-only/client.tsx new file mode 100644 index 000000000..21c76d2b6 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/browser-only/client.tsx @@ -0,0 +1,65 @@ +'use client' + +import * as React from 'react' + +const BrowserDep = ( + import.meta.env.SSR ? undefined : React.lazy(() => import('./browser-dep')) +)! + +export function TestBrowserOnly() { + return ( +
+ test-browser-only:{' '} + loading...}> + + +
+ ) +} + +function BrowserOnly(props: React.SuspenseProps) { + const hydrated = useHydrated() + if (!hydrated) { + return props.fallback + } + return +} + +const noopStore = () => () => {} + +const useHydrated = () => + React.useSyncExternalStore( + noopStore, + () => true, + () => false, + ) + +/* +If we were to implement this whole logic via hypothetical `browserOnly` helper with transform: + +======= input ====== + +const SomeDep = browserOnly(() => import('./some-dep')) + +======= output ====== + +const __TmpLazy = import.meta.env.SSR ? undefined : React.lazy(() => import('./some-dep'})); + +const SomeDep = ({ browserOnlyFallback, ...props }) => { + const hydrated = useHydrated() + if (!hydrated) { + return browserOnlyFallback + } + return ( + + <__TmpLazy {...props} /> + + ) +} + +=== helper types === + +declare function browserOnly(fn: () => Promise<{ default: React.ComponentType }>): + React.ComponentType + +*/ diff --git a/packages/plugin-rsc/examples/basic/src/routes/chunk/client1.tsx b/packages/plugin-rsc/examples/basic/src/routes/chunk/client1.tsx new file mode 100644 index 000000000..cc0429fbe --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/chunk/client1.tsx @@ -0,0 +1,9 @@ +'use client' + +export function TestClientChunk1() { + return test-chunk1 +} + +export function TestClientChunkConflict() { + return test-chunk-conflict1 +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/chunk/client2.tsx b/packages/plugin-rsc/examples/basic/src/routes/chunk/client2.tsx new file mode 100644 index 000000000..7795d3397 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/chunk/client2.tsx @@ -0,0 +1,5 @@ +'use client' + +export function TestClientChunk2() { + return test-chunk2 +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/chunk/client3.tsx b/packages/plugin-rsc/examples/basic/src/routes/chunk/client3.tsx new file mode 100644 index 000000000..f04cb369c --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/chunk/client3.tsx @@ -0,0 +1,9 @@ +'use client' + +export function TestClientChunk3() { + return test-chunk3 +} + +export function TestClientChunkConflict() { + return test-chunk-conflict3 +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/chunk/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/chunk/server.tsx new file mode 100644 index 000000000..2e2830532 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/chunk/server.tsx @@ -0,0 +1,21 @@ +import { + TestClientChunk1, + TestClientChunkConflict as TestClientChunkConflict1, +} from './client1' +import { TestClientChunk2 } from './client2' +import { + TestClientChunk3, + TestClientChunkConflict as TestClientChunkConflict3, +} from './client3' + +export function TestClientChunkServer() { + return ( +
+ | + | + | + | + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/chunk2/client1.tsx b/packages/plugin-rsc/examples/basic/src/routes/chunk2/client1.tsx new file mode 100644 index 000000000..0ffe9aadc --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/chunk2/client1.tsx @@ -0,0 +1,5 @@ +'use client' + +export default function TestChunkClient1() { + return test-chunk1 +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/chunk2/client2.tsx b/packages/plugin-rsc/examples/basic/src/routes/chunk2/client2.tsx new file mode 100644 index 000000000..dc7dacf5f --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/chunk2/client2.tsx @@ -0,0 +1,5 @@ +'use client' + +export default function TestChunkClient2() { + return test-chunk2 +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/chunk2/client2b.tsx b/packages/plugin-rsc/examples/basic/src/routes/chunk2/client2b.tsx new file mode 100644 index 000000000..9d03f2a01 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/chunk2/client2b.tsx @@ -0,0 +1,5 @@ +'use client' + +export default function TestChunkClient2b() { + return test-chunk2b +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/chunk2/client3.tsx b/packages/plugin-rsc/examples/basic/src/routes/chunk2/client3.tsx new file mode 100644 index 000000000..1baadec9e --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/chunk2/client3.tsx @@ -0,0 +1,5 @@ +'use client' + +export default function TestChunkClient3() { + return test-chunk3 +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/chunk2/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/chunk2/server.tsx new file mode 100644 index 000000000..960397069 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/chunk2/server.tsx @@ -0,0 +1,17 @@ +import React from 'react' + +const TestChunkClient1 = React.lazy(() => import('./client1')) +const TestChunkServer2 = React.lazy(() => import('./server2')) +const TestChunkServer3 = React.lazy(() => import('./server3')) +const TestChunkServer4 = React.lazy(() => import('./server4')) + +export function TestChunk2() { + return ( +
+ | + | + | + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/chunk2/server2.tsx b/packages/plugin-rsc/examples/basic/src/routes/chunk2/server2.tsx new file mode 100644 index 000000000..693a828cd --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/chunk2/server2.tsx @@ -0,0 +1,11 @@ +import TestChunkClient2 from './client2' +import TestChunkClient2b from './client2b' + +export default function TestChunkServer2() { + return ( + <> + | + + + ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/chunk2/server3.tsx b/packages/plugin-rsc/examples/basic/src/routes/chunk2/server3.tsx new file mode 100644 index 000000000..e8a829b09 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/chunk2/server3.tsx @@ -0,0 +1,5 @@ +import TestChunkClient3 from './client3' + +export default function TestChunkServer3() { + return +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/chunk2/server4.tsx b/packages/plugin-rsc/examples/basic/src/routes/chunk2/server4.tsx new file mode 100644 index 000000000..69ee9e37e --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/chunk2/server4.tsx @@ -0,0 +1,5 @@ +import TestChunkClient3 from './client3' + +export default function TestChunkServer4() { + return +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/client.tsx new file mode 100644 index 000000000..237ff422f --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/client.tsx @@ -0,0 +1,26 @@ +'use client' + +import React from 'react' + +export function ClientCounter(): React.ReactElement { + const [count, setCount] = React.useState(0) + return ( + + ) +} + +const noop = () => () => {} +export function Hydrated() { + const hydrated = React.useSyncExternalStore( + noop, + () => true, + () => false, + ) + return [hydrated: {hydrated ? 1 : 0}] +} + +export function UnusedClientReference() { + console.log('__unused_client_reference__') +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/css-queries/client-inline.css b/packages/plugin-rsc/examples/basic/src/routes/css-queries/client-inline.css new file mode 100644 index 000000000..dbbc8070c --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/css-queries/client-inline.css @@ -0,0 +1,3 @@ +.test-css-inline-client { + color: rgb(255, 50, 0); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/css-queries/client-raw.css b/packages/plugin-rsc/examples/basic/src/routes/css-queries/client-raw.css new file mode 100644 index 000000000..19b0428db --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/css-queries/client-raw.css @@ -0,0 +1,3 @@ +.test-css-raw-client { + color: rgb(255, 0, 0); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/css-queries/client-url.css b/packages/plugin-rsc/examples/basic/src/routes/css-queries/client-url.css new file mode 100644 index 000000000..95c67acb0 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/css-queries/client-url.css @@ -0,0 +1,3 @@ +.test-css-url-client { + color: rgb(255, 100, 0); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/css-queries/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/css-queries/client.tsx new file mode 100644 index 000000000..682e3ebca --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/css-queries/client.tsx @@ -0,0 +1,36 @@ +'use client' + +import cssUrl from './client-url.css?url' +import cssInline from './client-inline.css?inline' +import cssRaw from './client-raw.css?raw' +import React from 'react' + +export function TestCssQueriesClient(props: { + serverUrl: string + serverInline: string + serverRaw: string +}) { + const [enabled, setEnabled] = React.useState(false) + + return ( +
+ +
+ {enabled && ( + <> + + + + + + + + )} + test-css-url-client + | + test-css-inline-client + | + test-css-raw-client +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/css-queries/server-inline.css b/packages/plugin-rsc/examples/basic/src/routes/css-queries/server-inline.css new file mode 100644 index 000000000..4f007865a --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/css-queries/server-inline.css @@ -0,0 +1,3 @@ +.test-css-inline-server { + color: rgb(0, 255, 50); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/css-queries/server-raw.css b/packages/plugin-rsc/examples/basic/src/routes/css-queries/server-raw.css new file mode 100644 index 000000000..c7cdd3a57 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/css-queries/server-raw.css @@ -0,0 +1,3 @@ +.test-css-raw-server { + color: rgb(0, 255, 0); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/css-queries/server-url.css b/packages/plugin-rsc/examples/basic/src/routes/css-queries/server-url.css new file mode 100644 index 000000000..167620c4b --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/css-queries/server-url.css @@ -0,0 +1,3 @@ +.test-css-url-server { + color: rgb(0, 255, 100); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/css-queries/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/css-queries/server.tsx new file mode 100644 index 000000000..49ed52ee1 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/css-queries/server.tsx @@ -0,0 +1,21 @@ +import cssUrl from './server-url.css?url' +import cssInline from './server-inline.css?inline' +import cssRaw from './server-raw.css?raw' +import { TestCssQueriesClient } from './client' + +export function TestCssQueries() { + return ( +
+ + test-css-url-server + | + test-css-inline-server + | + test-css-raw-server +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/deps/client-in-server/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/deps/client-in-server/client.tsx new file mode 100644 index 000000000..2e7dc9f2b --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/deps/client-in-server/client.tsx @@ -0,0 +1,8 @@ +'use client' + +// @ts-ignore +import { TestContextValue } from '@vitejs/test-dep-client-in-server2/client' + +export function TestContextValueIndirect() { + return +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/deps/client-in-server/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/deps/client-in-server/server.tsx new file mode 100644 index 000000000..cb029357e --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/deps/client-in-server/server.tsx @@ -0,0 +1,22 @@ +// @ts-ignore +import { TestClientInServerDep } from '@vitejs/test-dep-client-in-server/server' +// @ts-ignore +import { TestContextProviderInServer } from '@vitejs/test-dep-client-in-server2/server' +import { TestContextValueIndirect } from './client' + +export function TestClientInServer() { + return ( +
+
+ [test-client-in-server-dep: ] +
+
+ [test-provider-in-server-dep:{' '} + + + + ] +
+
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/deps/server-in-client/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/deps/server-in-client/client.tsx new file mode 100644 index 000000000..0a0c7335f --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/deps/server-in-client/client.tsx @@ -0,0 +1,6 @@ +// @ts-ignore +import { TestClient } from '@vitejs/test-dep-server-in-client/client' + +export function TestServerInClient() { + return +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/deps/server-in-server/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/deps/server-in-server/server.tsx new file mode 100644 index 000000000..5d15747bb --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/deps/server-in-server/server.tsx @@ -0,0 +1,6 @@ +// @ts-ignore +import { ServerCounter } from '@vitejs/test-dep-server-in-server/server' + +export function TestServerInServer() { + return +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/deps/transitive-cjs/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/deps/transitive-cjs/client.tsx new file mode 100644 index 000000000..10c2a1e44 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/deps/transitive-cjs/client.tsx @@ -0,0 +1,20 @@ +'use client' + +// @ts-ignore +import { TestClient } from '@vitejs/test-dep-transitive-cjs/client' + +// @ts-ignore +import { TestClient as TestClient2 } from '@vitejs/test-dep-transitive-use-sync-external-store/client' + +export function TestTransitiveCjsClient() { + return ( + <> +
+ [test-dep-transitive-cjs-client: ] +
+
+ [test-dep-transitive-use-sync-external-store-client: ] +
+ + ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep/client-dep.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep/client-dep.tsx new file mode 100644 index 000000000..adb03ac2b --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep/client-dep.tsx @@ -0,0 +1,3 @@ +export function ClientDep() { + return <>[ok] +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep/client.tsx new file mode 100644 index 000000000..3c2500fbe --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep/client.tsx @@ -0,0 +1,24 @@ +'use client' + +import React from 'react' +import { ClientDep } from './client-dep' + +export function TestHmrClientDep(props: { url: Pick }) { + const [count, setCount] = React.useState(0) + return ( +
+ + + + {' '} + + re-render + {props.url.search.includes('test-hmr-client-dep-re-render') + ? ' [ok]' + : ''} + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep2/client-dep.ts b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep2/client-dep.ts new file mode 100644 index 000000000..fd47fd7c7 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep2/client-dep.ts @@ -0,0 +1,3 @@ +export function clientDep() { + return '[ok]' +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep2/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep2/client.tsx new file mode 100644 index 000000000..8bee6cdbc --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep2/client.tsx @@ -0,0 +1,24 @@ +'use client' + +import React from 'react' +import { clientDep } from './client-dep' + +export function TestHmrClientDep2(props: { url: Pick }) { + const [count, setCount] = React.useState(0) + return ( +
+ + + {clientDep()} + {' '} + + re-render + {props.url.search.includes('test-hmr-client-dep2-re-render') + ? ' [ok]' + : ''} + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-a.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-a.tsx new file mode 100644 index 000000000..c4154575c --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-a.tsx @@ -0,0 +1,20 @@ +'use client' + +import React from 'react' +import { clientDep } from './client-dep' +import { ClientDepComp } from './client-dep-comp' + +export function TestHmrClientDepA() { + const [count, setCount] = React.useState(0) + return ( + <> + + + {clientDep()} + + + + ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-b.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-b.tsx new file mode 100644 index 000000000..fbd243711 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-b.tsx @@ -0,0 +1,7 @@ +'use client' + +import { TestHmrClientDepA } from './client-a' + +export function TestHmrClientDepB() { + return +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-dep-comp.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-dep-comp.tsx new file mode 100644 index 000000000..e028d7239 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-dep-comp.tsx @@ -0,0 +1,3 @@ +export function ClientDepComp() { + return '[ok]' +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-dep.ts b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-dep.ts new file mode 100644 index 000000000..fd47fd7c7 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/client-dep.ts @@ -0,0 +1,3 @@ +export function clientDep() { + return '[ok]' +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/server.tsx new file mode 100644 index 000000000..a364d29c0 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-client-dep3/server.tsx @@ -0,0 +1,23 @@ +import { TestHmrClientDepA } from './client-a' +import { TestHmrClientDepB } from './client-b' + +// example to demonstrate a folowing behavior +// https://github.com/vitejs/vite-plugin-react/pull/788#issuecomment-3227656612 +/* +server server + | | + v v +client-a client-a?t=xx <-- client-b + | | + v v +client-dep-comp?t=xx +*/ + +export function TestHmrClientDep3() { + return ( +
+ + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/client.tsx new file mode 100644 index 000000000..76dcff0b2 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/client.tsx @@ -0,0 +1,21 @@ +'use client' + +import React from 'react' +import { testShared } from './shared' + +export function TestClient({ + testSharedFromServer, +}: { + testSharedFromServer: string +}) { + React.useEffect(() => { + if (testShared !== testSharedFromServer) { + console.log({ testShared, testSharedFromServer }) + throw new Error( + `Mismatch: ${JSON.stringify({ testShared, testSharedFromServer })}`, + ) + } + }, [testShared, testSharedFromServer]) + + return <>ok ({testShared}) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/error-boundary.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/error-boundary.tsx new file mode 100644 index 000000000..af59d2c4b --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/error-boundary.tsx @@ -0,0 +1,40 @@ +'use client' + +import * as React from 'react' + +interface Props { + children?: React.ReactNode +} + +interface State { + error: Error | null +} + +export default class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props) + this.state = { error: null } + } + + static getDerivedStateFromError(error: Error) { + return { error } + } + + render() { + if (this.state.error) { + return ( + + ErrorBoundary: {this.state.error.message} + + + ) + } + return this.props.children + } +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/server.tsx new file mode 100644 index 000000000..478d9ac03 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/server.tsx @@ -0,0 +1,14 @@ +import { TestClient } from './client' +import ErrorBoundary from './error-boundary' +import { testShared } from './shared' + +export function TestHmrSharedAtomic() { + return ( +
+ test-hmr-shared-atomic:{' '} + + + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/shared.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/shared.tsx new file mode 100644 index 000000000..64f17a986 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/atomic/shared.tsx @@ -0,0 +1 @@ +export const testShared = 'test-shared' diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/client.tsx new file mode 100644 index 000000000..ef7497222 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/client.tsx @@ -0,0 +1,13 @@ +'use client' + +import { TestHmrSharedComponent } from './shared1' +import { testHmrSharedObject } from './shared2' + +export function TestHmrSharedClient() { + return ( +
+ test-hmr-shared-client: (,{' '} + {testHmrSharedObject.value}) +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/server.tsx new file mode 100644 index 000000000..f53a1037a --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/server.tsx @@ -0,0 +1,11 @@ +import { TestHmrSharedComponent } from './shared1' +import { testHmrSharedObject } from './shared2' + +export function TestHmrSharedServer() { + return ( +
+ test-hmr-shared-server: (,{' '} + {testHmrSharedObject.value}) +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/shared1.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/shared1.tsx new file mode 100644 index 000000000..b2fc6ea7d --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/shared1.tsx @@ -0,0 +1,3 @@ +export function TestHmrSharedComponent() { + return <>shared1 +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/shared2.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/shared2.tsx new file mode 100644 index 000000000..fd39b39fb --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-shared/shared2.tsx @@ -0,0 +1,3 @@ +export const testHmrSharedObject = { + value: 'shared2', +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-switch/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-switch/client.tsx new file mode 100644 index 000000000..311d866e4 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-switch/client.tsx @@ -0,0 +1,11 @@ +'use client' + +import React from 'react' + +export function TestHmrSwitchClient() { + return ( +
+ test-hmr-switch-client (useState: {String(!!React.useState)}) +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hmr-switch/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/hmr-switch/server.tsx new file mode 100644 index 000000000..caa8ced01 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hmr-switch/server.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +export function TestHmrSwitchServer() { + return ( +
+ test-hmr-switch-server (useState: {String(!!React.useState)}) +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hydration-mismatch/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/hydration-mismatch/client.tsx new file mode 100644 index 000000000..079d20712 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hydration-mismatch/client.tsx @@ -0,0 +1,6 @@ +'use client' + +export function Mismatch() { + const value = typeof window !== 'undefined' ? 'browser' : 'ssr' + return <>[{value}] +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/hydration-mismatch/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/hydration-mismatch/server.tsx new file mode 100644 index 000000000..b16d51be7 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/hydration-mismatch/server.tsx @@ -0,0 +1,16 @@ +import { Mismatch } from './client' + +export function TestHydrationMismatch(props: { url: URL }) { + const show = props.url.searchParams.has('test-hydration-mismatch') + return ( +
+ test-hydration-mismatch{' '} + {show ? ( + hide + ) : ( + show + )}{' '} + {show && } +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/import-meta-glob/dep.tsx b/packages/plugin-rsc/examples/basic/src/routes/import-meta-glob/dep.tsx new file mode 100644 index 000000000..e6d3de7ef --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/import-meta-glob/dep.tsx @@ -0,0 +1,3 @@ +export default function Dep() { + return <>test-import-meta-glob +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/import-meta-glob/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/import-meta-glob/server.tsx new file mode 100644 index 000000000..a9ec558a2 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/import-meta-glob/server.tsx @@ -0,0 +1,4 @@ +export async function TestImportMetaGlob() { + const mod: any = await Object.values(import.meta.glob('./dep.tsx'))[0]() + return +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/module-invalidation/server-dep.tsx b/packages/plugin-rsc/examples/basic/src/routes/module-invalidation/server-dep.tsx new file mode 100644 index 000000000..64c930ab6 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/module-invalidation/server-dep.tsx @@ -0,0 +1,3 @@ +export const dep = { + value: 0, +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/module-invalidation/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/module-invalidation/server.tsx new file mode 100644 index 000000000..90062572b --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/module-invalidation/server.tsx @@ -0,0 +1,18 @@ +import { dep } from './server-dep' + +export function TestModuleInvalidationServer() { + return ( +
+
{ + 'use server' + dep.value ^= 1 + }} + > + + [dep: {dep.value}] +
+
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/payload/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/payload/client.tsx new file mode 100644 index 000000000..788de21f6 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/payload/client.tsx @@ -0,0 +1,29 @@ +'use client' + +export function TestPayloadClient(props: { + test1?: any + test2?: any + test3?: any + test4?: any +}) { + const results = { + test1: props.test1 === 'đŸ™‚', + test2: props.test2 === "", + test3: + props.test3 instanceof Uint8Array && + isSameArray(props.test3, new TextEncoder().encode('đŸ”¥').reverse()), + test4: props.test4 === '&><\u2028\u2029', + } + const formatted = Object.entries(results) + .map(([k, v]) => `${k}: ${String(v)}`) + .join(', ') + return <>{formatted} +} + +function isSameArray(x: Uint8Array, y: Uint8Array) { + if (x.length !== y.length) return false + for (let i = 0; i < x.length; i++) { + if (x[i] !== y[i]) return false + } + return true +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/payload/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/payload/server.tsx new file mode 100644 index 000000000..253a6b719 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/payload/server.tsx @@ -0,0 +1,24 @@ +import { TestPayloadClient } from './client' + +export function TestPayloadServer(props: { url: URL }) { + return ( +
+ test-payload (binary):{' '} + + throw new Error('boom')"} + test3={ + // disabled by default so that it won't break Stackblitz demo + // https://github.com/stackblitz/webcontainer-core/issues/1861 + props.url.searchParams.has('test-payload-binary') + ? // reverse to have non-utf8 binary data + new TextEncoder().encode('đŸ”¥').reverse() + : null + } + test4={'&><\u2028\u2029'} + /> + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/react-cache/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/react-cache/server.tsx new file mode 100644 index 000000000..12733af46 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/react-cache/server.tsx @@ -0,0 +1,43 @@ +import React from 'react' + +// Note that `React.cache` doesn't have effect inside action +// since it's outside of RSC render request context. +// https://github.com/hi-ogawa/reproductions/tree/main/next-rsc-action-cache + +export async function TestReactCache(props: { url: URL }) { + if (props.url.searchParams.has('test-react-cache')) { + await testCacheFn('test1') + await testCacheFn('test2') + await testCacheFn('test1') + await testNonCacheFn('test1') + await testNonCacheFn('test2') + await testNonCacheFn('test1') + } else { + cacheFnCount = 0 + nonCacheFnCount = 0 + } + + return ( +
+ test-react-cache{' '} + + (cacheFnCount = {cacheFnCount}, nonCacheFnCount = {nonCacheFnCount}) + +
+ ) +} + +let cacheFnCount = 0 +let nonCacheFnCount = 0 + +const testCacheFn = React.cache(async (...args: unknown[]) => { + console.log('[cached:args]', args) + cacheFnCount++ + await new Promise((resolve) => setTimeout(resolve, 20)) +}) + +const testNonCacheFn = async (...args: unknown[]) => { + console.log('[not-cached:args]', args) + nonCacheFnCount++ + await new Promise((resolve) => setTimeout(resolve, 20)) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/root.tsx b/packages/plugin-rsc/examples/basic/src/routes/root.tsx new file mode 100644 index 000000000..429518744 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/root.tsx @@ -0,0 +1,136 @@ +import React from 'react' +import { + TestServerActionBindAction, + TestServerActionBindClient, + TestServerActionBindReset, + TestServerActionBindSimple, +} from './action-bind/server' +import { TestServerActionError } from './action-error/server' +import { + TestActionFromClient, + TestUseActionState, +} from './action-from-client/client' +import { TestActionStateServer } from './action-state/server' +import { ServerCounter } from './action/server' +import { ClientCounter, Hydrated } from './client' +import { TestClientInServer } from './deps/client-in-server/server' +import { TestServerInClient } from './deps/server-in-client/client' +import { TestServerInServer } from './deps/server-in-server/server' +import { TestHmrClientDep } from './hmr-client-dep/client' +import { TestModuleInvalidationServer } from './module-invalidation/server' +import { TestPayloadServer } from './payload/server' +import { TestSerializationServer } from './serialization/server' +import { TestCssClientNoSsr } from './style-client-no-ssr/server' +import { TestStyleClient } from './style-client/client' +import { TestStyleServer } from './style-server/server' +import { TestTemporaryReference } from './temporary-reference/client' +import { TestUseCache } from './use-cache/server' +import { TestReactCache } from './react-cache/server' +import { TestHydrationMismatch } from './hydration-mismatch/server' +import { TestBrowserOnly } from './browser-only/client' +import { TestTransitiveCjsClient } from './deps/transitive-cjs/client' +import TestDepCssInServer from '@vitejs/test-dep-css-in-server/server' +import { TestHmrSharedServer } from './hmr-shared/server' +import { TestHmrSharedClient } from './hmr-shared/client' +import { TestHmrSharedAtomic } from './hmr-shared/atomic/server' +import { TestCssQueries } from './css-queries/server' +import { TestImportMetaGlob } from './import-meta-glob/server' +import { TestAssetsServer } from './assets/server' +import { TestHmrSwitchServer } from './hmr-switch/server' +import { TestHmrSwitchClient } from './hmr-switch/client' +import { TestTreeShakeServer } from './tree-shake/server' +import { TestTreeShake2 } from './tree-shake2/server' +import { TestClientChunkServer } from './chunk/server' +import { TestTailwind } from './tailwind' +import { TestHmrClientDep2 } from './hmr-client-dep2/client' +import { TestHmrClientDep3 } from './hmr-client-dep3/server' +import { TestChunk2 } from './chunk2/server' +import { TestUseId } from './use-id/server' + +export function Root(props: { url: URL }) { + return ( + + + + vite-rsc + {import.meta.viteRsc.loadCss('/src/routes/root.tsx')} + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function TestReplayConsoleLogs(props: { url: URL }) { + if (props.url.search.includes('test-replay-console-logs')) { + console.log('[test-replay-console-logs]') + } + return test-replayConsoleLogs +} + +function TestSuspense(props: { url: URL }) { + if (props.url.search.includes('test-suspense')) { + const ms = Number(props.url.searchParams.get('test-suspense')) || 1000 + async function Inner() { + await new Promise((resolve) => setTimeout(resolve, ms)) + return
suspense-resolved
+ } + return ( +
+ suspense-fallback
}> + +
+ + ) + } + return test-suspense +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/serialization/action.tsx b/packages/plugin-rsc/examples/basic/src/routes/serialization/action.tsx new file mode 100644 index 000000000..69e96d666 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/serialization/action.tsx @@ -0,0 +1,6 @@ +'use server' + +export async function testSerializationAction() { + console.log('[test-serialization-action]') + return 'ok' +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/serialization/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/serialization/client.tsx new file mode 100644 index 000000000..7815f8072 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/serialization/client.tsx @@ -0,0 +1,18 @@ +'use client' + +import React from 'react' + +export function TestSerializationClient(props: { action: () => Promise }) { + const [state, setState] = React.useState('?') + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/serialization/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/serialization/server.tsx new file mode 100644 index 000000000..ffb7b9699 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/serialization/server.tsx @@ -0,0 +1,27 @@ +import { + createFromReadableStream, + renderToReadableStream, +} from '@vitejs/plugin-rsc/rsc' +import { testSerializationAction } from './action' +import { TestSerializationClient } from './client' + +export function TestSerializationServer() { + const original = + let serialized = renderToReadableStream(original) + // debug serialization + if (0) { + serialized = (serialized as ReadableStream>) + .pipeThrough(new TextDecoderStream()) + .pipeThrough( + new TransformStream({ + transform(chunk, controller) { + console.log('[test-serialization]', { chunk }) + controller.enqueue(chunk) + }, + }), + ) + .pipeThrough(new TextEncoderStream()) + } + const deserialized = createFromReadableStream(serialized) + return
test-serialization:{deserialized}
+} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-client-no-ssr/client.css b/packages/plugin-rsc/examples/basic/src/routes/style-client-no-ssr/client.css new file mode 100644 index 000000000..96c363257 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-client-no-ssr/client.css @@ -0,0 +1,3 @@ +.test-style-client-no-ssr { + color: rgb(0, 200, 100); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-client-no-ssr/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/style-client-no-ssr/client.tsx new file mode 100644 index 000000000..85754d472 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-client-no-ssr/client.tsx @@ -0,0 +1,7 @@ +'use client' + +import './client.css' + +export function TestClient() { + return [test] +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-client-no-ssr/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/style-client-no-ssr/server.tsx new file mode 100644 index 000000000..8e0f7304f --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-client-no-ssr/server.tsx @@ -0,0 +1,11 @@ +import { TestClient } from './client' + +export function TestCssClientNoSsr(props: { url: URL }) { + return ( +
+ test-client-style-no-ssr{' '} + show hide{' '} + {props.url.searchParams.has('test-client-style-no-ssr') && } +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-client/client-dep.css b/packages/plugin-rsc/examples/basic/src/routes/style-client/client-dep.css new file mode 100644 index 000000000..a58f3cfcf --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-client/client-dep.css @@ -0,0 +1,3 @@ +.test-style-client-dep { + color: rgb(255, 165, 0); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-client/client-dep.tsx b/packages/plugin-rsc/examples/basic/src/routes/style-client/client-dep.tsx new file mode 100644 index 000000000..8dcd56b14 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-client/client-dep.tsx @@ -0,0 +1,5 @@ +import './client-dep.css' + +export function TestClientDep() { + return
test-style-client-dep
+} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-client/client-url.css b/packages/plugin-rsc/examples/basic/src/routes/style-client/client-url.css new file mode 100644 index 000000000..cabba9a9a --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-client/client-url.css @@ -0,0 +1,3 @@ +.test-style-url-client { + color: rgb(255, 165, 0); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-client/client.css b/packages/plugin-rsc/examples/basic/src/routes/style-client/client.css new file mode 100644 index 000000000..bd95cc0fd --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-client/client.css @@ -0,0 +1,5 @@ +/* css imported by client references */ + +.test-style-client { + color: rgb(255, 165, 0); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-client/client.module.css b/packages/plugin-rsc/examples/basic/src/routes/style-client/client.module.css new file mode 100644 index 000000000..7b8fea47b --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-client/client.module.css @@ -0,0 +1,3 @@ +.client { + color: rgb(255, 165, 0); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-client/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/style-client/client.tsx new file mode 100644 index 000000000..53a7c1289 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-client/client.tsx @@ -0,0 +1,27 @@ +'use client' + +import './client.css' +import { TestClientDep } from './client-dep' +import styles from './client.module.css' +import styleUrl from './client-url.css?url' + +export function TestStyleClient() { + return ( +
+
test-style-client
+ | +
+ test-css-module-client +
+ | + +
test-style-url-client
+ | + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-server/server-url.css b/packages/plugin-rsc/examples/basic/src/routes/style-server/server-url.css new file mode 100644 index 000000000..5e249a05a --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-server/server-url.css @@ -0,0 +1,3 @@ +.test-style-url-server { + color: rgb(255, 165, 0); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-server/server.css b/packages/plugin-rsc/examples/basic/src/routes/style-server/server.css new file mode 100644 index 000000000..480fa1388 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-server/server.css @@ -0,0 +1,3 @@ +.test-style-server { + color: rgb(255, 165, 0); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-server/server.module.css b/packages/plugin-rsc/examples/basic/src/routes/style-server/server.module.css new file mode 100644 index 000000000..a391a735e --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-server/server.module.css @@ -0,0 +1,3 @@ +.server { + color: rgb(255, 165, 0); +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-server/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/style-server/server.tsx new file mode 100644 index 000000000..da4bafafb --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-server/server.tsx @@ -0,0 +1,29 @@ +import './server.css' +import styles from './server.module.css' +import styleUrl from './server-url.css?url' + +export function TestStyleServer() { + return ( +
+
test-style-server
+ | +
+ test-css-module-server +
+ | + +
test-style-url-server
+ | + +
test-style-server-manual
+
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/tailwind/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/tailwind/client.tsx new file mode 100644 index 000000000..868bc0bb3 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/tailwind/client.tsx @@ -0,0 +1,5 @@ +'use client' + +export function TestTailwindClient() { + return
test-tw-client
+} diff --git a/packages/plugin-rsc/examples/basic/src/routes/tailwind/index.tsx b/packages/plugin-rsc/examples/basic/src/routes/tailwind/index.tsx new file mode 100644 index 000000000..7d1d691c6 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/tailwind/index.tsx @@ -0,0 +1,12 @@ +import { TestTailwindClient } from './client' +import { TestTailwindServer } from './server' + +export function TestTailwind() { + return ( +
+ + | + +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/tailwind/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/tailwind/server.tsx new file mode 100644 index 000000000..b130e1cae --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/tailwind/server.tsx @@ -0,0 +1,3 @@ +export function TestTailwindServer() { + return
test-tw-server
+} diff --git a/packages/plugin-rsc/examples/basic/src/routes/tailwind/unused.tsx b/packages/plugin-rsc/examples/basic/src/routes/tailwind/unused.tsx new file mode 100644 index 000000000..98476b011 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/tailwind/unused.tsx @@ -0,0 +1 @@ +console.log(
unused
) diff --git a/packages/plugin-rsc/examples/basic/src/routes/temporary-reference/action.tsx b/packages/plugin-rsc/examples/basic/src/routes/temporary-reference/action.tsx new file mode 100644 index 000000000..c855de521 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/temporary-reference/action.tsx @@ -0,0 +1,10 @@ +'use server' + +export async function action(node: React.ReactNode) { + 'use server' + return ( + + [server {node}] + + ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/temporary-reference/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/temporary-reference/client.tsx new file mode 100644 index 000000000..69dd7e9fa --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/temporary-reference/client.tsx @@ -0,0 +1,21 @@ +'use client' + +import React from 'react' +import { action } from './action' + +export function TestTemporaryReference() { + const [result, setResult] = React.useState('(none)') + + return ( +
+
{ + setResult(await action([client])) + }} + > + +
+
result: {result}
+
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/tree-shake/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/tree-shake/server.tsx new file mode 100644 index 000000000..df3cb2692 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/tree-shake/server.tsx @@ -0,0 +1,17 @@ +export function TestTreeShakeServer() { + return ( +
{ + 'use server' + console.log('test-tree-shake-server') + }} + > + +
+ ) +} + +// this should not be exported as server functions +export function __unused_server_export__() { + console.log('__unused_server_export__') +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib-client1.tsx b/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib-client1.tsx new file mode 100644 index 000000000..df4f7bfd6 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib-client1.tsx @@ -0,0 +1,5 @@ +'use client' + +export function LibClient1() { + return 'lib-client1' +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib-client2.tsx b/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib-client2.tsx new file mode 100644 index 000000000..4bb13bd3d --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib-client2.tsx @@ -0,0 +1,5 @@ +'use client' + +export function LibClient2() { + return 'lib-client2:__unused_tree_shake2__' +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib-server1.tsx b/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib-server1.tsx new file mode 100644 index 000000000..ff7640170 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib-server1.tsx @@ -0,0 +1,3 @@ +export function LibServer1() { + return 'lib-server1' +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib-server2.tsx b/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib-server2.tsx new file mode 100644 index 000000000..22bb7efc3 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib-server2.tsx @@ -0,0 +1,3 @@ +export function LibServer2() { + return 'lib-server2:__unused_tree_shake2__' +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib.tsx b/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib.tsx new file mode 100644 index 000000000..76d4c3c25 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/lib.tsx @@ -0,0 +1,4 @@ +export * from './lib-client1' +export * from './lib-client2' +export * from './lib-server1' +export * from './lib-server2' diff --git a/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/server.tsx new file mode 100644 index 000000000..39e2652d0 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/tree-shake2/server.tsx @@ -0,0 +1,10 @@ +import { LibClient1, LibServer1 } from './lib' + +export function TestTreeShake2() { + return ( +
+ test-tree-shake2: + | +
+ ) +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/use-cache/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/use-cache/server.tsx new file mode 100644 index 000000000..9c5e09f4c --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/use-cache/server.tsx @@ -0,0 +1,105 @@ +import { revalidateCache } from '../../framework/use-cache-runtime' + +export function TestUseCache() { + return ( + <> + + + + + ) +} + +function TestUseCacheFn() { + return ( +
{ + 'use server' + actionCount++ + const argument = formData.get('argument') + await testFn(argument) + if (argument === 'revalidate') { + revalidateCache(testFn) + } + }} + > + + + + (actionCount: {actionCount}, cacheFnCount: {cacheFnCount}) + +
+ ) +} + +let actionCount = 0 +let cacheFnCount = 0 + +async function testFn(..._args: unknown[]) { + 'use cache' + cacheFnCount++ +} + +function TestUseCacheComponent() { + // NOTE: wrapping with `span` (or any jsx) is crucial because + // raw string `children` would get included as cache key + // and thus causes `TestComponent` to be evaluated in each render. + return ( + + {new Date().toISOString()} + + ) +} + +async function TestComponent(props: { children?: React.ReactNode }) { + 'use cache' + return ( +
+ [test-use-cache-component]{' '} + + (static: {new Date().toISOString()}) + {' '} + + (dynamic: {props.children}) + +
+ ) +} + +async function TestUseCacheClosure() { + return ( +
+
{ + 'use server' + actionCount2++ + outerFnArg = formData.get('outer') as string + innerFnArg = formData.get('inner') as string + await outerFn(outerFnArg)(innerFnArg) + }} + > + + + +
+ + (actionCount: {actionCount2}, innerFnCount: {innerFnCount}) + +
+ ) +} + +function outerFn(outer: string) { + async function innerFn(inner: string) { + 'use cache' + innerFnCount++ + console.log({ outer, inner }) + } + return innerFn +} + +let outerFnArg = '' +let innerFnArg = '' +let innerFnCount = 0 +let actionCount2 = 0 diff --git a/packages/plugin-rsc/examples/basic/src/routes/use-id/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/use-id/client.tsx new file mode 100644 index 000000000..8a222c9e3 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/use-id/client.tsx @@ -0,0 +1,8 @@ +'use client' + +import { useId } from 'react' + +export function TestUseIdClient() { + const id = useId() + return <>test-useId-client: {id} +} diff --git a/packages/plugin-rsc/examples/basic/src/routes/use-id/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/use-id/server.tsx new file mode 100644 index 000000000..a97edefcf --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/use-id/server.tsx @@ -0,0 +1,17 @@ +import { useId } from 'react' +import { TestUseIdClient } from './client' + +export function TestUseId() { + return ( +
+ + | + +
+ ) +} + +function TestUseIdServer() { + const id = useId() + return <>test-useId-server: {id} +} diff --git a/packages/plugin-rsc/examples/basic/src/server.tsx b/packages/plugin-rsc/examples/basic/src/server.tsx new file mode 100644 index 000000000..b845bfba2 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/server.tsx @@ -0,0 +1,43 @@ +import { handleRequest } from './framework/entry.rsc.tsx' +import './styles.css' + +export default async function handler(request: Request): Promise { + const url = new URL(request.url) + const { Root } = await import('./routes/root.tsx') + const nonce = !process.env.NO_CSP ? crypto.randomUUID() : undefined + // https://vite.dev/guide/features.html#content-security-policy-csp + // this isn't needed if `style-src: 'unsafe-inline'` (dev) and `script-src: 'self'` + const nonceMeta = nonce && + const root = ( + <> + {/* this `loadCss` only collects `styles.css` but not css inside dynamic import `root.tsx` */} + {import.meta.viteRsc.loadCss()} + {nonceMeta} + + + ) + const response = await handleRequest({ + request, + getRoot: () => root, + nonce, + }) + if (nonce && response.headers.get('content-type')?.includes('text/html')) { + const cspValue = [ + `default-src 'self';`, + // `unsafe-eval` is required during dev since React uses eval for findSourceMapURL feature + `script-src 'self' 'nonce-${nonce}' ${import.meta.env.DEV ? `'unsafe-eval'` : ``};`, + `style-src 'self' 'unsafe-inline';`, + `img-src 'self' data:;`, + // allow blob: worker for Vite server ping shared worker + import.meta.hot && `worker-src 'self' blob:;`, + ] + .filter(Boolean) + .join('') + response.headers.set('content-security-policy', cspValue) + } + return response +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/basic/src/styles.css b/packages/plugin-rsc/examples/basic/src/styles.css new file mode 100644 index 000000000..2cb11909b --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/styles.css @@ -0,0 +1,13 @@ +@import 'tailwindcss'; + +button { + @apply bg-gray-100 mx-1 px-2 border hover:bg-gray-200 active:bg-gray-300; +} + +input { + @apply mx-1 px-2 border; +} + +a { + @apply text-gray-500 underline hover:text-gray-700 cursor-pointer; +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/cjs/index.js b/packages/plugin-rsc/examples/basic/test-dep/cjs/index.js new file mode 100644 index 000000000..94dc9b3a4 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/cjs/index.js @@ -0,0 +1 @@ +exports.ok = 'ok' diff --git a/packages/plugin-rsc/examples/basic/test-dep/cjs/package.json b/packages/plugin-rsc/examples/basic/test-dep/cjs/package.json new file mode 100644 index 000000000..dc270dca8 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/cjs/package.json @@ -0,0 +1,9 @@ +{ + "name": "@vitejs/test-dep-cjs", + "private": true, + "type": "commonjs", + "exports": "./index.js", + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/client-in-server/client.js b/packages/plugin-rsc/examples/basic/test-dep/client-in-server/client.js new file mode 100644 index 000000000..6a7a8d1ba --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/client-in-server/client.js @@ -0,0 +1,8 @@ +'use client' + +import React from 'react' + +export function TestClient() { + const [ok] = React.useState(() => true) + return React.createElement('span', null, String(ok)) +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/client-in-server/package.json b/packages/plugin-rsc/examples/basic/test-dep/client-in-server/package.json new file mode 100644 index 000000000..68ab77952 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/client-in-server/package.json @@ -0,0 +1,11 @@ +{ + "name": "@vitejs/test-dep-client-in-server", + "private": true, + "type": "module", + "exports": { + "./server": "./server.js" + }, + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/client-in-server/server.js b/packages/plugin-rsc/examples/basic/test-dep/client-in-server/server.js new file mode 100644 index 000000000..e145d3c52 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/client-in-server/server.js @@ -0,0 +1,6 @@ +import React from 'react' +import { TestClient } from './client.js' + +export async function TestClientInServerDep() { + return React.createElement(TestClient) +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/client-in-server2/client.js b/packages/plugin-rsc/examples/basic/test-dep/client-in-server2/client.js new file mode 100644 index 000000000..8a09b673a --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/client-in-server2/client.js @@ -0,0 +1,18 @@ +'use client' + +import React from 'react' + +const testContext = React.createContext() + +export function TestContextProvider(props) { + return React.createElement( + testContext.Provider, + { value: props.value }, + props.children, + ) +} + +export function TestContextValue() { + const value = React.useContext(testContext) + return React.createElement('span', null, String(value)) +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/client-in-server2/package.json b/packages/plugin-rsc/examples/basic/test-dep/client-in-server2/package.json new file mode 100644 index 000000000..fbc55fef5 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/client-in-server2/package.json @@ -0,0 +1,12 @@ +{ + "name": "@vitejs/test-dep-client-in-server2", + "private": true, + "type": "module", + "exports": { + "./server": "./server.js", + "./client": "./client.js" + }, + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/client-in-server2/server.js b/packages/plugin-rsc/examples/basic/test-dep/client-in-server2/server.js new file mode 100644 index 000000000..323d0e03f --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/client-in-server2/server.js @@ -0,0 +1,10 @@ +import React from 'react' +import { TestContextProvider } from './client.js' + +export function TestContextProviderInServer(props) { + return React.createElement( + TestContextProvider, + { value: props.value }, + props.children, + ) +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/css-in-server/package.json b/packages/plugin-rsc/examples/basic/test-dep/css-in-server/package.json new file mode 100644 index 000000000..946252b68 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/css-in-server/package.json @@ -0,0 +1,11 @@ +{ + "name": "@vitejs/test-dep-css-in-server", + "private": true, + "type": "module", + "exports": { + "./server": "./server.js" + }, + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/css-in-server/server.css b/packages/plugin-rsc/examples/basic/test-dep/css-in-server/server.css new file mode 100644 index 000000000..9f6f4f39a --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/css-in-server/server.css @@ -0,0 +1,3 @@ +.test-dep-css-in-server { + color: rgb(255, 165, 0); +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/css-in-server/server.d.ts b/packages/plugin-rsc/examples/basic/test-dep/css-in-server/server.d.ts new file mode 100644 index 000000000..8177ed95c --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/css-in-server/server.d.ts @@ -0,0 +1 @@ +export default function TestDepCssInServer(): import('react').ReactNode diff --git a/packages/plugin-rsc/examples/basic/test-dep/css-in-server/server.js b/packages/plugin-rsc/examples/basic/test-dep/css-in-server/server.js new file mode 100644 index 000000000..4fefceb50 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/css-in-server/server.js @@ -0,0 +1,12 @@ +import React from 'react' +import './server.css' + +const h = React.createElement + +export default function TestDepCssInServer() { + return h( + 'div', + { className: 'test-dep-css-in-server' }, + `test-dep-css-in-server`, + ) +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/server-in-client/client.js b/packages/plugin-rsc/examples/basic/test-dep/server-in-client/client.js new file mode 100644 index 000000000..bdb6c4596 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/server-in-client/client.js @@ -0,0 +1,21 @@ +'use client' + +import React from 'react' +import { changeCounter } from './server.js' + +const h = React.createElement + +export function TestClient() { + const [count, setCount] = React.useState(() => '?') + + return h( + 'button', + { + 'data-testid': 'server-in-client', + onClick: async () => { + setCount(await changeCounter(1)) + }, + }, + `server-in-client: ${count}`, + ) +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/server-in-client/package.json b/packages/plugin-rsc/examples/basic/test-dep/server-in-client/package.json new file mode 100644 index 000000000..d172984a1 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/server-in-client/package.json @@ -0,0 +1,11 @@ +{ + "name": "@vitejs/test-dep-server-in-client", + "private": true, + "type": "module", + "exports": { + "./client": "./client.js" + }, + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/server-in-client/server.js b/packages/plugin-rsc/examples/basic/test-dep/server-in-client/server.js new file mode 100644 index 000000000..3e76f153e --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/server-in-client/server.js @@ -0,0 +1,12 @@ +'use server' + +let counter = 0 + +export async function getCounter() { + return counter +} + +export async function changeCounter(change) { + counter += change + return counter +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/server-in-server/package.json b/packages/plugin-rsc/examples/basic/test-dep/server-in-server/package.json new file mode 100644 index 000000000..7a84ef530 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/server-in-server/package.json @@ -0,0 +1,11 @@ +{ + "name": "@vitejs/test-dep-server-in-server", + "private": true, + "type": "module", + "exports": { + "./server": "./server.js" + }, + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/server-in-server/server.js b/packages/plugin-rsc/examples/basic/test-dep/server-in-server/server.js new file mode 100644 index 000000000..bc553464a --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/server-in-server/server.js @@ -0,0 +1,19 @@ +import React from 'react' + +const h = React.createElement + +let counter = 0 + +export function ServerCounter() { + return h( + 'form', + { + 'data-testid': 'server-in-server', + action: async () => { + 'use server' + counter++ + }, + }, + h('button', null, `server-in-server: ${counter}`), + ) +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/transitive-cjs/client.js b/packages/plugin-rsc/examples/basic/test-dep/transitive-cjs/client.js new file mode 100644 index 000000000..87aa08e69 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/transitive-cjs/client.js @@ -0,0 +1,17 @@ +'use client' + +import React from 'react' + +import { ok } from '@vitejs/test-dep-cjs' + +const h = React.createElement + +export function TestClient() { + return h( + 'span', + { + 'data-testid': 'transitive-cjs-client', + }, + ok, + ) +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/transitive-cjs/package.json b/packages/plugin-rsc/examples/basic/test-dep/transitive-cjs/package.json new file mode 100644 index 000000000..e0a0eaea8 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/transitive-cjs/package.json @@ -0,0 +1,14 @@ +{ + "name": "@vitejs/test-dep-transitive-cjs", + "private": true, + "type": "module", + "exports": { + "./client": "./client.js" + }, + "dependencies": { + "@vitejs/test-dep-cjs": "file:../cjs" + }, + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/transitive-use-sync-external-store/client.js b/packages/plugin-rsc/examples/basic/test-dep/transitive-use-sync-external-store/client.js new file mode 100644 index 000000000..1ac3e0cc1 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/transitive-use-sync-external-store/client.js @@ -0,0 +1,28 @@ +'use client' + +import React from 'react' + +// similar to +// https://github.com/vercel/swr/blob/063fe55dddb95f0b6c3f1637a935c43d732ded78/src/index/use-swr.ts#L3 +// https://github.com/TanStack/store/blob/1d1323283e79059821d6c731eaaee60e4143dbc2/packages/react-store/src/index.ts#L1 +import { useSyncExternalStore } from 'use-sync-external-store/shim/index.js' + +const h = React.createElement + +const noopStore = () => () => {} + +export function TestClient() { + const value = useSyncExternalStore( + noopStore, + () => 'ok:browser', + () => 'ok:ssr', + ) + + return h( + 'span', + { + 'data-testid': 'transitive-use-sync-external-store-client', + }, + value, + ) +} diff --git a/packages/plugin-rsc/examples/basic/test-dep/transitive-use-sync-external-store/package.json b/packages/plugin-rsc/examples/basic/test-dep/transitive-use-sync-external-store/package.json new file mode 100644 index 000000000..c478349fa --- /dev/null +++ b/packages/plugin-rsc/examples/basic/test-dep/transitive-use-sync-external-store/package.json @@ -0,0 +1,14 @@ +{ + "name": "@vitejs/test-dep-transitive-use-sync-external-store", + "private": true, + "type": "module", + "exports": { + "./client": "./client.js" + }, + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "react": "*" + } +} diff --git a/packages/plugin-rsc/examples/basic/tsconfig.json b/packages/plugin-rsc/examples/basic/tsconfig.json new file mode 100644 index 000000000..77438d9db --- /dev/null +++ b/packages/plugin-rsc/examples/basic/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/basic/vite.config.ts b/packages/plugin-rsc/examples/basic/vite.config.ts new file mode 100644 index 000000000..e51f3d2a0 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/vite.config.ts @@ -0,0 +1,337 @@ +import assert from 'node:assert' +import rsc from '@vitejs/plugin-rsc' +import { transformHoistInlineDirective } from '@vitejs/plugin-rsc/transforms' +import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' +import { + type Plugin, + type Rollup, + defineConfig, + normalizePath, + parseAstAsync, +} from 'vite' +// import inspect from 'vite-plugin-inspect' +import path from 'node:path' +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' + +export default defineConfig({ + clearScreen: false, + plugins: [ + // inspect(), + tailwindcss(), + react(), + vitePluginUseCache(), + rsc({ + entries: { + client: './src/framework/entry.browser.tsx', + ssr: './src/framework/entry.ssr.tsx', + rsc: './src/server.tsx', + }, + // disable auto css injection to manually test `loadCss` feature. + rscCssTransform: false, + copyServerAssetsToClient: (fileName) => + fileName !== '__server_secret.txt', + clientChunks(meta) { + if (process.env.TEST_CUSTOM_CLIENT_CHUNKS) { + if (meta.id.includes('/src/routes/chunk/')) { + return 'custom-chunk' + } + } + }, + }), + { + name: 'test-tree-shake', + enforce: 'post', + writeBundle(_options, bundle) { + for (const chunk of Object.values(bundle)) { + if (chunk.type === 'chunk') { + assert(!chunk.code.includes('__unused_client_reference__')) + assert(!chunk.code.includes('__unused_server_export__')) + assert(!chunk.code.includes('__unused_tree_shake2__')) + } + } + }, + }, + { + // dump entire bundle to analyze build output for e2e + name: 'test-metadata', + enforce: 'post', + writeBundle(options, bundle) { + const chunks: Rollup.OutputChunk[] = [] + for (const chunk of Object.values(bundle)) { + if (chunk.type === 'chunk') { + chunks.push(chunk) + } + } + fs.writeFileSync( + path.join(options.dir!, '.vite/test.json'), + JSON.stringify({ chunks }, null, 2), + ) + }, + }, + { + name: 'test-server-assets-security', + buildStart() { + if (this.environment.name === 'rsc') { + this.emitFile({ + type: 'asset', + fileName: '__server_secret.txt', + source: '__server_secret', + }) + } + }, + writeBundle(_options, bundle) { + if (this.environment.name === 'rsc') { + assert(Object.keys(bundle).includes('__server_secret.txt')) + } else { + assert(!Object.keys(bundle).includes('__server_secret.txt')) + } + + const viteManifest = bundle['.vite/manifest.json'] + assert(viteManifest.type === 'asset') + assert(typeof viteManifest.source === 'string') + if (this.environment.name === 'rsc') { + assert(viteManifest.source.includes('src/server.tsx')) + assert( + !viteManifest.source.includes('src/framework/entry.browser.tsx'), + ) + } + if (this.environment.name === 'client') { + assert(!viteManifest.source.includes('src/server.tsx')) + assert( + viteManifest.source.includes('src/framework/entry.browser.tsx'), + ) + } + }, + }, + { + name: 'test-browser-only', + writeBundle(_options, bundle) { + const moduleIds = Object.values(bundle).flatMap((c) => + c.type === 'chunk' ? [...c.moduleIds] : [], + ) + const browserId = normalizePath( + path.resolve('src/routes/browser-only/browser-dep.tsx'), + ) + if (this.environment.name === 'client') { + assert(moduleIds.includes(browserId)) + } + if (this.environment.name === 'ssr') { + assert(!moduleIds.includes(browserId)) + } + }, + }, + { + name: 'optimize-chunks', + apply: 'build', + config() { + const resolvePackageSource = (source: string) => + normalizePath(fileURLToPath(import.meta.resolve(source))) + + // TODO: this package entry isn't a public API. + const reactServerDom = resolvePackageSource( + '@vitejs/plugin-rsc/react/browser', + ) + + return { + environments: { + client: { + build: { + rollupOptions: { + output: { + manualChunks: (id) => { + // need to use functional form to handle commonjs plugin proxy module + // e.g. `(id)?commonjs-es-import` + if ( + id.includes('node_modules/react/') || + id.includes('node_modules/react-dom/') || + id.includes(reactServerDom) + ) { + return 'lib-react' + } + if (id === '\0vite/preload-helper.js') { + return 'lib-vite' + } + }, + }, + }, + }, + }, + }, + } + }, + // verify chunks are "stable" + writeBundle(_options, bundle) { + if (this.environment.name === 'client') { + const entryChunks: Rollup.OutputChunk[] = [] + const libChunks: Record = {} + for (const chunk of Object.values(bundle)) { + if (chunk.type === 'chunk') { + if (chunk.isEntry) { + entryChunks.push(chunk) + } + if (chunk.name.startsWith('lib-')) { + ;(libChunks[chunk.name] ??= []).push(chunk) + } + } + } + + // react vendor chunk has no import + assert.equal(libChunks['lib-react'].length, 1) + assert.deepEqual( + // https://rolldown.rs/guide/in-depth/advanced-chunks#why-there-s-always-a-runtime-js-chunk + libChunks['lib-react'][0].imports.filter( + (f) => !f.includes('rolldown-runtime'), + ), + [], + ) + assert.deepEqual(libChunks['lib-react'][0].dynamicImports, []) + + // entry chunk has no export + assert.equal(entryChunks.length, 1) + assert.deepEqual(entryChunks[0].exports, []) + } + }, + }, + { + name: 'cf-build', + enforce: 'post', + apply: () => !!process.env.CF_BUILD, + configEnvironment() { + return { + keepProcessEnv: false, + define: { + 'process.env.NO_CSP': 'false', + }, + resolve: { + noExternal: true, + }, + } + }, + generateBundle() { + if (this.environment.name === 'rsc') { + this.emitFile({ + type: 'asset', + fileName: 'cloudflare.js', + source: `\ +import handler from './index.js'; +export default { fetch: handler }; +`, + }) + } + if (this.environment.name === 'client') { + // https://developers.cloudflare.com/workers/static-assets/headers/#custom-headers + this.emitFile({ + type: 'asset', + fileName: '_headers', + source: `\ +/favicon.ico + Cache-Control: public, max-age=3600, s-maxage=3600 +/test.css + Cache-Control: public, max-age=3600, s-maxage=3600 +/assets/* + Cache-Control: public, max-age=31536000, immutable +`, + }) + } + }, + }, + testBuildPlugin(), + ], + build: { + minify: false, + manifest: true, + }, + environments: { + client: { + optimizeDeps: { + entries: [ + './src/routes/**/client.tsx', + './src/framework/entry.browser.tsx', + ], + exclude: [ + '@vitejs/test-dep-client-in-server/client', + '@vitejs/test-dep-client-in-server2/client', + '@vitejs/test-dep-server-in-client/client', + ], + }, + }, + ssr: { + optimizeDeps: { + include: ['@vitejs/test-dep-transitive-cjs > @vitejs/test-dep-cjs'], + }, + }, + }, +}) as any + +function testBuildPlugin(): Plugin[] { + const moduleIds: { name: string; ids: string[] }[] = [] + return [ + { + name: 'test-scan', + apply: 'build', + buildEnd() { + moduleIds.push({ + name: this.environment.name, + ids: [...this.getModuleIds()], + }) + }, + buildApp: { + order: 'post', + async handler() { + // client scan build discovers additional modules for server references. + const [m1, m2] = moduleIds.filter((m) => m.name === 'rsc') + const diff = m2.ids.filter((id) => !m1.ids.includes(id)) + assert(diff.length > 0) + + // but make sure it's not due to import.meta.glob + // https://github.com/vitejs/rolldown-vite/issues/373 + assert.equal( + diff.find((id) => id.includes('import-meta-glob/dep.tsx')), + undefined, + ) + }, + }, + }, + { + name: 'test-copyPublicDir', + apply: 'build', + buildApp: { + order: 'post', + async handler() { + assert(fs.existsSync('dist/client/favicon.ico')) + assert(!fs.existsSync('dist/rsc/favicon.ico')) + assert(!fs.existsSync('dist/ssr/favicon.ico')) + }, + }, + }, + ] +} + +function vitePluginUseCache(): Plugin[] { + return [ + { + name: 'use-cache', + async transform(code) { + if (!code.includes('use cache')) return + const ast = await parseAstAsync(code) + // @ts-ignore for rolldown-vite ci estree/oxc mismatch + const result = transformHoistInlineDirective(code, ast, { + runtime: (value) => `__vite_rsc_cache(${value})`, + directive: 'use cache', + rejectNonAsyncFunction: true, + noExport: true, + }) + if (!result.output.hasChanged()) return + result.output.prepend( + `import __vite_rsc_cache from "/src/framework/use-cache-runtime";`, + ) + return { + code: result.output.toString(), + map: result.output.generateMap({ hires: 'boundary' }), + } + }, + }, + ] +} diff --git a/packages/plugin-rsc/examples/basic/wrangler.jsonc b/packages/plugin-rsc/examples/basic/wrangler.jsonc new file mode 100644 index 000000000..0d5ead71e --- /dev/null +++ b/packages/plugin-rsc/examples/basic/wrangler.jsonc @@ -0,0 +1,11 @@ +{ + "$schema": "https://www.unpkg.com/wrangler@4.13.0/config-schema.json", + "name": "vite-rsc-basic", + "main": "dist/rsc/cloudflare.js", + "assets": { + "directory": "dist/client", + }, + "workers_dev": true, + "compatibility_date": "2025-04-01", + "compatibility_flags": ["nodejs_als"], +} diff --git a/packages/plugin-rsc/examples/browser-mode/README.md b/packages/plugin-rsc/examples/browser-mode/README.md new file mode 100644 index 000000000..2e9ed6455 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/README.md @@ -0,0 +1 @@ +[examples/starter](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) but entirely on Browser. Inspired by https://github.com/kasperpeulen/vitest-plugin-rsc/ diff --git a/packages/plugin-rsc/examples/browser-mode/index.html b/packages/plugin-rsc/examples/browser-mode/index.html new file mode 100644 index 000000000..6323c94f5 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/index.html @@ -0,0 +1,13 @@ + + + + + RSC Browser Mode + + + + + +
+ + diff --git a/packages/plugin-rsc/examples/browser-mode/package.json b/packages/plugin-rsc/examples/browser-mode/package.json new file mode 100644 index 000000000..ff391f248 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/package.json @@ -0,0 +1,23 @@ +{ + "name": "@vitejs/plugin-rsc-examples-browser-mode", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "vite": "^7.1.4" + } +} diff --git a/packages/plugin-rsc/examples/browser-mode/public/vite.svg b/packages/plugin-rsc/examples/browser-mode/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/browser-mode/src/action-bind/client.tsx b/packages/plugin-rsc/examples/browser-mode/src/action-bind/client.tsx new file mode 100644 index 000000000..2fe0c81c6 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/action-bind/client.tsx @@ -0,0 +1,12 @@ +'use client' + +import React from 'react' + +export function ActionBindClient() { + const hydrated = React.useSyncExternalStore( + React.useCallback(() => () => {}, []), + () => true, + () => false, + ) + return <>{String(hydrated)} +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/action-bind/form.tsx b/packages/plugin-rsc/examples/browser-mode/src/action-bind/form.tsx new file mode 100644 index 000000000..1b1675c3a --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/action-bind/form.tsx @@ -0,0 +1,16 @@ +'use client' + +import React from 'react' + +export function TestServerActionBindClientForm(props: { + action: () => Promise +}) { + const [result, formAction] = React.useActionState(props.action, '[?]') + + return ( +
+ + {result} +
+ ) +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/action-bind/server.tsx b/packages/plugin-rsc/examples/browser-mode/src/action-bind/server.tsx new file mode 100644 index 000000000..dda9ee2ed --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/action-bind/server.tsx @@ -0,0 +1,107 @@ +// based on test cases in +// https://github.com/vercel/next.js/blob/ad898de735c393d98960a68c8d9eaeee32206c57/test/e2e/app-dir/actions/app/encryption/page.js + +import { ActionBindClient } from './client' +import { TestServerActionBindClientForm } from './form' + +export function TestActionBind() { + return ( + <> + + + + + + ) +} + +export function TestServerActionBindReset() { + return ( +
{ + 'use server' + testServerActionBindSimpleState = '[?]' + testServerActionBindActionState = '[?]' + testServerActionBindClientState++ + }} + > + +
+ ) +} + +let testServerActionBindSimpleState = '[?]' + +export function TestServerActionBindSimple() { + const outerValue = 'outerValue' + + return ( +
{ + 'use server' + const result = String(formData.get('value')) === outerValue + testServerActionBindSimpleState = JSON.stringify(result) + }} + > + + + + {testServerActionBindSimpleState} + +
+ ) +} + +let testServerActionBindClientState = 0 + +export function TestServerActionBindClient() { + // client element as server action bound argument + const client = + + const action = async () => { + 'use server' + return client + } + + return ( + + ) +} + +let testServerActionBindActionState = '[?]' + +export function TestServerActionBindAction() { + async function otherAction() { + 'use server' + return 'otherActionValue' + } + + function wrapAction(value: string, action: () => Promise) { + return async function (formValue: string) { + 'use server' + const actionValue = await action() + return [actionValue === 'otherActionValue', formValue === value] + } + } + + const action = wrapAction('ok', otherAction) + + return ( +
{ + 'use server' + const result = await action(String(formData.get('value'))) + testServerActionBindActionState = JSON.stringify(result) + }} + > + + + + {testServerActionBindActionState} + +
+ ) +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/action-from-client/action.tsx b/packages/plugin-rsc/examples/browser-mode/src/action-from-client/action.tsx new file mode 100644 index 000000000..a72eb0bda --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/action-from-client/action.tsx @@ -0,0 +1,5 @@ +'use server' + +export async function testActionState(prev: number) { + return prev + 1 +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/action-from-client/client.tsx b/packages/plugin-rsc/examples/browser-mode/src/action-from-client/client.tsx new file mode 100644 index 000000000..aca850f2f --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/action-from-client/client.tsx @@ -0,0 +1,14 @@ +'use client' + +import React from 'react' +import { testActionState } from './action' + +export function TestUseActionState() { + const [state, formAction] = React.useActionState(testActionState, 0) + + return ( +
+ +
+ ) +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/action.tsx b/packages/plugin-rsc/examples/browser-mode/src/action.tsx new file mode 100644 index 000000000..4fc55d65b --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/action.tsx @@ -0,0 +1,11 @@ +'use server' + +let serverCounter = 0 + +export async function getServerCounter() { + return serverCounter +} + +export async function updateServerCounter(change: number) { + serverCounter += change +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/assets/react.svg b/packages/plugin-rsc/examples/browser-mode/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/browser-mode/src/client.tsx b/packages/plugin-rsc/examples/browser-mode/src/client.tsx new file mode 100644 index 000000000..29bb5d367 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/client.tsx @@ -0,0 +1,13 @@ +'use client' + +import React from 'react' + +export function ClientCounter() { + const [count, setCount] = React.useState(0) + + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/browser-mode/src/framework/entry.browser.tsx new file mode 100644 index 000000000..b5a85a034 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/framework/entry.browser.tsx @@ -0,0 +1,74 @@ +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { + createFromFetch, + setRequireModule, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/react/browser' +import type { RscPayload } from './entry.rsc' +import buildClientReferences from 'virtual:vite-rsc-browser-mode/build-client-references' + +let fetchServer: typeof import('./entry.rsc').fetchServer + +export function initialize(options: { fetchServer: typeof fetchServer }) { + fetchServer = options.fetchServer + setRequireModule({ + load: (id) => { + if (import.meta.env.__vite_rsc_build__) { + const import_ = buildClientReferences[id] + if (!import_) { + throw new Error(`invalid client reference: ${id}`) + } + return import_() + } else { + return import(/* @vite-ignore */ id) + } + }, + }) +} + +export async function main() { + let setPayload: (v: RscPayload) => void + + const initialPayload = await createFromFetch( + fetchServer(new Request(window.location.href)), + ) + + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + return payload.root + } + + setServerCallback(async (id, args) => { + const url = new URL(window.location.href) + const temporaryReferences = createTemporaryReferenceSet() + const payload = await createFromFetch( + fetchServer( + new Request(url, { + method: 'POST', + body: await encodeReply(args, { temporaryReferences }), + headers: { + 'x-rsc-action': id, + }, + }), + ), + { temporaryReferences }, + ) + setPayload(payload) + return payload.returnValue + }) + + const browserRoot = ( + + + + ) + createRoot(document.body).render(browserRoot) +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/browser-mode/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..3cbc83a99 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/framework/entry.rsc.tsx @@ -0,0 +1,73 @@ +import { + setRequireModule, + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/react/rsc' +import type React from 'react' +import { Root } from '../root' +import type { ReactFormState } from 'react-dom/client' +import buildServerReferences from 'virtual:vite-rsc-browser-mode/build-server-references' + +export type RscPayload = { + root: React.ReactNode + returnValue?: unknown + formState?: ReactFormState +} + +declare let __vite_rsc_raw_import__: (id: string) => Promise + +export function initialize() { + setRequireModule({ + load: (id) => { + if (import.meta.env.__vite_rsc_build__) { + const import_ = buildServerReferences[id] + if (!import_) { + throw new Error(`invalid server reference: ${id}`) + } + return import_() + } else { + return __vite_rsc_raw_import__(/* @vite-ignore */ id) + } + }, + }) +} + +export async function fetchServer(request: Request): Promise { + const isAction = request.method === 'POST' + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + if (isAction) { + const actionId = request.headers.get('x-rsc-action') + if (actionId) { + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(actionId) + returnValue = await action.apply(null, args) + } else { + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } + } + + const rscPayload: RscPayload = { root: , formState, returnValue } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + return new Response(rscStream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + vary: 'accept', + }, + }) +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/load-client-dev.tsx b/packages/plugin-rsc/examples/browser-mode/src/framework/load-client-dev.tsx new file mode 100644 index 000000000..8ca40ae43 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/framework/load-client-dev.tsx @@ -0,0 +1,25 @@ +import { ESModulesEvaluator, ModuleRunner } from 'vite/module-runner' + +export default async function loadClient() { + const runner = new ModuleRunner( + { + sourcemapInterceptor: false, + transport: { + invoke: async (payload) => { + const response = await fetch( + '/@vite/invoke-react-client?' + + new URLSearchParams({ + data: JSON.stringify(payload), + }), + ) + return response.json() + }, + }, + hmr: false, + }, + new ESModulesEvaluator(), + ) + return await runner.import( + '/src/framework/entry.browser.tsx', + ) +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/main.tsx b/packages/plugin-rsc/examples/browser-mode/src/framework/main.tsx new file mode 100644 index 000000000..6a485deb4 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/framework/main.tsx @@ -0,0 +1,11 @@ +import * as server from './entry.rsc' +import loadClient from 'virtual:vite-rsc-browser-mode/load-client' + +async function main() { + const client = await loadClient() + server.initialize() + client.initialize({ fetchServer: server.fetchServer }) + await client.main() +} + +main() diff --git a/packages/plugin-rsc/examples/browser-mode/src/framework/virtual.d.ts b/packages/plugin-rsc/examples/browser-mode/src/framework/virtual.d.ts new file mode 100644 index 000000000..74f7cc59d --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/framework/virtual.d.ts @@ -0,0 +1,14 @@ +declare module 'virtual:vite-rsc-browser-mode/build-client-references' { + const default_: Record Promise> + export default default_ +} + +declare module 'virtual:vite-rsc-browser-mode/build-server-references' { + const default_: Record Promise> + export default default_ +} + +declare module 'virtual:vite-rsc-browser-mode/load-client' { + const default_: () => Promise + export default default_ +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/index.css b/packages/plugin-rsc/examples/browser-mode/src/index.css new file mode 100644 index 000000000..f4d2128c0 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/index.css @@ -0,0 +1,112 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 1rem; +} + +.read-the-docs { + color: #888; + text-align: left; +} diff --git a/packages/plugin-rsc/examples/browser-mode/src/root.tsx b/packages/plugin-rsc/examples/browser-mode/src/root.tsx new file mode 100644 index 000000000..e8d912527 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/src/root.tsx @@ -0,0 +1,44 @@ +import './index.css' +import viteLogo from '/vite.svg' +import { getServerCounter, updateServerCounter } from './action.tsx' +import reactLogo from './assets/react.svg' +import { ClientCounter } from './client.tsx' +import { TestUseActionState } from './action-from-client/client.tsx' +import { TestActionBind } from './action-bind/server.tsx' + +export function Root() { + return +} + +function App() { + return ( +
+ +

Vite + RSC

+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ ) +} diff --git a/packages/plugin-rsc/examples/browser-mode/tsconfig.json b/packages/plugin-rsc/examples/browser-mode/tsconfig.json new file mode 100644 index 000000000..4c355ed3c --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/browser-mode/vite.config.ts b/packages/plugin-rsc/examples/browser-mode/vite.config.ts new file mode 100644 index 000000000..b03059904 --- /dev/null +++ b/packages/plugin-rsc/examples/browser-mode/vite.config.ts @@ -0,0 +1,235 @@ +import { defaultClientConditions, defineConfig, type Plugin } from 'vite' +import { + vitePluginRscMinimal, + getPluginApi, + type PluginApi, +} from '@vitejs/plugin-rsc/plugin' +// import inspect from 'vite-plugin-inspect' + +export default defineConfig({ + plugins: [ + // inspect(), + rscBrowserModePlugin(), + ], + environments: { + client: { + build: { + minify: false, + }, + }, + }, +}) + +function rscBrowserModePlugin(): Plugin[] { + let manager: PluginApi['manager'] + + return [ + ...vitePluginRscMinimal({ + environment: { + rsc: 'client', + browser: 'react_client', + }, + }), + { + name: 'rsc-browser-mode', + config(userConfig, env) { + return { + define: { + 'import.meta.env.__vite_rsc_build__': JSON.stringify( + env.command === 'build', + ), + }, + environments: { + client: { + keepProcessEnv: false, + resolve: { + conditions: ['react-server', ...defaultClientConditions], + }, + optimizeDeps: { + include: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@vitejs/plugin-rsc/vendor/react-server-dom/server.edge', + '@vitejs/plugin-rsc/vendor/react-server-dom/client.edge', + ], + exclude: ['vite', '@vitejs/plugin-rsc'], + }, + build: { + outDir: 'dist/client', + }, + }, + react_client: { + keepProcessEnv: false, + resolve: { + conditions: [...defaultClientConditions], + noExternal: true, + }, + optimizeDeps: { + include: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + '@vitejs/plugin-rsc/vendor/react-server-dom/client.browser', + ], + exclude: ['@vitejs/plugin-rsc'], + esbuildOptions: { + platform: 'browser', + }, + }, + build: { + outDir: 'dist/react_client', + copyPublicDir: false, + emitAssets: true, + rollupOptions: { + input: { + index: './src/framework/entry.browser.tsx', + }, + }, + }, + }, + }, + builder: { + sharedPlugins: true, + sharedConfigBuild: true, + }, + build: { + // packages/common/warning.ts + rollupOptions: { + onwarn(warning, defaultHandler) { + if ( + warning.code === 'MODULE_LEVEL_DIRECTIVE' && + (warning.message.includes('use client') || + warning.message.includes('use server')) + ) { + return + } + // https://github.com/vitejs/vite/issues/15012 + if ( + warning.code === 'SOURCEMAP_ERROR' && + warning.message.includes('resolve original location') && + warning.pos === 0 + ) { + return + } + if (userConfig.build?.rollupOptions?.onwarn) { + userConfig.build.rollupOptions.onwarn(warning, defaultHandler) + } else { + defaultHandler(warning) + } + }, + }, + }, + } + }, + configResolved(config) { + manager = getPluginApi(config)!.manager + }, + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const url = new URL(req.url ?? '/', 'https://any.local') + if (url.pathname === '/@vite/invoke-react-client') { + const payload = JSON.parse(url.searchParams.get('data')!) + const result = + await server.environments['react_client']!.hot.handleInvoke( + payload, + ) + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(result)) + return + } + next() + }) + }, + hotUpdate(ctx) { + if (this.environment.name === 'react_client') { + if (ctx.modules.length > 0) { + ctx.server.environments.client.hot.send({ + type: 'full-reload', + path: ctx.file, + }) + } + } + }, + async buildApp(builder) { + const reactServer = builder.environments.client! + const reactClient = builder.environments['react_client']! + manager.isScanBuild = true + reactServer.config.build.write = false + await builder.build(reactServer) + manager.isScanBuild = false + reactServer.config.build.write = true + await builder.build(reactClient) + await builder.build(reactServer) + }, + }, + { + name: 'rsc-browser-mode:load-client', + resolveId(source) { + if (source === 'virtual:vite-rsc-browser-mode/load-client') { + if (this.environment.mode === 'dev') { + return this.resolve('/src/framework/load-client-dev') + } + return '\0' + source + } + }, + load(id) { + if (id === '\0virtual:vite-rsc-browser-mode/load-client') { + if (manager.isScanBuild) { + return `export default async () => {}` + } else { + return `export default async () => import("/dist/react_client/index.js")` + } + } + }, + }, + { + name: 'rsc-browser-mode:build-client-references', + resolveId(source) { + if ( + source === 'virtual:vite-rsc-browser-mode/build-client-references' + ) { + return '\0' + source + } + }, + load(id) { + if (id === '\0virtual:vite-rsc-browser-mode/build-client-references') { + if (this.environment.mode === 'dev') { + return `export default {}` // no-op during dev + } + let code = '' + for (const meta of Object.values(manager.clientReferenceMetaMap)) { + code += `${JSON.stringify(meta.referenceKey)}: () => import(${JSON.stringify(meta.importId)}),` + } + return `export default {${code}}` + } + }, + }, + { + name: 'rsc-browser-mode:build-server-references', + resolveId(source) { + if ( + source === 'virtual:vite-rsc-browser-mode/build-server-references' + ) { + return '\0' + source + } + }, + load(id) { + if (id === '\0virtual:vite-rsc-browser-mode/build-server-references') { + if (this.environment.mode === 'dev') { + return `export default {}` // no-op during dev + } + let code = '' + for (const meta of Object.values(manager.serverReferenceMetaMap)) { + code += `${JSON.stringify(meta.referenceKey)}: () => import(${JSON.stringify(meta.importId)}),` + } + return `export default {${code}}` + } + }, + }, + ] +} diff --git a/packages/plugin-rsc/examples/e2e/middleware-mode.ts b/packages/plugin-rsc/examples/e2e/middleware-mode.ts new file mode 100644 index 000000000..2b0c38cd4 --- /dev/null +++ b/packages/plugin-rsc/examples/e2e/middleware-mode.ts @@ -0,0 +1,46 @@ +import path from 'node:path' +import { pathToFileURL } from 'node:url' +// @ts-ignore +import connect from 'connect' +import { createRequestListener } from '@remix-run/node-fetch-server' +import sirv from 'sirv' +import type { Connect } from 'vite' + +async function main() { + const app = connect() as Connect.Server + const command = process.argv[2] + if (command === 'dev') { + const { createServer } = await import('vite') + const server = await createServer({ + clearScreen: false, + server: { middlewareMode: true }, + }) + app.use(server.middlewares) + } else if (command === 'start') { + app.use( + sirv('./dist/client', { + etag: true, + dev: true, + extensions: [], + ignores: false, + }), + ) + const entry = await import( + pathToFileURL(path.resolve('dist/rsc/index.js')).href + ) + app.use(createRequestListener(entry.default)) + } else { + console.error(`Unknown command: ${command}`) + process.exitCode = 1 + return + } + + const port = process.env.PORT || 3000 + app.listen(port) + console.log(`Server started at http://localhost:${port}`) +} + +main().catch((e) => { + console.error(e) + process.exitCode = 1 +}) diff --git a/packages/plugin-rsc/examples/e2e/package.json b/packages/plugin-rsc/examples/e2e/package.json new file mode 100644 index 000000000..9121f0090 --- /dev/null +++ b/packages/plugin-rsc/examples/e2e/package.json @@ -0,0 +1,12 @@ +{ + "name": "@vitejs/plugin-rsc-examples-e2e", + "private": true, + "type": "module", + "devDependencies": { + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "babel-plugin-react-compiler": "19.1.0-rc.3", + "connect": "^3.7.0", + "sirv": "^3.0.2" + } +} diff --git a/packages/plugin-rsc/examples/e2e/tsconfig.json b/packages/plugin-rsc/examples/e2e/tsconfig.json new file mode 100644 index 000000000..49a0459e3 --- /dev/null +++ b/packages/plugin-rsc/examples/e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["*.ts"], + "compilerOptions": { + "noPropertyAccessFromIndexSignature": false, + "noImplicitReturns": false, + "checkJs": false, + "declaration": true, + "isolatedDeclarations": true, + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/no-ssr/README.md b/packages/plugin-rsc/examples/no-ssr/README.md new file mode 100644 index 000000000..db13dfe8d --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/README.md @@ -0,0 +1 @@ +[examples/starter](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) without SSR environment diff --git a/packages/plugin-rsc/examples/no-ssr/index.html b/packages/plugin-rsc/examples/no-ssr/index.html new file mode 100644 index 000000000..01b0331d7 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/index.html @@ -0,0 +1,12 @@ + + + + + + + + + +
+ + diff --git a/packages/plugin-rsc/examples/no-ssr/package.json b/packages/plugin-rsc/examples/no-ssr/package.json new file mode 100644 index 000000000..9e731e474 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/package.json @@ -0,0 +1,23 @@ +{ + "name": "@vitejs/plugin-rsc-examples-no-ssr", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "vite": "^7.1.4" + } +} diff --git a/packages/plugin-rsc/examples/no-ssr/public/vite.svg b/packages/plugin-rsc/examples/no-ssr/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/no-ssr/src/action.tsx b/packages/plugin-rsc/examples/no-ssr/src/action.tsx new file mode 100644 index 000000000..4fc55d65b --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/src/action.tsx @@ -0,0 +1,11 @@ +'use server' + +let serverCounter = 0 + +export async function getServerCounter() { + return serverCounter +} + +export async function updateServerCounter(change: number) { + serverCounter += change +} diff --git a/packages/plugin-rsc/examples/no-ssr/src/assets/react.svg b/packages/plugin-rsc/examples/no-ssr/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/no-ssr/src/client.tsx b/packages/plugin-rsc/examples/no-ssr/src/client.tsx new file mode 100644 index 000000000..29bb5d367 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/src/client.tsx @@ -0,0 +1,13 @@ +'use client' + +import React from 'react' + +export function ClientCounter() { + const [count, setCount] = React.useState(0) + + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/no-ssr/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.browser.tsx new file mode 100644 index 000000000..f33a65500 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.browser.tsx @@ -0,0 +1,127 @@ +import { + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { createRoot } from 'react-dom/client' +import type { RscPayload } from './entry.rsc' + +async function main() { + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void + + const initialPayload = await createFromFetch( + fetch(window.location.href), + ) + + // browser root component to (re-)render RSC payload as state + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const payload = await createFromFetch( + fetch(window.location.href), + ) + setPayload(payload) + } + + // register a handler which will be internally called by React + // on server function request after hydration. + setServerCallback(async (id, args) => { + const url = new URL(window.location.href) + const temporaryReferences = createTemporaryReferenceSet() + const payload = await createFromFetch( + fetch(url, { + method: 'POST', + body: await encodeReply(args, { temporaryReferences }), + headers: { + 'x-rsc-action': id, + }, + }), + { temporaryReferences }, + ) + setPayload(payload) + return payload.returnValue + }) + + // hydration + const browserRoot = ( + + + + ) + createRoot(document.body).render(browserRoot) + + // implement server HMR by trigering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +main() diff --git a/packages/plugin-rsc/examples/no-ssr/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..27a5ce931 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/src/framework/entry.rsc.tsx @@ -0,0 +1,56 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' + +export type RscPayload = { + root: React.ReactNode + returnValue?: unknown + formState?: ReactFormState +} + +export default async function handler(request: Request): Promise { + const isAction = request.method === 'POST' + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + if (isAction) { + const actionId = request.headers.get('x-rsc-action') + if (actionId) { + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(actionId) + returnValue = await action.apply(null, args) + } else { + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } + } + + const rscPayload: RscPayload = { root: , formState, returnValue } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + return new Response(rscStream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + vary: 'accept', + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/no-ssr/src/index.css b/packages/plugin-rsc/examples/no-ssr/src/index.css new file mode 100644 index 000000000..f4d2128c0 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/src/index.css @@ -0,0 +1,112 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 1rem; +} + +.read-the-docs { + color: #888; + text-align: left; +} diff --git a/packages/plugin-rsc/examples/no-ssr/src/root.tsx b/packages/plugin-rsc/examples/no-ssr/src/root.tsx new file mode 100644 index 000000000..9baa7b9c2 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/src/root.tsx @@ -0,0 +1,44 @@ +import './index.css' // css import is automatically injected in exported server components +import viteLogo from '/vite.svg' +import { getServerCounter, updateServerCounter } from './action.tsx' +import reactLogo from './assets/react.svg' +import { ClientCounter } from './client.tsx' + +export function Root() { + return +} + +function App() { + return ( +
+ +

Vite + RSC

+
+ +
+
+
+ +
+
+
    +
  • + Edit src/client.tsx to test client HMR. +
  • +
  • + Edit src/root.tsx to test server HMR. +
  • +
+
+ ) +} diff --git a/packages/plugin-rsc/examples/no-ssr/tsconfig.json b/packages/plugin-rsc/examples/no-ssr/tsconfig.json new file mode 100644 index 000000000..4c355ed3c --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/no-ssr/vite.config.ts b/packages/plugin-rsc/examples/no-ssr/vite.config.ts new file mode 100644 index 000000000..ce349c6e9 --- /dev/null +++ b/packages/plugin-rsc/examples/no-ssr/vite.config.ts @@ -0,0 +1,65 @@ +import rsc from '@vitejs/plugin-rsc' +import react from '@vitejs/plugin-react' +import { defineConfig, type Plugin } from 'vite' +import fsp from 'node:fs/promises' + +export default defineConfig({ + plugins: [ + spaPlugin(), + react(), + rsc({ + entries: { + rsc: './src/framework/entry.rsc.tsx', + }, + }), + ], +}) + +function spaPlugin(): Plugin[] { + // serve index.html before rsc server + return [ + { + name: 'serve-spa', + configureServer(server) { + return () => { + server.middlewares.use(async (req, res, next) => { + try { + if (req.headers.accept?.includes('text/html')) { + const html = await fsp.readFile('index.html', 'utf-8') + const transformed = await server.transformIndexHtml('/', html) + res.setHeader('Content-type', 'text/html') + res.setHeader('Vary', 'accept') + res.end(transformed) + return + } + } catch (error) { + next(error) + return + } + next() + }) + } + }, + configurePreviewServer(server) { + return () => { + server.middlewares.use(async (req, res, next) => { + try { + if (req.headers.accept?.includes('text/html')) { + const html = await fsp.readFile( + 'dist/client/index.html', + 'utf-8', + ) + res.end(html) + return + } + } catch (error) { + next(error) + return + } + next() + }) + } + }, + }, + ] +} diff --git a/packages/plugin-rsc/examples/react-router/README.md b/packages/plugin-rsc/examples/react-router/README.md new file mode 100644 index 000000000..27e7f9c77 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/README.md @@ -0,0 +1,34 @@ +# rsc react-router + +https://vite-rsc-react-router.hiro18181.workers.dev + +> [!NOTE] +> React Router now provides [official RSC support](https://reactrouter.com/how-to/react-server-components) for Vite. The example might not be kept up to date with the latest version. Please refer to React Router's official documentation for the latest integrations. + +Vite RSC example based on demo made by React router team with Parcel: + +- https://github.com/jacob-ebey/parcel-plugin-react-router/ +- https://github.com/jacob-ebey/experimental-parcel-react-router-starter +- https://github.com/remix-run/react-router/tree/rsc/playground/rsc-vite + +See also [`rsc-movies`](https://github.com/hi-ogawa/rsc-movies/). + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/react-router?file=src%2Froutes%2Froot.tsx) + +Or try it locally by: + +```sh +npx giget gh:vitejs/vite-plugin-react/packages/plugin-rsc/examples/react-router my-app +cd my-app +npm i +npm run dev +npm run build +npm run preview + +# run on @cloudflare/vite-plugin and deploy. +# a separate configuration is found in ./cf/vite.config.ts +npm run cf-dev +npm run cf-build +npm run cf-preview +npm run cf-release +``` diff --git a/packages/plugin-rsc/examples/react-router/app/paper.css b/packages/plugin-rsc/examples/react-router/app/paper.css new file mode 100644 index 000000000..761e51864 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/paper.css @@ -0,0 +1,150 @@ +@theme { + --default-font-family: 'Patrick Hand SC', sans-serif; + --default-mono-font-family: 'Patrick Hand SC', sans-serif; + + --color-foreground: black; + --color-danger: rgb(167, 52, 45); + --color-secondary: rgb(11, 116, 213); + --color-success: rgb(134, 163, 97); + --color-warning: rgb(221, 205, 69); + --color-border: #cdcccb; + --color-border-active: rgba(0, 0, 0, 0.2); + + --color-paper-background: white; + --color-paper-border: #cdcccb; + --shadow-paper: -1px 5px 35px -9px rgba(0, 0, 0, 0.2); + + --shadow-btn: 15px 28px 25px -18px rgba(0, 0, 0, 0.2); + --shadow-btn-hover: 2px 8px 8px -5px rgba(0, 0, 0, 0.3); + --color-btn-border: black; + --btn-color-danger: var(--color-danger); + --btn-color-secondary: var(--color-secondary); + --btn-color-success: var(--color-success); + --btn-color-warning: var(--color-warning); +} + +@utility paper-border { + @apply border-2 border-border; + border-bottom-left-radius: 25px 115px; + border-bottom-right-radius: 155px 25px; + border-top-left-radius: 15px 225px; + border-top-right-radius: 25px 150px; +} + +@utility no-paper-border { + @apply border-0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +@utility paper-underline { + @apply border-b-3 border-[currentcolor]; + border-bottom-left-radius: 15px 3px; + border-bottom-right-radius: 15px 5px; + border-bottom-style: solid; +} + +@utility paper-underline-hover { + @apply paper-underline border-transparent; + @variant hover { + @apply border-[currentcolor]; + } +} + +@utility paper { + @apply border border-paper-border bg-paper-background p-8 shadow-paper; +} + +@utility breadcrumbs { + @apply flex flex-wrap gap-2; + & > * { + @apply inline-block after:text-lg after:content-[""] not-last:after:ml-2 not-last:after:text-foreground not-last:after:content-["/"]; + } + & > a { + @apply text-secondary; + } +} + +@utility btn { + @apply inline-block cursor-pointer bg-paper-background paper-border px-4 py-2 text-lg shadow-btn transition-[shadow_transition]; + + @variant active { + @apply border-border-active; + } + @variant hover { + @apply translate-y-1 shadow-btn-hover; + } + + &.btn-icon { + @apply aspect-square px-2 py-2; + & img, + & svg { + @apply h-7 w-7; + } + } +} + +@utility btn-* { + border-color: --value(--btn-color-*); + color: --value(--btn-color-*); +} + +@utility btn-sm { + @apply px-2 py-1 text-base; +} + +@utility btn-lg { + @apply px-6 py-3 text-2xl; +} + +@utility label { + @apply mb-1 block font-semibold; +} + +@utility input { + @apply paper-border px-3 py-2; + + @variant disabled { + @apply border-border-active; + } +} + +@utility checkbox { + @apply h-6 w-6 paper-border; + + @variant disabled { + @apply border-border-active; + } +} + +@utility select { + @apply paper-border px-3 py-2; + + @variant disabled { + @apply border-border-active; + } +} + +@layer base { + body { + @apply text-foreground; + } + + * { + @apply outline-secondary; + } +} + +@layer utilities { + .prose { + :where(u):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply paper-underline no-underline; + } + + :where(a):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply paper-underline-hover no-underline text-secondary; + } + } +} diff --git a/packages/plugin-rsc/examples/react-router/app/root.tsx b/packages/plugin-rsc/examples/react-router/app/root.tsx new file mode 100644 index 000000000..399576b63 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/root.tsx @@ -0,0 +1,54 @@ +import './styles.css' +import { Link, Outlet } from 'react-router' +import { TestClientState, TestHydrated } from './routes/client' +import { DumpError, GlobalNavigationLoadingBar } from './routes/root.client' + +export function Layout({ children }: { children: React.ReactNode }) { + console.log('Layout') + return ( + + + + + React Router Vite + + +
+ +
+ + {children} + + + ) +} + +export default function Component() { + console.log('Root') + return ( + <> + + + ) +} + +export function ErrorBoundary() { + return +} diff --git a/packages/plugin-rsc/examples/react-router/app/routes.ts b/packages/plugin-rsc/examples/react-router/app/routes.ts new file mode 100644 index 000000000..24914e7fd --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/routes.ts @@ -0,0 +1,21 @@ +import type { unstable_RSCRouteConfigEntry } from 'react-router' + +export const routes: unstable_RSCRouteConfigEntry[] = [ + { + id: 'root', + path: '', + lazy: () => import('./root'), + children: [ + { + id: 'home', + index: true, + lazy: () => import('./routes/home'), + }, + { + id: 'about', + path: 'about', + lazy: () => import('./routes/about'), + }, + ], + }, +] diff --git a/packages/plugin-rsc/examples/react-router/app/routes/about.tsx b/packages/plugin-rsc/examples/react-router/app/routes/about.tsx new file mode 100644 index 000000000..a4a076cac --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/routes/about.tsx @@ -0,0 +1,20 @@ +'use client' + +import React from 'react' + +export function Component() { + const [count, setCount] = React.useState(0) + + return ( +
+
+

About

+

This is the about page.

+

[test-style-home]

+ +
+
+ ) +} diff --git a/packages/plugin-rsc/examples/react-router/app/routes/client.tsx b/packages/plugin-rsc/examples/react-router/app/routes/client.tsx new file mode 100644 index 000000000..679c9938d --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/routes/client.tsx @@ -0,0 +1,22 @@ +'use client' + +import React from 'react' + +export function TestHydrated() { + const hydrated = React.useSyncExternalStore( + React.useCallback(() => () => {}, []), + () => true, + () => false, + ) + return [hydrated: {hydrated ? 1 : 0}] +} + +export function TestClientState() { + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/react-router/app/routes/home.actions.ts b/packages/plugin-rsc/examples/react-router/app/routes/home.actions.ts new file mode 100644 index 000000000..94e686299 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/routes/home.actions.ts @@ -0,0 +1,7 @@ +'use server' + +export async function sayHello(defaultName: string, formData: FormData) { + await new Promise((resolve) => setTimeout(resolve, 500)) + const name = formData.get('name') || defaultName + console.log(`Hello, ${name}`) +} diff --git a/packages/plugin-rsc/examples/react-router/app/routes/home.client.tsx b/packages/plugin-rsc/examples/react-router/app/routes/home.client.tsx new file mode 100644 index 000000000..6da32f109 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/routes/home.client.tsx @@ -0,0 +1,12 @@ +'use client' + +import { useFormStatus } from 'react-dom' + +export function PendingButton() { + const status = useFormStatus() + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/react-router/app/routes/home.css b/packages/plugin-rsc/examples/react-router/app/routes/home.css new file mode 100644 index 000000000..7204e2fde --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/routes/home.css @@ -0,0 +1,3 @@ +.test-style-home { + color: rgb(250, 150, 0); +} diff --git a/packages/plugin-rsc/examples/react-router/app/routes/home.tsx b/packages/plugin-rsc/examples/react-router/app/routes/home.tsx new file mode 100644 index 000000000..d632fe29a --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/routes/home.tsx @@ -0,0 +1,56 @@ +namespace Route { + export type LoaderArgs = any + export type ComponentProps = any +} + +import { sayHello } from './home.actions.ts' +import { PendingButton } from './home.client.tsx' +import './home.css' +import { TestActionStateServer } from './test-action-state/server.tsx' + +export function loader({ request }: Route.LoaderArgs) { + const url = new URL(request.url) + const name = url.searchParams.get('name') + return { name: name || 'Unknown' } +} + +const Component = ({ loaderData }: Route.ComponentProps) => { + return ( +
+
+

Home

+

This is the home page.

+ [test-style-home] +
+          loaderData: {JSON.stringify(loaderData)}
+        
+

Server Action

+
+
+ + +
+
+ +
+
+
+ +
+
+
+ ) +} + +export default Component diff --git a/packages/plugin-rsc/examples/react-router/app/routes/root.client.tsx b/packages/plugin-rsc/examples/react-router/app/routes/root.client.tsx new file mode 100644 index 000000000..be3a8e3de --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/routes/root.client.tsx @@ -0,0 +1,44 @@ +'use client' + +import { useNavigation, useRouteError } from 'react-router' + +export function GlobalNavigationLoadingBar() { + const navigation = useNavigation() + + if (navigation.state === 'idle') return null + + return ( +
+
+
+ ) +} + +export function DumpError() { + const error = useRouteError() + const message = + error instanceof Error ? ( +
+
+          {JSON.stringify(
+            {
+              ...error,
+              name: error.name,
+              message: error.message,
+            },
+            null,
+            2,
+          )}
+        
+ {error.stack &&
{error.stack}
} +
+ ) : ( +
Unknown Error
+ ) + return ( + <> +

Oooops

+
{message}
+ + ) +} diff --git a/packages/plugin-rsc/examples/react-router/app/routes/test-action-state/client.tsx b/packages/plugin-rsc/examples/react-router/app/routes/test-action-state/client.tsx new file mode 100644 index 000000000..520dab494 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/routes/test-action-state/client.tsx @@ -0,0 +1,19 @@ +'use client' + +import React from 'react' + +export function TestActionStateClient(props: { + action: (prev: React.ReactNode) => Promise +}) { + const [state, formAction, isPending] = React.useActionState( + props.action, + null, + ) + + return ( +
+ + {isPending ? 'pending...' : state} +
+ ) +} diff --git a/packages/plugin-rsc/examples/react-router/app/routes/test-action-state/server.tsx b/packages/plugin-rsc/examples/react-router/app/routes/test-action-state/server.tsx new file mode 100644 index 000000000..128186e2f --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/routes/test-action-state/server.tsx @@ -0,0 +1,20 @@ +import { TestActionStateClient } from './client' + +// Test case based on +// https://github.com/remix-run/react-router/issues/13882 + +export function TestActionStateServer({ message }: { message: string }) { + return ( + { + 'use server' + await new Promise((resolve) => setTimeout(resolve, 200)) + return ( + + [(ok) ({message})] {prev} + + ) + }} + /> + ) +} diff --git a/packages/plugin-rsc/examples/react-router/app/styles.css b/packages/plugin-rsc/examples/react-router/app/styles.css new file mode 100644 index 000000000..e1a22e4ec --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/styles.css @@ -0,0 +1,32 @@ +@import 'tailwindcss'; +@plugin "@tailwindcss/typography"; + +@import './paper.css'; + +@theme { + --animate-progress: progress 1s infinite linear; + + @keyframes progress { + 0% { + transform: translateX(0) scaleX(0); + } + 40% { + transform: translateX(0) scaleX(0.4); + } + 100% { + transform: translateX(100%) scaleX(0.5); + } + } +} + +@utility vt-name { + view-transition-name: var(--vt-name); +} + +@utility no-vt { + view-transition-name: none; +} + +@view-transition { + navigation: auto; +} diff --git a/packages/plugin-rsc/examples/react-router/cf/entry.rsc.tsx b/packages/plugin-rsc/examples/react-router/cf/entry.rsc.tsx new file mode 100644 index 000000000..103b41ed1 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/cf/entry.rsc.tsx @@ -0,0 +1,9 @@ +import { fetchServer } from '../react-router-vite/entry.rsc' + +console.log('[debug:cf-rsc-entry]') + +export default { + fetch(request: Request) { + return fetchServer(request) + }, +} diff --git a/packages/plugin-rsc/examples/react-router/cf/entry.ssr.tsx b/packages/plugin-rsc/examples/react-router/cf/entry.ssr.tsx new file mode 100644 index 000000000..689e7ac25 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/cf/entry.ssr.tsx @@ -0,0 +1,9 @@ +import { generateHTML } from '../react-router-vite/entry.ssr' + +console.log('[debug:cf-ssr-entry]') + +export default { + fetch(request: Request, env: any) { + return generateHTML(request, (request) => env.RSC.fetch(request)) + }, +} diff --git a/packages/plugin-rsc/examples/react-router/cf/vite.config.ts b/packages/plugin-rsc/examples/react-router/cf/vite.config.ts new file mode 100644 index 000000000..4f77cb175 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/cf/vite.config.ts @@ -0,0 +1,55 @@ +import { cloudflare } from '@cloudflare/vite-plugin' +import rsc from '@vitejs/plugin-rsc' +import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +// import inspect from 'vite-plugin-inspect' + +export default defineConfig({ + clearScreen: false, + build: { + minify: false, + }, + plugins: [ + // inspect(), + tailwindcss(), + react(), + rsc({ + entries: { + client: './react-router-vite/entry.browser.tsx', + }, + serverHandler: false, + }), + cloudflare({ + configPath: './cf/wrangler.ssr.jsonc', + viteEnvironment: { + name: 'ssr', + }, + auxiliaryWorkers: [ + { + configPath: './cf/wrangler.rsc.jsonc', + viteEnvironment: { + name: 'rsc', + }, + }, + ], + }), + ], + environments: { + client: { + optimizeDeps: { + include: ['react-router', 'react-router/internal/react-server-client'], + }, + }, + ssr: { + optimizeDeps: { + exclude: ['react-router'], + }, + }, + rsc: { + optimizeDeps: { + exclude: ['react-router'], + }, + }, + }, +}) diff --git a/packages/plugin-rsc/examples/react-router/cf/wrangler.rsc.jsonc b/packages/plugin-rsc/examples/react-router/cf/wrangler.rsc.jsonc new file mode 100644 index 000000000..67cdb5435 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/cf/wrangler.rsc.jsonc @@ -0,0 +1,8 @@ +{ + "$schema": "https://www.unpkg.com/wrangler@4.19.1/config-schema.json", + "name": "vite-rsc-react-router-rsc", + "main": "./entry.rsc.tsx", + "workers_dev": true, + "compatibility_date": "2025-04-01", + "compatibility_flags": ["nodejs_als"], +} diff --git a/packages/plugin-rsc/examples/react-router/cf/wrangler.ssr.jsonc b/packages/plugin-rsc/examples/react-router/cf/wrangler.ssr.jsonc new file mode 100644 index 000000000..22b718b03 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/cf/wrangler.ssr.jsonc @@ -0,0 +1,9 @@ +{ + "$schema": "https://www.unpkg.com/wrangler@4.19.1/config-schema.json", + "name": "vite-rsc-react-router", + "main": "./entry.ssr.tsx", + "workers_dev": true, + "services": [{ "binding": "RSC", "service": "vite-rsc-react-router-rsc" }], + "compatibility_date": "2025-04-01", + "compatibility_flags": ["nodejs_als"], +} diff --git a/packages/plugin-rsc/examples/react-router/package.json b/packages/plugin-rsc/examples/react-router/package.json new file mode 100644 index 000000000..d22a1fdd1 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/package.json @@ -0,0 +1,32 @@ +{ + "name": "@vitejs/plugin-rsc-examples-react-router", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "cf-dev": "vite -c ./cf/vite.config.ts", + "cf-build": "vite -c ./cf/vite.config.ts build", + "cf-preview": "vite -c ./cf/vite.config.ts preview", + "cf-release": "wrangler deploy -c dist/rsc/wrangler.json && wrangler deploy" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router": "7.8.2" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.12.3", + "@tailwindcss/typography": "^0.5.16", + "@tailwindcss/vite": "^4.1.13", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "tailwindcss": "^4.1.13", + "vite": "^7.1.4", + "wrangler": "^4.34.0" + } +} diff --git a/packages/plugin-rsc/examples/react-router/public/favicon.ico b/packages/plugin-rsc/examples/react-router/public/favicon.ico new file mode 100644 index 000000000..5dbdfcddc Binary files /dev/null and b/packages/plugin-rsc/examples/react-router/public/favicon.ico differ diff --git a/packages/plugin-rsc/examples/react-router/react-router-vite/entry.browser.tsx b/packages/plugin-rsc/examples/react-router/react-router-vite/entry.browser.tsx new file mode 100644 index 000000000..817df7bd1 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/react-router-vite/entry.browser.tsx @@ -0,0 +1,54 @@ +import { + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, +} from '@vitejs/plugin-rsc/browser' +import { startTransition, StrictMode } from 'react' +import { hydrateRoot } from 'react-dom/client' +import { + unstable_createCallServer as createCallServer, + unstable_getRSCStream as getRSCStream, + unstable_RSCHydratedRouter as RSCHydratedRouter, + type DataRouter, + type unstable_RSCPayload as RSCServerPayload, +} from 'react-router' + +// Create and set the callServer function to support post-hydration server actions. +setServerCallback( + createCallServer({ + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + }), +) + +// Get and decode the initial server payload +createFromReadableStream(getRSCStream()).then((payload) => { + startTransition(async () => { + const formState = + payload.type === 'render' ? await payload.formState : undefined + + hydrateRoot( + document, + + + , + { + // @ts-expect-error - no types for this yet + formState, + }, + ) + }) +}) + +declare let __reactRouterDataRouter: DataRouter + +if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + __reactRouterDataRouter.revalidate() + }) +} diff --git a/packages/plugin-rsc/examples/react-router/react-router-vite/entry.rsc.single.tsx b/packages/plugin-rsc/examples/react-router/react-router-vite/entry.rsc.single.tsx new file mode 100644 index 000000000..da3895388 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/react-router-vite/entry.rsc.single.tsx @@ -0,0 +1,10 @@ +import { fetchServer } from './entry.rsc' + +export default async function handler(request: Request) { + // Import the generateHTML function from the client environment + const ssr = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr') + >('ssr', 'index') + + return ssr.generateHTML(request, fetchServer) +} diff --git a/packages/plugin-rsc/examples/react-router/react-router-vite/entry.rsc.tsx b/packages/plugin-rsc/examples/react-router/react-router-vite/entry.rsc.tsx new file mode 100644 index 000000000..847211bfd --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/react-router-vite/entry.rsc.tsx @@ -0,0 +1,36 @@ +import { + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + renderToReadableStream, +} from '@vitejs/plugin-rsc/rsc' +import { unstable_matchRSCServerRequest as matchRSCServerRequest } from 'react-router' +import { routes } from '../app/routes' + +export function fetchServer(request: Request) { + return matchRSCServerRequest({ + // Provide the React Server touchpoints. + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + // The incoming request. + request, + // The app routes. + routes, + // Encode the match with the React Server implementation. + generateResponse(match, options) { + return new Response(renderToReadableStream(match.payload, options), { + status: match.statusCode, + headers: match.headers, + }) + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/react-router/react-router-vite/entry.ssr.tsx b/packages/plugin-rsc/examples/react-router/react-router-vite/entry.ssr.tsx new file mode 100644 index 000000000..c80cde622 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/react-router-vite/entry.ssr.tsx @@ -0,0 +1,38 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import { renderToReadableStream as renderHTMLToReadableStream } from 'react-dom/server.edge' +import { + unstable_routeRSCServerRequest as routeRSCServerRequest, + unstable_RSCStaticRouter as RSCStaticRouter, +} from 'react-router' + +export async function generateHTML( + request: Request, + fetchServer: (request: Request) => Promise, +): Promise { + return await routeRSCServerRequest({ + // The incoming request. + request, + // How to call the React Server. + fetchServer, + // Provide the React Server touchpoints. + createFromReadableStream, + // Render the router to HTML. + async renderHTML(getPayload) { + const payload = await getPayload() + const formState = + payload.type === 'render' ? await payload.formState : undefined + + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + + return await renderHTMLToReadableStream( + , + { + bootstrapScriptContent, + // @ts-expect-error - no types for this yet + formState, + }, + ) + }, + }) +} diff --git a/packages/plugin-rsc/examples/react-router/react-router-vite/types.d.ts b/packages/plugin-rsc/examples/react-router/react-router-vite/types.d.ts new file mode 100644 index 000000000..bb5578e14 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/react-router-vite/types.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/packages/plugin-rsc/examples/react-router/tsconfig.json b/packages/plugin-rsc/examples/react-router/tsconfig.json new file mode 100644 index 000000000..20b648a36 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/react-router/vite.config.ts b/packages/plugin-rsc/examples/react-router/vite.config.ts new file mode 100644 index 000000000..7b9dbb7c0 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/vite.config.ts @@ -0,0 +1,27 @@ +import rsc from '@vitejs/plugin-rsc' +import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +// import inspect from 'vite-plugin-inspect' + +export default defineConfig({ + clearScreen: false, + build: { + minify: false, + }, + plugins: [ + // inspect(), + tailwindcss(), + react(), + rsc({ + entries: { + client: './react-router-vite/entry.browser.tsx', + ssr: './react-router-vite/entry.ssr.tsx', + rsc: './react-router-vite/entry.rsc.single.tsx', + }, + }), + ], + optimizeDeps: { + include: ['react-router', 'react-router/internal/react-server-client'], + }, +}) as any diff --git a/packages/plugin-rsc/examples/ssg/README.md b/packages/plugin-rsc/examples/ssg/README.md new file mode 100644 index 000000000..c5da5218d --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/README.md @@ -0,0 +1,15 @@ +# SSG + MDX example + +This example demonstrates: + +- Client component inside MDX +- MDX HMR +- Static site generation + +## usage + +```js +pnpm dev +pnpm build +pnpm preview +``` diff --git a/packages/plugin-rsc/examples/ssg/package.json b/packages/plugin-rsc/examples/ssg/package.json new file mode 100644 index 000000000..163d9108a --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/package.json @@ -0,0 +1,23 @@ +{ + "name": "@vitejs/plugin-rsc-examples-ssg", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@mdx-js/rollup": "^3.1.1", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest" + } +} diff --git a/packages/plugin-rsc/examples/ssg/public/favicon.ico b/packages/plugin-rsc/examples/ssg/public/favicon.ico new file mode 100644 index 000000000..4aff07660 Binary files /dev/null and b/packages/plugin-rsc/examples/ssg/public/favicon.ico differ diff --git a/packages/plugin-rsc/examples/ssg/src/counter.tsx b/packages/plugin-rsc/examples/ssg/src/counter.tsx new file mode 100644 index 000000000..79444524a --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/src/counter.tsx @@ -0,0 +1,11 @@ +'use client' + +import React from 'react' + +export function Counter() { + const [count, setCount] = React.useState(0) + + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/ssg/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/ssg/src/framework/entry.browser.tsx new file mode 100644 index 000000000..381be8dc5 --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/src/framework/entry.browser.tsx @@ -0,0 +1,98 @@ +import { + createFromFetch, + createFromReadableStream, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import { RSC_POSTFIX, type RscPayload } from './shared' + +async function hydrate(): Promise { + async function onNavigation() { + const url = new URL(window.location.href) + url.pathname = url.pathname + RSC_POSTFIX + const payload = await createFromFetch(fetch(url)) + setPayload(payload) + } + + const initialPayload = await createFromReadableStream(rscStream) + + let setPayload: (v: RscPayload) => void + + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + React.useEffect(() => { + return listenNavigation(() => onNavigation()) + }, []) + + return payload.root + } + + const browserRoot = ( + + + + ) + + hydrateRoot(document, browserRoot) + + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + window.history.replaceState({}, '', window.location.href) + }) + } +} + +function listenNavigation(onNavigation: () => void): () => void { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +hydrate() diff --git a/packages/plugin-rsc/examples/ssg/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/ssg/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..7b87fdc18 --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/src/framework/entry.rsc.tsx @@ -0,0 +1,62 @@ +import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc' +import { Root, getStaticPaths } from '../root' +import { RSC_POSTFIX, type RscPayload } from './shared' + +export { getStaticPaths } + +export default async function handler(request: Request): Promise { + let url = new URL(request.url) + let isRscRequest = false + if (url.pathname.endsWith(RSC_POSTFIX)) { + isRscRequest = true + url.pathname = url.pathname.slice(0, -RSC_POSTFIX.length) + } + + const rscPayload: RscPayload = { root: } + const rscStream = renderToReadableStream(rscPayload) + + if (isRscRequest) { + return new Response(rscStream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + vary: 'accept', + }, + }) + } + + const ssr = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr') + >('ssr', 'index') + const htmlStream = await ssr.renderHtml(rscStream) + + return new Response(htmlStream, { + headers: { + 'content-type': 'text/html;charset=utf-8', + vary: 'accept', + }, + }) +} + +// return both rsc and html streams at once for ssg +export async function handleSsg(request: Request): Promise<{ + html: ReadableStream + rsc: ReadableStream +}> { + const url = new URL(request.url) + const rscPayload: RscPayload = { root: } + const rscStream = renderToReadableStream(rscPayload) + const [rscStream1, rscStream2] = rscStream.tee() + + const ssr = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr') + >('ssr', 'index') + const htmlStream = await ssr.renderHtml(rscStream1, { + ssg: true, + }) + + return { html: htmlStream, rsc: rscStream2 } +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/ssg/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/ssg/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..b20eaf42b --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/src/framework/entry.ssr.tsx @@ -0,0 +1,40 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' +import { renderToReadableStream } from 'react-dom/server.edge' +import { prerender } from 'react-dom/static.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' +import type { RscPayload } from './shared' + +export async function renderHtml( + rscStream: ReadableStream, + options?: { + ssg?: boolean + }, +) { + const [rscStream1, rscStream2] = rscStream.tee() + + let payload: Promise + function SsrRoot() { + payload ??= createFromReadableStream(rscStream1) + const root = React.use(payload).root + return root + } + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + + let htmlStream: ReadableStream + if (options?.ssg) { + const prerenderResult = await prerender(, { + bootstrapScriptContent, + }) + htmlStream = prerenderResult.prelude + } else { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent, + }) + } + + let responseStream: ReadableStream = htmlStream + responseStream = responseStream.pipeThrough(injectRSCPayload(rscStream2)) + return responseStream +} diff --git a/packages/plugin-rsc/examples/ssg/src/framework/shared.tsx b/packages/plugin-rsc/examples/ssg/src/framework/shared.tsx new file mode 100644 index 000000000..e602b35d8 --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/src/framework/shared.tsx @@ -0,0 +1,7 @@ +import type React from 'react' + +export const RSC_POSTFIX = '_.rsc' + +export type RscPayload = { + root: React.ReactNode +} diff --git a/packages/plugin-rsc/examples/ssg/src/posts/counter.mdx b/packages/plugin-rsc/examples/ssg/src/posts/counter.mdx new file mode 100644 index 000000000..1654ee8b4 --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/src/posts/counter.mdx @@ -0,0 +1,7 @@ +export const title = 'Counter in MDX' + +import { Counter } from '../counter' + +# Counter in MDX + + diff --git a/packages/plugin-rsc/examples/ssg/src/posts/oxc.mdx b/packages/plugin-rsc/examples/ssg/src/posts/oxc.mdx new file mode 100644 index 000000000..5cff86b88 --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/src/posts/oxc.mdx @@ -0,0 +1,3 @@ +# Oxc + +The fastest JavaScript language toolchain! diff --git a/packages/plugin-rsc/examples/ssg/src/posts/rolldown.mdx b/packages/plugin-rsc/examples/ssg/src/posts/rolldown.mdx new file mode 100644 index 000000000..71e2931a0 --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/src/posts/rolldown.mdx @@ -0,0 +1,3 @@ +# Rolldown + +The fastest JavaScript bundler! diff --git a/packages/plugin-rsc/examples/ssg/src/posts/vite.mdx b/packages/plugin-rsc/examples/ssg/src/posts/vite.mdx new file mode 100644 index 000000000..b510d3862 --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/src/posts/vite.mdx @@ -0,0 +1,3 @@ +# Vite + +The build tool for the web! diff --git a/packages/plugin-rsc/examples/ssg/src/posts/vitest.mdx b/packages/plugin-rsc/examples/ssg/src/posts/vitest.mdx new file mode 100644 index 000000000..9b534e107 --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/src/posts/vitest.mdx @@ -0,0 +1,3 @@ +# Vitest + +Next-generation test runner! diff --git a/packages/plugin-rsc/examples/ssg/src/root.tsx b/packages/plugin-rsc/examples/ssg/src/root.tsx new file mode 100644 index 000000000..cb3ecb122 --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/src/root.tsx @@ -0,0 +1,70 @@ +import { Counter } from './counter' + +async function getPosts() { + let glob = import.meta.glob('./posts/*.mdx', { eager: true }) + glob = Object.fromEntries( + Object.entries(glob).map(([k, v]) => [ + k.slice('./posts'.length, -'.mdx'.length), + v, + ]), + ) + return glob +} + +export async function getStaticPaths() { + const posts = await getPosts() + return ['/', ...Object.keys(posts)] +} + +export async function Root({ url }: { url: URL }) { + const posts = await getPosts() + + async function RootContent() { + if (url.pathname === '/') { + return ( + + ) + } + + const module = posts[url.pathname] + if (!!module) { + const Component = (module as any).default + return + } + + // TODO: how to 404? + return

Not found

+ } + + return ( + + + + + RSC MDX SSG + + +
+

+ RSC + MDX + SSG +

+ + + Rendered at {new Date().toISOString()} + +
+
+ +
+ + + ) +} diff --git a/packages/plugin-rsc/examples/ssg/tsconfig.json b/packages/plugin-rsc/examples/ssg/tsconfig.json new file mode 100644 index 000000000..4c355ed3c --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/ssg/vite.config.ts b/packages/plugin-rsc/examples/ssg/vite.config.ts new file mode 100644 index 000000000..66c5d2522 --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/vite.config.ts @@ -0,0 +1,87 @@ +import fs from 'node:fs' +import path from 'node:path' +import { Readable } from 'node:stream' +import { pathToFileURL } from 'node:url' +import rsc from '@vitejs/plugin-rsc' +import mdx from '@mdx-js/rollup' +import react from '@vitejs/plugin-react' +import { type Plugin, type ResolvedConfig, defineConfig } from 'vite' +// import inspect from 'vite-plugin-inspect' +import { RSC_POSTFIX } from './src/framework/shared' + +export default defineConfig({ + plugins: [ + // inspect(), + mdx(), + react(), + rsc({ + entries: { + client: './src/framework/entry.browser.tsx', + rsc: './src/framework/entry.rsc.tsx', + ssr: './src/framework/entry.ssr.tsx', + }, + }), + rscSsgPlugin(), + ], +}) + +function rscSsgPlugin(): Plugin[] { + return [ + { + name: 'rsc-ssg', + config: { + order: 'pre', + handler(_config, env) { + return { + appType: env.isPreview ? 'mpa' : undefined, + rsc: { + serverHandler: env.isPreview ? false : undefined, + }, + } + }, + }, + buildApp: { + async handler(builder) { + await renderStatic(builder.config) + }, + }, + }, + ] +} + +async function renderStatic(config: ResolvedConfig) { + // import server entry + const entryPath = path.join(config.environments.rsc.build.outDir, 'index.js') + const entry: typeof import('./src/framework/entry.rsc') = await import( + pathToFileURL(entryPath).href + ) + + // entry provides a list of static paths + const staticPaths = await entry.getStaticPaths() + + // render rsc and html + const baseDir = config.environments.client.build.outDir + for (const staticPatch of staticPaths) { + config.logger.info('[vite-rsc:ssg] -> ' + staticPatch) + const { html, rsc } = await entry.handleSsg( + new Request(new URL(staticPatch, 'http://ssg.local')), + ) + await writeFileStream( + path.join(baseDir, normalizeHtmlFilePath(staticPatch)), + html, + ) + await writeFileStream(path.join(baseDir, staticPatch + RSC_POSTFIX), rsc) + } +} + +async function writeFileStream(filePath: string, stream: ReadableStream) { + await fs.promises.mkdir(path.dirname(filePath), { recursive: true }) + await fs.promises.writeFile(filePath, Readable.fromWeb(stream as any)) +} + +function normalizeHtmlFilePath(p: string) { + if (p.endsWith('/')) { + return p + 'index.html' + } + return p + '.html' +} diff --git a/packages/plugin-rsc/examples/starter-cf-single/README.md b/packages/plugin-rsc/examples/starter-cf-single/README.md new file mode 100644 index 000000000..fb9ca04ff --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/README.md @@ -0,0 +1,23 @@ +# Vite + RSC + Cloudflare Workers + +https://vite-rsc-starter.hiro18181.workers.dev + +[examples/starter](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) integrated with [`@cloudflare/vite-plugin`](https://github.com/cloudflare/workers-sdk/tree/main/packages/vite-plugin-cloudflare). + +The difference from [examples/react-router](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/react-router) is that this doesn't require two workers. + +- RSC environment always runs on Cloudflare Workers. +- During development, SSR environment runs as Vite's default Node environment. +- During production, SSR environment build output is directly imported into RSC environment build and both codes run on the same worker. + +Such communication mechanism is enabled via `rsc({ loadModuleDevProxy: true })` plugin option. + +```sh +# run dev server +npm run dev + +# build for production and preview +npm run build +npm run preview +npm run release +``` diff --git a/packages/plugin-rsc/examples/starter-cf-single/package.json b/packages/plugin-rsc/examples/starter-cf-single/package.json new file mode 100644 index 000000000..0c69dd883 --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/package.json @@ -0,0 +1,26 @@ +{ + "name": "@vitejs/plugin-rsc-examples-starter-cf-single", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "release": "wrangler deploy" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.12.3", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^7.1.4" + } +} diff --git a/packages/plugin-rsc/examples/starter-cf-single/public/vite.svg b/packages/plugin-rsc/examples/starter-cf-single/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/starter-cf-single/src/action.tsx b/packages/plugin-rsc/examples/starter-cf-single/src/action.tsx new file mode 100644 index 000000000..4fc55d65b --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/src/action.tsx @@ -0,0 +1,11 @@ +'use server' + +let serverCounter = 0 + +export async function getServerCounter() { + return serverCounter +} + +export async function updateServerCounter(change: number) { + serverCounter += change +} diff --git a/packages/plugin-rsc/examples/starter-cf-single/src/assets/react.svg b/packages/plugin-rsc/examples/starter-cf-single/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/starter-cf-single/src/client.tsx b/packages/plugin-rsc/examples/starter-cf-single/src/client.tsx new file mode 100644 index 000000000..29bb5d367 --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/src/client.tsx @@ -0,0 +1,13 @@ +'use client' + +import React from 'react' + +export function ClientCounter() { + const [count, setCount] = React.useState(0) + + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/starter-cf-single/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/starter-cf-single/src/framework/entry.browser.tsx new file mode 100644 index 000000000..c4c0e4ade --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/src/framework/entry.browser.tsx @@ -0,0 +1,133 @@ +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import type { RscPayload } from './entry.rsc' + +async function main() { + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void + + // deserialize RSC stream back to React VDOM for CSR + const initialPayload = await createFromReadableStream( + // initial RSC stream is injected in SSR stream as + rscStream, + ) + + // browser root component to (re-)render RSC payload as state + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const payload = await createFromFetch( + fetch(window.location.href), + ) + setPayload(payload) + } + + // register a handler which will be internally called by React + // on server function request after hydration. + setServerCallback(async (id, args) => { + const url = new URL(window.location.href) + const temporaryReferences = createTemporaryReferenceSet() + const payload = await createFromFetch( + fetch(url, { + method: 'POST', + body: await encodeReply(args, { temporaryReferences }), + headers: { + 'x-rsc-action': id, + }, + }), + { temporaryReferences }, + ) + setPayload(payload) + return payload.returnValue + }) + + // hydration + const browserRoot = ( + + + + ) + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + + // implement server HMR by trigering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +main() diff --git a/packages/plugin-rsc/examples/starter-cf-single/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/starter-cf-single/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..767164f1e --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/src/framework/entry.rsc.tsx @@ -0,0 +1,99 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' + +export type RscPayload = { + root: React.ReactNode + returnValue?: unknown + formState?: ReactFormState +} + +async function handler(request: Request): Promise { + // handle server function request + const isAction = request.method === 'POST' + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + if (isAction) { + // x-rsc-action header exists when action is called via `ReactClient.setServerCallback`. + const actionId = request.headers.get('x-rsc-action') + if (actionId) { + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(actionId) + returnValue = await action.apply(null, args) + } else { + // otherwise server function is called via `
` + // before hydration (e.g. when javascript is disabled). + // aka progressive enhancement. + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } + } + + // serialization from React VDOM tree to RSC stream. + // we render RSC stream after handling server function request + // so that new render reflects updated state from server function call + // to achieve single round trip to mutate and fetch from server. + const rscPayload: RscPayload = { root: , formState, returnValue } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + // respond RSC stream without HTML rendering based on framework's convention. + // here we use request header `content-type`. + // additionally we allow `?__rsc` and `?__html` to easily view payload directly. + const url = new URL(request.url) + const isRscRequest = + (!request.headers.get('accept')?.includes('text/html') && + !url.searchParams.has('__html')) || + url.searchParams.has('__rsc') + + if (isRscRequest) { + return new Response(rscStream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + vary: 'accept', + }, + }) + } + + const { renderHTML } = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + const htmlStream = await renderHTML(rscStream, { + formState, + // allow quick simulation of javscript disabled browser + debugNojs: url.searchParams.has('__nojs'), + }) + + // respond html + return new Response(htmlStream, { + headers: { + 'Content-type': 'text/html', + vary: 'accept', + }, + }) +} + +export default { + fetch(request: Request) { + return handler(request) + }, +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/starter-cf-single/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/starter-cf-single/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..f015dac85 --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/src/framework/entry.ssr.tsx @@ -0,0 +1,54 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' +import type { ReactFormState } from 'react-dom/client' +import { renderToReadableStream } from 'react-dom/server.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' +import type { RscPayload } from './entry.rsc' + +export type RenderHTML = typeof renderHTML + +export async function renderHTML( + rscStream: ReadableStream, + options?: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +) { + // duplicate one RSC stream into two. + // - one for SSR (ReactClient.createFromReadableStream below) + // - another for browser hydration payload by injecting . + const [rscStream1, rscStream2] = rscStream.tee() + + // deserialize RSC stream back to React VDOM + let payload: Promise + function SsrRoot() { + // deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDomServer preinit/preloading to work + payload ??= createFromReadableStream(rscStream1) + return React.use(payload).root + } + + // render html (traditional SSR) + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + const htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return responseStream +} diff --git a/packages/plugin-rsc/examples/starter-cf-single/src/index.css b/packages/plugin-rsc/examples/starter-cf-single/src/index.css new file mode 100644 index 000000000..f4d2128c0 --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/src/index.css @@ -0,0 +1,112 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 1rem; +} + +.read-the-docs { + color: #888; + text-align: left; +} diff --git a/packages/plugin-rsc/examples/starter-cf-single/src/root.tsx b/packages/plugin-rsc/examples/starter-cf-single/src/root.tsx new file mode 100644 index 000000000..694d3fe7d --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/src/root.tsx @@ -0,0 +1,70 @@ +import './index.css' +import viteLogo from '/vite.svg' +import { getServerCounter, updateServerCounter } from './action.tsx' +import reactLogo from './assets/react.svg' +import { ClientCounter } from './client.tsx' + +export function Root() { + return ( + + + + + + Vite + RSC + + + + + + ) +} + +function App() { + return ( +
+ +

Vite + RSC

+
+ +
+
+ + + +
+
    +
  • + Edit src/client.tsx to test client HMR. +
  • +
  • + Edit src/root.tsx to test server HMR. +
  • +
  • + Visit{' '} + + /?__rsc + {' '} + to view RSC stream payload. +
  • +
  • + Visit{' '} + + /?__nojs + {' '} + to test server action without js enabled. +
  • +
+
+ ) +} diff --git a/packages/plugin-rsc/examples/starter-cf-single/tsconfig.json b/packages/plugin-rsc/examples/starter-cf-single/tsconfig.json new file mode 100644 index 000000000..4c355ed3c --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/starter-cf-single/vite.config.ts b/packages/plugin-rsc/examples/starter-cf-single/vite.config.ts new file mode 100644 index 000000000..e93d3b493 --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/vite.config.ts @@ -0,0 +1,52 @@ +import { cloudflare } from '@cloudflare/vite-plugin' +import rsc from '@vitejs/plugin-rsc' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + clearScreen: false, + build: { + minify: false, + }, + plugins: [ + react(), + rsc({ + entries: { + client: './src/framework/entry.browser.tsx', + ssr: './src/framework/entry.ssr.tsx', + }, + serverHandler: false, + loadModuleDevProxy: true, + }), + cloudflare({ + configPath: './wrangler.jsonc', + viteEnvironment: { + name: 'rsc', + }, + }), + ], + environments: { + rsc: { + build: { + rollupOptions: { + // ensure `default` export only in cloudflare entry output + preserveEntrySignatures: 'exports-only', + }, + }, + optimizeDeps: { + include: ['turbo-stream'], + }, + }, + ssr: { + keepProcessEnv: false, + build: { + // build `ssr` inside `rsc` directory so that + // wrangler can deploy self-contained `dist/rsc` + outDir: './dist/rsc/ssr', + }, + resolve: { + noExternal: true, + }, + }, + }, +}) diff --git a/packages/plugin-rsc/examples/starter-cf-single/wrangler.jsonc b/packages/plugin-rsc/examples/starter-cf-single/wrangler.jsonc new file mode 100644 index 000000000..c098e4362 --- /dev/null +++ b/packages/plugin-rsc/examples/starter-cf-single/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "$schema": "https://www.unpkg.com/wrangler@4.19.1/config-schema.json", + "name": "vite-rsc-starter", + "main": "./src/framework/entry.rsc.tsx", + "workers_dev": true, + "compatibility_date": "2025-04-01", + "compatibility_flags": ["nodejs_als"], +} diff --git a/packages/plugin-rsc/examples/starter/.gitignore b/packages/plugin-rsc/examples/starter/.gitignore new file mode 100644 index 000000000..f06235c46 --- /dev/null +++ b/packages/plugin-rsc/examples/starter/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/packages/plugin-rsc/examples/starter/README.md b/packages/plugin-rsc/examples/starter/README.md new file mode 100644 index 000000000..a79ba51ad --- /dev/null +++ b/packages/plugin-rsc/examples/starter/README.md @@ -0,0 +1,40 @@ +# Vite + RSC + +This example shows how to setup a React application with [Server Component](https://react.dev/reference/rsc/server-components) features on Vite using [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc). + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) + +```sh +# run dev server +npm run dev + +# build for production and preview +npm run build +npm run preview +``` + +## API usages + +See [`@vitejs/plugin-rsc`](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc) for the documentation. + +- [`vite.config.ts`](./vite.config.ts) + - `@higoawa/vite-rsc/plugin` +- [`./src/framework/entry.rsc.tsx`](./src/framework/entry.rsc.tsx) + - `@vitejs/plugin-rsc/rsc` + - `import.meta.viteRsc.loadModule` +- [`./src/framework/entry.ssr.tsx`](./src/framework/entry.ssr.tsx) + - `@vitejs/plugin-rsc/ssr` + - `import.meta.viteRsc.loadBootstrapScriptContent` + - `rsc-html-stream/server` +- [`./src/framework/entry.browser.tsx`](./src/framework/entry.browser.tsx) + - `@vitejs/plugin-rsc/browser` + - `rsc-html-stream/client` + +## Notes + +- [`./src/framework/entry.{browser,rsc,ssr}.tsx`](./src/framework) (with inline comments) provides an overview of how low level RSC (React flight) API can be used to build RSC framework. +- You can use [`vite-plugin-inspect`](https://github.com/antfu-collective/vite-plugin-inspect) to understand how `"use client"` and `"use server"` directives are transformed internally. + +## Deployment + +See [vite-plugin-rsc-deploy-example](https://github.com/hi-ogawa/vite-plugin-rsc-deploy-example) diff --git a/packages/plugin-rsc/examples/starter/package.json b/packages/plugin-rsc/examples/starter/package.json new file mode 100644 index 000000000..6cc65d402 --- /dev/null +++ b/packages/plugin-rsc/examples/starter/package.json @@ -0,0 +1,24 @@ +{ + "name": "@vitejs/plugin-rsc-examples-starter", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^7.1.4" + } +} diff --git a/packages/plugin-rsc/examples/starter/public/vite.svg b/packages/plugin-rsc/examples/starter/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/plugin-rsc/examples/starter/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/starter/src/action.tsx b/packages/plugin-rsc/examples/starter/src/action.tsx new file mode 100644 index 000000000..4fc55d65b --- /dev/null +++ b/packages/plugin-rsc/examples/starter/src/action.tsx @@ -0,0 +1,11 @@ +'use server' + +let serverCounter = 0 + +export async function getServerCounter() { + return serverCounter +} + +export async function updateServerCounter(change: number) { + serverCounter += change +} diff --git a/packages/plugin-rsc/examples/starter/src/assets/react.svg b/packages/plugin-rsc/examples/starter/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/plugin-rsc/examples/starter/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/starter/src/client.tsx b/packages/plugin-rsc/examples/starter/src/client.tsx new file mode 100644 index 000000000..29bb5d367 --- /dev/null +++ b/packages/plugin-rsc/examples/starter/src/client.tsx @@ -0,0 +1,13 @@ +'use client' + +import React from 'react' + +export function ClientCounter() { + const [count, setCount] = React.useState(0) + + return ( + + ) +} diff --git a/packages/plugin-rsc/examples/starter/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/starter/src/framework/entry.browser.tsx new file mode 100644 index 000000000..c4c0e4ade --- /dev/null +++ b/packages/plugin-rsc/examples/starter/src/framework/entry.browser.tsx @@ -0,0 +1,133 @@ +import { + createFromReadableStream, + createFromFetch, + setServerCallback, + createTemporaryReferenceSet, + encodeReply, +} from '@vitejs/plugin-rsc/browser' +import React from 'react' +import { hydrateRoot } from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import type { RscPayload } from './entry.rsc' + +async function main() { + // stash `setPayload` function to trigger re-rendering + // from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr) + let setPayload: (v: RscPayload) => void + + // deserialize RSC stream back to React VDOM for CSR + const initialPayload = await createFromReadableStream( + // initial RSC stream is injected in SSR stream as + rscStream, + ) + + // browser root component to (re-)render RSC payload as state + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + // re-fetch/render on client side navigation + React.useEffect(() => { + return listenNavigation(() => fetchRscPayload()) + }, []) + + return payload.root + } + + // re-fetch RSC and trigger re-rendering + async function fetchRscPayload() { + const payload = await createFromFetch( + fetch(window.location.href), + ) + setPayload(payload) + } + + // register a handler which will be internally called by React + // on server function request after hydration. + setServerCallback(async (id, args) => { + const url = new URL(window.location.href) + const temporaryReferences = createTemporaryReferenceSet() + const payload = await createFromFetch( + fetch(url, { + method: 'POST', + body: await encodeReply(args, { temporaryReferences }), + headers: { + 'x-rsc-action': id, + }, + }), + { temporaryReferences }, + ) + setPayload(payload) + return payload.returnValue + }) + + // hydration + const browserRoot = ( + + + + ) + hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + + // implement server HMR by trigering re-fetch/render of RSC upon server code change + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + fetchRscPayload() + }) + } +} + +// a little helper to setup events interception for client side navigation +function listenNavigation(onNavigation: () => void) { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} + +main() diff --git a/packages/plugin-rsc/examples/starter/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/starter/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..fa1c27845 --- /dev/null +++ b/packages/plugin-rsc/examples/starter/src/framework/entry.rsc.tsx @@ -0,0 +1,111 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' + +// The schema of payload which is serialized into RSC stream on rsc environment +// and deserialized on ssr/client environments. +export type RscPayload = { + // this demo renders/serializes/deserizlies entire root html element + // but this mechanism can be changed to render/fetch different parts of components + // based on your own route conventions. + root: React.ReactNode + // server action return value of non-progressive enhancement case + returnValue?: unknown + // server action form state (e.g. useActionState) of progressive enhancement case + formState?: ReactFormState +} + +// the plugin by default assumes `rsc` entry having default export of request handler. +// however, how server entries are executed can be customized by registering +// own server handler e.g. `@cloudflare/vite-plugin`. +export default async function handler(request: Request): Promise { + // handle server function request + const isAction = request.method === 'POST' + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + if (isAction) { + // x-rsc-action header exists when action is called via `ReactClient.setServerCallback`. + const actionId = request.headers.get('x-rsc-action') + if (actionId) { + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(actionId) + returnValue = await action.apply(null, args) + } else { + // otherwise server function is called via `
` + // before hydration (e.g. when javascript is disabled). + // aka progressive enhancement. + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } + } + + // serialization from React VDOM tree to RSC stream. + // we render RSC stream after handling server function request + // so that new render reflects updated state from server function call + // to achieve single round trip to mutate and fetch from server. + const url = new URL(request.url) + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + // respond RSC stream without HTML rendering based on framework's convention. + // here we use request header `content-type`. + // additionally we allow `?__rsc` and `?__html` to easily view payload directly. + const isRscRequest = + (!request.headers.get('accept')?.includes('text/html') && + !url.searchParams.has('__html')) || + url.searchParams.has('__rsc') + + if (isRscRequest) { + return new Response(rscStream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + vary: 'accept', + }, + }) + } + + // Delegate to SSR environment for html rendering. + // The plugin provides `loadSsrModule` helper to allow loading SSR environment entry module + // in RSC environment. however this can be customized by implementing own runtime communication + // e.g. `@cloudflare/vite-plugin`'s service binding. + const ssrEntryModule = await import.meta.viteRsc.loadModule< + typeof import('./entry.ssr.tsx') + >('ssr', 'index') + const htmlStream = await ssrEntryModule.renderHTML(rscStream, { + formState, + // allow quick simulation of javscript disabled browser + debugNojs: url.searchParams.has('__nojs'), + }) + + // respond html + return new Response(htmlStream, { + headers: { + 'Content-type': 'text/html', + vary: 'accept', + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..a510db376 --- /dev/null +++ b/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx @@ -0,0 +1,62 @@ +import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' +import React from 'react' +import type { ReactFormState } from 'react-dom/client' +import { renderToReadableStream } from 'react-dom/server.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' +import type { RscPayload } from './entry.rsc' + +export async function renderHTML( + rscStream: ReadableStream, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +) { + // duplicate one RSC stream into two. + // - one for SSR (ReactClient.createFromReadableStream below) + // - another for browser hydration payload by injecting . + const [rscStream1, rscStream2] = rscStream.tee() + + // deserialize RSC stream back to React VDOM + let payload: Promise | undefined + function SsrRoot() { + // deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDomServer preinit/preloading to work + payload ??= createFromReadableStream(rscStream1) + return {React.use(payload).root} + } + + // Add an empty component in between `SsrRoot` and user `root` to avoid React SSR bugs. + // SsrRoot (use) + // => FixSsrThenable + // => root (which potentially has `lazy` + `use`) + // https://github.com/facebook/react/issues/33937#issuecomment-3091349011 + function FixSsrThenable(props: React.PropsWithChildren) { + return props.children + } + + // render html (traditional SSR) + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + const htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + // using utility made by devongovett https://github.com/devongovett/rsc-html-stream + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return responseStream +} diff --git a/packages/plugin-rsc/examples/starter/src/index.css b/packages/plugin-rsc/examples/starter/src/index.css new file mode 100644 index 000000000..f4d2128c0 --- /dev/null +++ b/packages/plugin-rsc/examples/starter/src/index.css @@ -0,0 +1,112 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} + +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 1rem; +} + +.read-the-docs { + color: #888; + text-align: left; +} diff --git a/packages/plugin-rsc/examples/starter/src/root.tsx b/packages/plugin-rsc/examples/starter/src/root.tsx new file mode 100644 index 000000000..c6a649706 --- /dev/null +++ b/packages/plugin-rsc/examples/starter/src/root.tsx @@ -0,0 +1,71 @@ +import './index.css' // css import is automatically injected in exported server components +import viteLogo from '/vite.svg' +import { getServerCounter, updateServerCounter } from './action.tsx' +import reactLogo from './assets/react.svg' +import { ClientCounter } from './client.tsx' + +export function Root(props: { url: URL }) { + return ( + + + + + + Vite + RSC + + + + + + ) +} + +function App(props: { url: URL }) { + return ( +
+ +

Vite + RSC

+
+ +
+
+ + + +
+
Request URL: {props.url?.href}
+
    +
  • + Edit src/client.tsx to test client HMR. +
  • +
  • + Edit src/root.tsx to test server HMR. +
  • +
  • + Visit{' '} + + ?__rsc + {' '} + to view RSC stream payload. +
  • +
  • + Visit{' '} + + ?__nojs + {' '} + to test server action without js enabled. +
  • +
+
+ ) +} diff --git a/packages/plugin-rsc/examples/starter/tsconfig.json b/packages/plugin-rsc/examples/starter/tsconfig.json new file mode 100644 index 000000000..4c355ed3c --- /dev/null +++ b/packages/plugin-rsc/examples/starter/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/starter/vite.config.ts b/packages/plugin-rsc/examples/starter/vite.config.ts new file mode 100644 index 000000000..99837202c --- /dev/null +++ b/packages/plugin-rsc/examples/starter/vite.config.ts @@ -0,0 +1,73 @@ +import rsc from '@vitejs/plugin-rsc' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +// import inspect from "vite-plugin-inspect"; + +export default defineConfig({ + plugins: [ + rsc({ + // `entries` option is only a shorthand for specifying each `rollupOptions.input` below + // > entries: { rsc, ssr, client }, + // + // by default, the plugin setup request handler based on `default export` of `rsc` environment `rollupOptions.input.index`. + // This can be disabled when setting up own server handler e.g. `@cloudflare/vite-plugin`. + // > serverHandler: false + }), + + // use any of react plugins https://github.com/vitejs/vite-plugin-react + // to enable client component HMR + react(), + + // use https://github.com/antfu-collective/vite-plugin-inspect + // to understand internal transforms required for RSC. + // inspect(), + ], + + // specify entry point for each environment. + // (currently the plugin assumes `rollupOptions.input.index` for some features.) + environments: { + // `rsc` environment loads modules with `react-server` condition. + // this environment is responsible for: + // - RSC stream serialization (React VDOM -> RSC stream) + // - server functions handling + rsc: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.rsc.tsx', + }, + }, + }, + }, + + // `ssr` environment loads modules without `react-server` condition. + // this environment is responsible for: + // - RSC stream deserialization (RSC stream -> React VDOM) + // - traditional SSR (React VDOM -> HTML string/stream) + ssr: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.ssr.tsx', + }, + }, + }, + }, + + // client environment is used for hydration and client-side rendering + // this environment is responsible for: + // - RSC stream deserialization (RSC stream -> React VDOM) + // - traditional CSR (React VDOM -> Browser DOM tree mount/hydration) + // - refetch and re-render RSC + // - calling server functions + client: { + build: { + rollupOptions: { + input: { + index: './src/framework/entry.browser.tsx', + }, + }, + }, + }, + }, +}) diff --git a/packages/plugin-rsc/package.json b/packages/plugin-rsc/package.json new file mode 100644 index 000000000..5728a67e9 --- /dev/null +++ b/packages/plugin-rsc/package.json @@ -0,0 +1,71 @@ +{ + "name": "@vitejs/plugin-rsc", + "version": "0.4.29", + "description": "React Server Components (RSC) support for Vite.", + "keywords": [ + "vite", + "vite-plugin", + "react", + "react-server-components", + "rsc" + ], + "homepage": "https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc", + "repository": { + "type": "git", + "url": "git+https://github.com/vitejs/vite-plugin-react.git", + "directory": "packages/plugin-rsc" + }, + "license": "MIT", + "type": "module", + "exports": { + "./package.json": "./package.json", + "./types": "./types/index.d.ts", + ".": "./dist/index.js", + "./transforms": "./dist/transforms/index.js", + "./*": "./dist/*.js" + }, + "files": [ + "dist", + "types" + ], + "scripts": { + "test": "vitest", + "test-e2e": "playwright test --project=chromium", + "test-e2e-ci": "playwright test", + "tsc": "tsc -b ./tsconfig.json ./e2e/tsconfig.json ./examples/*/tsconfig.json", + "tsc-dev": "pnpm tsc --watch --preserveWatchOutput", + "dev": "tsdown --sourcemap --watch src", + "build": "tsdown", + "prepack": "tsdown" + }, + "dependencies": { + "@remix-run/node-fetch-server": "^0.8.0", + "es-module-lexer": "^1.7.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.18", + "periscopic": "^4.0.2", + "turbo-stream": "^3.1.0", + "vitefu": "^1.1.1" + }, + "devDependencies": { + "@hiogawa/utils": "^1.7.0", + "@playwright/test": "^1.55.0", + "@tsconfig/strictest": "^2.0.5", + "@types/estree": "^1.0.8", + "@types/node": "^22.18.1", + "@types/react": "^19.1.12", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "workspace:*", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-server-dom-webpack": "^19.1.1", + "rsc-html-stream": "^0.0.7", + "tinyexec": "^1.0.1", + "tsdown": "^0.14.2" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*", + "vite": "*" + } +} diff --git a/packages/plugin-rsc/playwright.config.ts b/packages/plugin-rsc/playwright.config.ts new file mode 100644 index 000000000..5c390e9de --- /dev/null +++ b/packages/plugin-rsc/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: 'e2e', + use: { + trace: 'on-first-retry', + }, + expect: { + toPass: { timeout: 10000 }, + }, + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + viewport: null, + deviceScaleFactor: undefined, + }, + }, + { + name: 'firefox', + use: devices['Desktop Firefox'], + }, + { + name: 'webkit', + use: devices['Desktop Safari'], + }, + ], + workers: 2, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: ['list', process.env.CI && 'github'] + .filter(Boolean) + .map((name) => [name] as any), +}) as any diff --git a/packages/plugin-rsc/src/browser.ts b/packages/plugin-rsc/src/browser.ts new file mode 100644 index 000000000..0892a8879 --- /dev/null +++ b/packages/plugin-rsc/src/browser.ts @@ -0,0 +1,34 @@ +import * as clientReferences from 'virtual:vite-rsc/client-references' +import { setRequireModule } from './core/browser' + +export * from './react/browser' + +initialize() + +function initialize(): void { + setRequireModule({ + load: async (id) => { + if (!import.meta.env.__vite_rsc_build__) { + // @ts-ignore + return __vite_rsc_raw_import__( + withTrailingSlash(import.meta.env.BASE_URL) + id.slice(1), + ) + } else { + const import_ = clientReferences.default[id] + if (!import_) { + throw new Error(`client reference not found '${id}'`) + } + return import_() + } + }, + }) +} + +// Vite normalizes `config.base` to have trailing slash, but not for `import.meta.env.BASE_URL`. +// https://github.com/vitejs/vite/blob/27a192fc95036dbdb6e615a4201b858eb64aa075/packages/vite/src/shared/utils.ts#L48-L53 +function withTrailingSlash(path: string): string { + if (path[path.length - 1] !== '/') { + return `${path}/` + } + return path +} diff --git a/packages/plugin-rsc/src/core/browser.ts b/packages/plugin-rsc/src/core/browser.ts new file mode 100644 index 000000000..d0f5e649f --- /dev/null +++ b/packages/plugin-rsc/src/core/browser.ts @@ -0,0 +1,19 @@ +import { memoize } from '@hiogawa/utils' +import { removeReferenceCacheTag, setInternalRequire } from './shared' + +let init = false + +export function setRequireModule(options: { + load: (id: string) => Promise +}): void { + if (init) return + init = true + + const requireModule = memoize((id: string) => { + return options.load(removeReferenceCacheTag(id)) + }) + + ;(globalThis as any).__vite_rsc_client_require__ = requireModule + + setInternalRequire() +} diff --git a/packages/plugin-rsc/src/core/plugin.ts b/packages/plugin-rsc/src/core/plugin.ts new file mode 100644 index 000000000..d22d2ab0a --- /dev/null +++ b/packages/plugin-rsc/src/core/plugin.ts @@ -0,0 +1,42 @@ +import type { Plugin } from 'vite' + +export default function vitePluginRscCore(): Plugin[] { + return [ + { + name: 'rsc:patch-react-server-dom-webpack', + transform(originalCode, _id, _options) { + let code = originalCode + if (code.includes('__webpack_require__.u')) { + // avoid accessing `__webpack_require__` on import side effect + // https://github.com/facebook/react/blob/a9bbe34622885ef5667d33236d580fe7321c0d8b/packages/react-server-dom-webpack/src/client/ReactFlightClientConfigBundlerWebpackBrowser.js#L16-L17 + code = code.replaceAll('__webpack_require__.u', '({}).u') + } + + // the existance of `__webpack_require__` global can break some packages + // https://github.com/TooTallNate/node-bindings/blob/c8033dcfc04c34397384e23f7399a30e6c13830d/bindings.js#L90-L94 + if (code.includes('__webpack_require__')) { + code = code.replaceAll('__webpack_require__', '__vite_rsc_require__') + } + + if (code !== originalCode) { + return { code, map: null } + } + }, + }, + { + // commonjsOptions needs to be tweaked when this is a linked dep + // since otherwise vendored cjs doesn't work. + name: 'rsc:workaround-linked-dep', + apply: () => !import.meta.url.includes('/node_modules/'), + configEnvironment() { + return { + build: { + commonjsOptions: { + include: [/\/node_modules\//, /\/vendor\/react-server-dom\//], + }, + }, + } + }, + }, + ] +} diff --git a/packages/plugin-rsc/src/core/rsc.ts b/packages/plugin-rsc/src/core/rsc.ts new file mode 100644 index 000000000..d00452555 --- /dev/null +++ b/packages/plugin-rsc/src/core/rsc.ts @@ -0,0 +1,130 @@ +import { memoize, tinyassert } from '@hiogawa/utils' +import type { BundlerConfig, ImportManifestEntry, ModuleMap } from '../types' +import { + SERVER_DECODE_CLIENT_PREFIX, + SERVER_REFERENCE_PREFIX, + createReferenceCacheTag, + removeReferenceCacheTag, + setInternalRequire, +} from './shared' + +// @ts-ignore +import * as ReactServer from '@vitejs/plugin-rsc/vendor/react-server-dom/server.edge' + +let init = false +let requireModule!: (id: string) => unknown + +export function setRequireModule(options: { + load: (id: string) => unknown +}): void { + if (init) return + init = true + + requireModule = (id) => { + return options.load(removeReferenceCacheTag(id)) + } + + // need memoize to return stable promise from __webpack_require__ + ;(globalThis as any).__vite_rsc_server_require__ = memoize( + async (id: string) => { + if (id.startsWith(SERVER_DECODE_CLIENT_PREFIX)) { + id = id.slice(SERVER_DECODE_CLIENT_PREFIX.length) + id = removeReferenceCacheTag(id) + // create `registerClientReference` on the fly since there's no way to + // grab the original client reference module on ther server. + // cf. https://github.com/lazarv/react-server/blob/79e7acebc6f4a8c930ad8422e2a4a9fdacfcce9b/packages/react-server/server/module-loader.mjs#L19 + // decode client reference on the server + return new Proxy({} as any, { + get(target, name, _receiver) { + if (typeof name !== 'string' || name === 'then') return + return (target[name] ??= ReactServer.registerClientReference( + () => { + throw new Error( + `Unexpectedly client reference export '${name}' is called on server`, + ) + }, + id, + name, + )) + }, + }) + } + return requireModule(id) + }, + ) + + setInternalRequire() +} + +export async function loadServerAction(id: string): Promise { + const [file, name] = id.split('#') as [string, string] + const mod: any = await requireModule(file) + return mod[name] +} + +export function createServerManifest(): BundlerConfig { + const cacheTag = import.meta.env.DEV ? createReferenceCacheTag() : '' + + return new Proxy( + {}, + { + get(_target, $$id, _receiver) { + tinyassert(typeof $$id === 'string') + let [id, name] = $$id.split('#') + tinyassert(id) + tinyassert(name) + return { + id: SERVER_REFERENCE_PREFIX + id + cacheTag, + name, + chunks: [], + async: true, + } satisfies ImportManifestEntry + }, + }, + ) +} + +export function createServerDecodeClientManifest(): ModuleMap { + return new Proxy( + {}, + { + get(_target, id: string) { + return new Proxy( + {}, + { + get(_target, name: string) { + return { + id: SERVER_REFERENCE_PREFIX + SERVER_DECODE_CLIENT_PREFIX + id, + name, + chunks: [], + async: true, + } + }, + }, + ) + }, + }, + ) +} + +export function createClientManifest(): BundlerConfig { + const cacheTag = import.meta.env.DEV ? createReferenceCacheTag() : '' + + return new Proxy( + {}, + { + get(_target, $$id, _receiver) { + tinyassert(typeof $$id === 'string') + let [id, name] = $$id.split('#') + tinyassert(id) + tinyassert(name) + return { + id: id + cacheTag, + name, + chunks: [], + async: true, + } satisfies ImportManifestEntry + }, + }, + ) +} diff --git a/packages/plugin-rsc/src/core/shared.ts b/packages/plugin-rsc/src/core/shared.ts new file mode 100644 index 000000000..bd3d18af3 --- /dev/null +++ b/packages/plugin-rsc/src/core/shared.ts @@ -0,0 +1,25 @@ +// use special prefix to switch client/server reference loading inside __webpack_require__ +export const SERVER_REFERENCE_PREFIX = '$$server:' + +export const SERVER_DECODE_CLIENT_PREFIX = '$$decode-client:' + +// cache bust memoized require promise during dev +export function createReferenceCacheTag(): string { + const cache = Math.random().toString(36).slice(2) + return '$$cache=' + cache +} + +export function removeReferenceCacheTag(id: string): string { + return id.split('$$cache=')[0]! +} + +export function setInternalRequire(): void { + // branch client and server require to support the case when ssr and rsc share the same global + ;(globalThis as any).__vite_rsc_require__ = (id: string) => { + if (id.startsWith(SERVER_REFERENCE_PREFIX)) { + id = id.slice(SERVER_REFERENCE_PREFIX.length) + return (globalThis as any).__vite_rsc_server_require__(id) + } + return (globalThis as any).__vite_rsc_client_require__(id) + } +} diff --git a/packages/plugin-rsc/src/core/ssr.ts b/packages/plugin-rsc/src/core/ssr.ts new file mode 100644 index 000000000..68a847a36 --- /dev/null +++ b/packages/plugin-rsc/src/core/ssr.ts @@ -0,0 +1,27 @@ +import { memoize } from '@hiogawa/utils' +import type { ServerConsumerManifest } from '../types' +import { removeReferenceCacheTag, setInternalRequire } from './shared' + +let init = false + +export function setRequireModule(options: { + load: (id: string) => unknown +}): void { + if (init) return + init = true + + const requireModule = memoize((id: string) => { + return options.load(removeReferenceCacheTag(id)) + }) + + const clientRequire = (id: string) => { + return requireModule(id) + } + ;(globalThis as any).__vite_rsc_client_require__ = clientRequire + + setInternalRequire() +} + +export function createServerConsumerManifest(): ServerConsumerManifest { + return {} +} diff --git a/packages/plugin-rsc/src/extra/browser.tsx b/packages/plugin-rsc/src/extra/browser.tsx new file mode 100644 index 000000000..4bf272e9a --- /dev/null +++ b/packages/plugin-rsc/src/extra/browser.tsx @@ -0,0 +1,132 @@ +import React from 'react' +import ReactDomClient from 'react-dom/client' +import { rscStream } from 'rsc-html-stream/client' +import { + type CallServerCallback, + createFromFetch, + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, +} from '../browser' +import type { RscPayload } from './rsc' + +/** + * @deprecated Use `@vitejs/plugin-rsc/browser` API instead. + */ +export async function hydrate(): Promise { + const callServer: CallServerCallback = async (id, args) => { + const url = new URL(window.location.href) + const temporaryReferences = createTemporaryReferenceSet() + const payload = await createFromFetch( + fetch(url, { + method: 'POST', + body: await encodeReply(args, { temporaryReferences }), + headers: { + 'x-rsc-action': id, + }, + }), + { temporaryReferences }, + ) + setPayload(payload) + return payload.returnValue + } + setServerCallback(callServer) + + async function onNavigation() { + const url = new URL(window.location.href) + const payload = await createFromFetch(fetch(url)) + setPayload(payload) + } + + const initialPayload = await createFromReadableStream(rscStream) + + let setPayload: (v: RscPayload) => void + + function BrowserRoot() { + const [payload, setPayload_] = React.useState(initialPayload) + + React.useEffect(() => { + setPayload = (v) => React.startTransition(() => setPayload_(v)) + }, [setPayload_]) + + React.useEffect(() => { + return listenNavigation(() => onNavigation()) + }, []) + + return payload.root + } + + const browserRoot = ( + + + + ) + + ReactDomClient.hydrateRoot(document, browserRoot, { + formState: initialPayload.formState, + }) + + if (import.meta.hot) { + import.meta.hot.on('rsc:update', () => { + window.history.replaceState({}, '', window.location.href) + }) + } +} + +/** + * @deprecated Use `@vitejs/plugin-rsc/browser` API instead. + */ +export async function fetchRSC( + request: string | URL | Request, +): Promise { + const payload = await createFromFetch(fetch(request)) + return payload.root +} + +function listenNavigation(onNavigation: () => void): () => void { + window.addEventListener('popstate', onNavigation) + + const oldPushState = window.history.pushState + window.history.pushState = function (...args) { + const res = oldPushState.apply(this, args) + onNavigation() + return res + } + + const oldReplaceState = window.history.replaceState + window.history.replaceState = function (...args) { + const res = oldReplaceState.apply(this, args) + onNavigation() + return res + } + + function onClick(e: MouseEvent) { + let link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && // left clicks only + !e.metaKey && // open in new tab (mac) + !e.ctrlKey && // open in new tab (windows) + !e.altKey && // download + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + history.pushState(null, '', link.href) + } + } + document.addEventListener('click', onClick) + + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onNavigation) + window.history.pushState = oldPushState + window.history.replaceState = oldReplaceState + } +} diff --git a/packages/plugin-rsc/src/extra/rsc.tsx b/packages/plugin-rsc/src/extra/rsc.tsx new file mode 100644 index 000000000..393dae1dd --- /dev/null +++ b/packages/plugin-rsc/src/extra/rsc.tsx @@ -0,0 +1,96 @@ +import type { ReactFormState } from 'react-dom/client' +import { + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + renderToReadableStream, +} from '../rsc' + +export type RscPayload = { + root: React.ReactNode + formState?: ReactFormState + returnValue?: unknown +} + +/** + * @deprecated Use `@vitejs/plugin-rsc/rsc` API instead. + */ +export async function renderRequest( + request: Request, + root: React.ReactNode, + options?: { nonce?: string }, +): Promise { + function RscRoot() { + // https://vite.dev/guide/features.html#content-security-policy-csp + // this isn't needed if `style-src: 'unsafe-inline'` (dev) and `script-src: 'self'` + const nonceMeta = options?.nonce && ( + + ) + return ( + <> + {nonceMeta} + {root} + + ) + } + + const url = new URL(request.url) + const isAction = request.method === 'POST' + + // use ?__rsc and ?__html for quick debugging + const isRscRequest = + (!request.headers.get('accept')?.includes('text/html') && + !url.searchParams.has('__html')) || + url.searchParams.has('__rsc') + + // TODO: error handling + // callAction + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + let temporaryReferences: unknown | undefined + if (isAction) { + const actionId = request.headers.get('x-rsc-action') + if (actionId) { + // client stream request + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + temporaryReferences = createTemporaryReferenceSet() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(actionId) + returnValue = await action.apply(null, args) + } else { + // progressive enhancement + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } + } + + const rscPayload: RscPayload = { root: , formState, returnValue } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + if (isRscRequest) { + return new Response(rscStream, { + headers: { + 'content-type': 'text/x-component;charset=utf-8', + vary: 'accept', + }, + }) + } + + const ssrEntry = await import.meta.viteRsc.loadModule( + 'ssr', + 'index', + ) + return ssrEntry.renderHtml(rscStream, { + formState, + nonce: options?.nonce, + debugNoJs: url.searchParams.has('__nojs'), + }) +} diff --git a/packages/plugin-rsc/src/extra/ssr.tsx b/packages/plugin-rsc/src/extra/ssr.tsx new file mode 100644 index 000000000..6e8c71bb2 --- /dev/null +++ b/packages/plugin-rsc/src/extra/ssr.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import type { ReactFormState } from 'react-dom/client' +import ReactDomServer from 'react-dom/server.edge' +import { injectRSCPayload } from 'rsc-html-stream/server' +import { createFromReadableStream } from '../ssr' +import type { RscPayload } from './rsc' + +/** + * @deprecated Use `@vitejs/plugin-rsc/ssr` API instead. + */ +export async function renderHtml( + rscStream: ReadableStream, + options?: { + formState?: ReactFormState + nonce?: string + debugNoJs?: boolean + }, +): Promise { + const [rscStream1, rscStream2] = rscStream.tee() + + // flight deserialization needs to be kicked off inside SSR context + // for ReactDomServer preinit/preloading to work + let payload: Promise + function SsrRoot() { + payload ??= createFromReadableStream(rscStream1, { + nonce: options?.nonce, + }) + const root = React.use(payload).root + return root + } + + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + const htmlStream = await ReactDomServer.renderToReadableStream(, { + bootstrapScriptContent: options?.debugNoJs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNoJs) { + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return new Response(responseStream, { + headers: { + 'content-type': 'text/html;charset=utf-8', + vary: 'accept', + }, + }) +} diff --git a/packages/plugin-rsc/src/index.ts b/packages/plugin-rsc/src/index.ts new file mode 100644 index 000000000..b3c2a7f0b --- /dev/null +++ b/packages/plugin-rsc/src/index.ts @@ -0,0 +1,6 @@ +export { + default, + type RscPluginOptions, + getPluginApi, + type PluginApi, +} from './plugin' diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts new file mode 100644 index 000000000..ad0004af1 --- /dev/null +++ b/packages/plugin-rsc/src/plugin.ts @@ -0,0 +1,2290 @@ +import assert from 'node:assert' +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { createRequestListener } from '@remix-run/node-fetch-server' +import * as esModuleLexer from 'es-module-lexer' +import MagicString from 'magic-string' +import * as vite from 'vite' +import { + type BuilderOptions, + type DevEnvironment, + type EnvironmentModuleNode, + type Plugin, + type ResolvedConfig, + type Rollup, + type RunnableDevEnvironment, + type ViteDevServer, + defaultServerConditions, + isCSSRequest, + normalizePath, + parseAstAsync, +} from 'vite' +import { crawlFrameworkPkgs } from 'vitefu' +import vitePluginRscCore from './core/plugin' +import { + type TransformWrapExportFilter, + hasDirective, + transformDirectiveProxyExport, + transformServerActionServer, + transformWrapExport, +} from './transforms' +import { generateEncryptionKey, toBase64 } from './utils/encryption-utils' +import { createRpcServer } from './utils/rpc' +import { + cleanUrl, + evalValue, + normalizeViteImportAnalysisUrl, + prepareError, +} from './plugins/vite-utils' +import { cjsModuleRunnerPlugin } from './plugins/cjs' +import { + createVirtualPlugin, + getEntrySource, + hashString, + normalizeRelativePath, + sortObject, + withRollupError, +} from './plugins/utils' +import { createDebug } from '@hiogawa/utils' +import { scanBuildStripPlugin } from './plugins/scan' +import { validateImportPlugin } from './plugins/validate-import' +import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url' +import { parseCssVirtual, toCssVirtual, parseIdQuery } from './plugins/shared' + +const isRolldownVite = 'rolldownVersion' in vite + +const BUILD_ASSETS_MANIFEST_NAME = '__vite_rsc_assets_manifest.js' + +type ClientReferenceMeta = { + importId: string + // same as `importId` during dev. hashed id during build. + referenceKey: string + packageSource?: string + // build only for tree-shaking unused export + exportNames: string[] + renderedExports: string[] + serverChunk?: string + groupChunkId?: string +} + +type ServerRerferenceMeta = { + importId: string + referenceKey: string + // TODO: tree shake unused server functions + exportNames: string[] +} + +const PKG_NAME = '@vitejs/plugin-rsc' +const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom` + +// dev-only wrapper virtual module of rollupOptions.input.index +const VIRTUAL_ENTRIES = { + browser: 'virtual:vite-rsc/entry-browser', +} + +const require = createRequire(import.meta.url) + +function resolvePackage(name: string) { + return pathToFileURL(require.resolve(name)).href +} + +export type { RscPluginManager } + +class RscPluginManager { + server!: ViteDevServer + config!: ResolvedConfig + rscBundle!: Rollup.OutputBundle + buildAssetsManifest: AssetsManifest | undefined + isScanBuild: boolean = false + clientReferenceMetaMap: Record = {} + clientReferenceGroups: Record = + {} + serverReferenceMetaMap: Record = {} + serverResourcesMetaMap: Record = {} + + stabilize(): void { + // sort for stable build + this.clientReferenceMetaMap = sortObject(this.clientReferenceMetaMap) + this.serverResourcesMetaMap = sortObject(this.serverResourcesMetaMap) + } + + toRelativeId(id: string): string { + return normalizePath(path.relative(this.config.root, id)) + } +} + +export type RscPluginOptions = { + /** + * shorthand for configuring `environments.(name).build.rollupOptions.input.index` + */ + entries?: Partial> + + /** @deprecated use `serverHandler: false` */ + disableServerHandler?: boolean + + /** @default { enviornmentName: "rsc", entryName: "index" } */ + serverHandler?: + | { + environmentName: string + entryName: string + } + | false + + /** @default false */ + loadModuleDevProxy?: boolean + + rscCssTransform?: false | { filter?: (id: string) => boolean } + + /** @deprecated use "DEBUG=vite-env:*" to see warnings. */ + ignoredPackageWarnings?: (string | RegExp)[] + + /** + * This option allows customizing how client build copies assets from server build. + * By default, all assets are copied, but frameworks can establish server asset convention + * to tighten security using this option. + */ + copyServerAssetsToClient?: (fileName: string) => boolean + + /** + * This option allows disabling action closure encryption for debugging purpose. + * @default true + */ + enableActionEncryption?: boolean + + /** + * By default, the plugin uses a build-time generated encryption key for + * "use server" closure argument binding. + * This can be overwritten by configuring `defineEncryptionKey` option, + * for example, to obtain a key through environment variable during runtime. + * cf. https://nextjs.org/docs/app/guides/data-security#overwriting-encryption-keys-advanced + */ + defineEncryptionKey?: string + + /** Escape hatch for Waku's `allowServer` */ + keepUseCientProxy?: boolean + + /** + * Enable build-time validation of 'client-only' and 'server-only' imports + * @default true + */ + validateImports?: boolean + + /** + * use `Plugin.buildApp` hook (introduced on Vite 7) instead of `builder.buildApp` configuration + * for better composability with other plugins. + * @default true since Vite 7 + */ + useBuildAppHook?: boolean + + /** + * Custom environment configuration + * @experimental + * @default { browser: 'client', ssr: 'ssr', rsc: 'rsc' } + */ + environment?: { + browser?: string + ssr?: string + rsc?: string + } + + /** + * Custom chunking strategy for client reference modules. + * + * This function allows you to group multiple client components into + * custom chunks instead of having each module in its own chunk. + * By default, client chunks are grouped by `meta.serverChunk`. + */ + clientChunks?: (meta: { + /** client reference module id */ + id: string + /** normalized client reference module id */ + normalizedId: string + /** server chunk which includes a corresponding client reference proxy module */ + serverChunk: string + }) => string | undefined +} + +export type PluginApi = { + manager: RscPluginManager +} + +/** @experimental */ +export function getPluginApi( + config: Pick, +): PluginApi | undefined { + const plugin = config.plugins.find((p) => p.name === 'rsc:minimal') + return plugin?.api as PluginApi | undefined +} + +/** @experimental */ +export function vitePluginRscMinimal( + rscPluginOptions: RscPluginOptions = {}, + manager: RscPluginManager = new RscPluginManager(), +): Plugin[] { + return [ + { + name: 'rsc:minimal', + enforce: 'pre', + // https://rollupjs.org/plugin-development/#direct-plugin-communication + api: { + manager, + } satisfies PluginApi, + async config() { + await esModuleLexer.init + }, + configResolved(config) { + manager.config = config + // ensure outDir is fully resolved to take custom root into account + // https://github.com/vitejs/vite/blob/946831f986cb797009b8178659d2b31f570c44ff/packages/vite/src/node/build.ts#L574 + for (const e of Object.values(config.environments)) { + e.build.outDir = path.resolve(config.root, e.build.outDir) + } + }, + configureServer(server_) { + manager.server = server_ + }, + }, + { + name: 'rsc:vite-client-raw-import', + transform: { + order: 'post', + handler(code) { + if (code.includes('__vite_rsc_raw_import__')) { + // inject dynamic import last to avoid Vite adding `?import` query + // to client references (and browser mode server references) + return code.replace('__vite_rsc_raw_import__', 'import') + } + }, + }, + }, + ...vitePluginRscCore(), + ...vitePluginUseClient(rscPluginOptions, manager), + ...vitePluginUseServer(rscPluginOptions, manager), + ...vitePluginDefineEncryptionKey(rscPluginOptions), + scanBuildStripPlugin({ manager }), + ] +} + +export default function vitePluginRsc( + rscPluginOptions: RscPluginOptions = {}, +): Plugin[] { + const manager = new RscPluginManager() + + const buildApp: NonNullable = async (builder) => { + // no-ssr case + // rsc -> client -> rsc -> client + if (!builder.environments.ssr?.config.build.rollupOptions.input) { + manager.isScanBuild = true + builder.environments.rsc!.config.build.write = false + builder.environments.client!.config.build.write = false + await builder.build(builder.environments.rsc!) + await builder.build(builder.environments.client!) + manager.isScanBuild = false + builder.environments.rsc!.config.build.write = true + builder.environments.client!.config.build.write = true + await builder.build(builder.environments.rsc!) + manager.stabilize() + await builder.build(builder.environments.client!) + writeAssetsManifest(['rsc']) + return + } + + // rsc -> ssr -> rsc -> client -> ssr + manager.isScanBuild = true + builder.environments.rsc!.config.build.write = false + builder.environments.ssr!.config.build.write = false + await builder.build(builder.environments.rsc!) + await builder.build(builder.environments.ssr!) + manager.isScanBuild = false + builder.environments.rsc!.config.build.write = true + builder.environments.ssr!.config.build.write = true + await builder.build(builder.environments.rsc!) + manager.stabilize() + await builder.build(builder.environments.client!) + await builder.build(builder.environments.ssr!) + writeAssetsManifest(['ssr', 'rsc']) + } + + function writeAssetsManifest(environmentNames: string[]) { + // output client manifest to non-client build directly. + // this makes server build to be self-contained and deploy-able for cloudflare. + const assetsManifestCode = `export default ${serializeValueWithRuntime( + manager.buildAssetsManifest, + )}` + for (const name of environmentNames) { + const manifestPath = path.join( + manager.config.environments[name]!.build.outDir, + BUILD_ASSETS_MANIFEST_NAME, + ) + fs.writeFileSync(manifestPath, assetsManifestCode) + } + } + + return [ + { + name: 'rsc', + async config(config, env) { + if (config.rsc) { + // mutate `rscPluginOptions` since internally this object is passed around + Object.assign( + rscPluginOptions, + // not sure which should win. for now plugin constructor wins. + vite.mergeConfig(config.rsc, rscPluginOptions), + ) + } + // crawl packages with "react" in "peerDependencies" to bundle react deps on server + // see https://github.com/svitejs/vitefu/blob/d8d82fa121e3b2215ba437107093c77bde51b63b/src/index.js#L95-L101 + const result = await crawlFrameworkPkgs({ + root: process.cwd(), + isBuild: env.command === 'build', + isFrameworkPkgByJson(pkgJson) { + if ([PKG_NAME, 'react-dom'].includes(pkgJson.name)) { + return + } + const deps = pkgJson['peerDependencies'] + return deps && 'react' in deps + }, + }) + const noExternal = [ + 'react', + 'react-dom', + 'server-only', + 'client-only', + PKG_NAME, + ...result.ssr.noExternal.sort(), + ] + + return { + appType: config.appType ?? 'custom', + define: { + 'import.meta.env.__vite_rsc_build__': JSON.stringify( + env.command === 'build', + ), + }, + environments: { + client: { + build: { + outDir: + config.environments?.client?.build?.outDir ?? 'dist/client', + rollupOptions: { + input: rscPluginOptions.entries?.client && { + index: rscPluginOptions.entries.client, + }, + }, + }, + optimizeDeps: { + include: [ + 'react-dom/client', + `${REACT_SERVER_DOM_NAME}/client.browser`, + ], + exclude: [PKG_NAME], + }, + }, + ssr: { + build: { + outDir: config.environments?.ssr?.build?.outDir ?? 'dist/ssr', + copyPublicDir: false, + rollupOptions: { + input: rscPluginOptions.entries?.ssr && { + index: rscPluginOptions.entries.ssr, + }, + }, + }, + resolve: { + noExternal, + }, + optimizeDeps: { + include: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'react-dom/server.edge', + 'react-dom/static.edge', + `${REACT_SERVER_DOM_NAME}/client.edge`, + ], + exclude: [PKG_NAME], + }, + }, + rsc: { + build: { + outDir: config.environments?.rsc?.build?.outDir ?? 'dist/rsc', + copyPublicDir: false, + emitAssets: true, + rollupOptions: { + input: rscPluginOptions.entries?.rsc && { + index: rscPluginOptions.entries.rsc, + }, + }, + }, + resolve: { + conditions: ['react-server', ...defaultServerConditions], + noExternal, + }, + optimizeDeps: { + include: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + `${REACT_SERVER_DOM_NAME}/server.edge`, + `${REACT_SERVER_DOM_NAME}/client.edge`, + ], + exclude: [PKG_NAME], + }, + }, + }, + builder: { + sharedPlugins: true, + sharedConfigBuild: true, + async buildApp(builder) { + if (!rscPluginOptions.useBuildAppHook) { + await buildApp(builder) + } + }, + }, + } + }, + configResolved() { + if (Number(vite.version.split('.')[0]) >= 7) { + rscPluginOptions.useBuildAppHook ??= true + } + }, + buildApp: { + async handler(builder) { + if (rscPluginOptions.useBuildAppHook) { + await buildApp(builder) + } + }, + }, + configureServer(server) { + ;(globalThis as any).__viteRscDevServer = server + + // intercept client hmr to propagate client boundary invalidation to server environment + const oldSend = server.environments.client.hot.send + server.environments.client.hot.send = async function ( + this, + ...args: any[] + ) { + const e = args[0] as vite.UpdatePayload + if (e && typeof e === 'object' && e.type === 'update') { + for (const update of e.updates) { + if (update.type === 'js-update') { + const mod = + server.environments.client.moduleGraph.urlToModuleMap.get( + update.path, + ) + if (mod && mod.id && manager.clientReferenceMetaMap[mod.id]) { + const serverMod = + server.environments.rsc!.moduleGraph.getModuleById(mod.id) + if (serverMod) { + server.environments.rsc!.moduleGraph.invalidateModule( + serverMod, + ) + } + } + } + } + } + return oldSend.apply(this, args as any) + } + + if (rscPluginOptions.disableServerHandler) return + if (rscPluginOptions.serverHandler === false) return + const options = rscPluginOptions.serverHandler ?? { + environmentName: 'rsc', + entryName: 'index', + } + const environment = server.environments[ + options.environmentName + ] as RunnableDevEnvironment + const source = getEntrySource(environment.config, options.entryName) + + return () => { + server.middlewares.use(async (req, res, next) => { + try { + // resolve before `runner.import` to workaround https://github.com/vitejs/vite/issues/19975 + const resolved = + await environment.pluginContainer.resolveId(source) + assert( + resolved, + `[vite-rsc] failed to resolve server handler '${source}'`, + ) + const mod = await environment.runner.import(resolved.id) + // expose original request url to server handler. + // for example, this restores `base` which is automatically stripped by Vite. + // https://github.com/vitejs/vite/blob/84079a84ad94de4c1ef4f1bdb2ab448ff2c01196/packages/vite/src/node/server/middlewares/base.ts#L18-L20 + req.url = req.originalUrl ?? req.url + // ensure catching rejected promise + // https://github.com/mjackson/remix-the-web/blob/b5aa2ae24558f5d926af576482caf6e9b35461dc/packages/node-fetch-server/src/lib/request-listener.ts#L87 + await createRequestListener(mod.default)(req, res) + } catch (e) { + next(e) + } + }) + } + }, + async configurePreviewServer(server) { + if (rscPluginOptions.disableServerHandler) return + if (rscPluginOptions.serverHandler === false) return + const options = rscPluginOptions.serverHandler ?? { + environmentName: 'rsc', + entryName: 'index', + } + + const entryFile = path.join( + manager.config.environments[options.environmentName]!.build.outDir, + `${options.entryName}.js`, + ) + const entry = pathToFileURL(entryFile).href + const mod = await import(/* @vite-ignore */ entry) + const handler = createRequestListener(mod.default) + + // disable compressions since it breaks html streaming + // https://github.com/vitejs/vite/blob/9f5c59f07aefb1756a37bcb1c0aff24d54288950/packages/vite/src/node/preview.ts#L178 + server.middlewares.use((req, _res, next) => { + delete req.headers['accept-encoding'] + next() + }) + + return () => { + server.middlewares.use(async (req, res, next) => { + try { + req.url = req.originalUrl ?? req.url + await handler(req, res) + } catch (e) { + next(e) + } + }) + } + }, + async hotUpdate(ctx) { + if (isCSSRequest(ctx.file)) { + if (this.environment.name === 'client') { + // filter out `.css?direct` (injected by SSR) to avoid browser full reload + // when changing non-self accepting css such as `module.css`. + return ctx.modules.filter( + (m) => !(m.id?.includes('?direct') && !m.isSelfAccepting), + ) + } + } + + const ids = ctx.modules.map((mod) => mod.id).filter((v) => v !== null) + if (ids.length === 0) return + + // handle client -> server switch (i.e. "use client" removal) + // by eagerly transforming new module on "rsc" environment. + if (this.environment.name === 'rsc') { + for (const mod of ctx.modules) { + if ( + mod.type === 'js' && + mod.id && + mod.id in manager.clientReferenceMetaMap + ) { + try { + await this.environment.transformRequest(mod.url) + } catch {} + } + } + } + + // a shared component/module will have `isInsideClientBoundary = false` on `rsc` environment + // and `isInsideClientBoundary = true` on `client` environment, + // which means both server hmr and client hmr will be triggered. + function isInsideClientBoundary(mods: EnvironmentModuleNode[]) { + const visited = new Set() + function recurse(mod: EnvironmentModuleNode): boolean { + if (!mod.id) return false + if (manager.clientReferenceMetaMap[mod.id]) return true + if (visited.has(mod.id)) return false + visited.add(mod.id) + for (const importer of mod.importers) { + if (recurse(importer)) { + return true + } + } + return false + } + return mods.some((mod) => recurse(mod)) + } + + if (!isInsideClientBoundary(ctx.modules)) { + if (this.environment.name === 'rsc') { + // detect if this module is only created as css deps (e.g. tailwind) + // (NOTE: this is not necessary since Vite 7.1.0-beta.0 https://github.com/vitejs/vite/pull/20391 ) + if (ctx.modules.length === 1) { + const importers = [...ctx.modules[0]!.importers] + if ( + importers.length > 0 && + importers.every((m) => m.id && isCSSRequest(m.id)) + ) { + return [] + } + } + + // transform js to surface syntax errors + for (const mod of ctx.modules) { + if (mod.type === 'js') { + try { + await this.environment.transformRequest(mod.url) + } catch (e) { + manager.server.environments.client.hot.send({ + type: 'error', + err: prepareError(e as any), + }) + throw e + } + } + } + // server hmr + ctx.server.environments.client.hot.send({ + type: 'custom', + event: 'rsc:update', + data: { + file: ctx.file, + }, + }) + } + + if (this.environment.name === 'client') { + // Server files can be included in client module graph, for example, + // when `addWatchFile` is used to track js files as style dependency (e.g. tailwind) + // In this case, reload all importers (for css hmr), and return empty modules to avoid full-reload. + // (NOTE: this is not necessary since Vite 7.1.0-beta.0 https://github.com/vitejs/vite/pull/20391 ) + const env = ctx.server.environments.rsc! + const mod = env.moduleGraph.getModuleById(ctx.file) + if (mod) { + for (const clientMod of ctx.modules) { + for (const importer of clientMod.importers) { + if (importer.id && isCSSRequest(importer.id)) { + await this.environment.reloadModule(importer) + } + } + } + return [] + } + } + } + }, + }, + { + // backward compat: `loadSsrModule(name)` implemented as `loadModule("ssr", name)` + name: 'rsc:load-ssr-module', + transform(code) { + if (code.includes('import.meta.viteRsc.loadSsrModule(')) { + return code.replaceAll( + `import.meta.viteRsc.loadSsrModule(`, + `import.meta.viteRsc.loadModule("ssr", `, + ) + } + }, + }, + { + // allow loading entry module in other environment by + // - (dev) rewriting to `server.environments[].runner.import()` + // - (build) rewriting to external `import("..//.js")` + name: 'rsc:load-environment-module', + async transform(code) { + if (!code.includes('import.meta.viteRsc.loadModule')) return + const { server } = manager + const s = new MagicString(code) + for (const match of code.matchAll( + /import\.meta\.viteRsc\.loadModule\(([\s\S]*?)\)/dg, + )) { + const argCode = match[1]!.trim() + const [environmentName, entryName] = evalValue(`[${argCode}]`) + let replacement: string + if ( + this.environment.mode === 'dev' && + rscPluginOptions.loadModuleDevProxy + ) { + const origin = server.resolvedUrls?.local[0] + assert(origin, '[vite-rsc] no server for loadModueleDevProxy') + const endpoint = + origin + + '__vite_rsc_load_module_dev_proxy?' + + new URLSearchParams({ environmentName, entryName }) + replacement = `__vite_rsc_rpc.createRpcClient(${JSON.stringify({ + endpoint, + })})` + s.prepend( + `import * as __vite_rsc_rpc from "@vitejs/plugin-rsc/utils/rpc";`, + ) + } else if (this.environment.mode === 'dev') { + const environment = server.environments[environmentName]! + const source = getEntrySource(environment.config, entryName) + const resolved = await environment.pluginContainer.resolveId(source) + assert(resolved, `[vite-rsc] failed to resolve entry '${source}'`) + replacement = + `globalThis.__viteRscDevServer.environments[${JSON.stringify( + environmentName, + )}]` + `.runner.import(${JSON.stringify(resolved.id)})` + } else { + replacement = JSON.stringify( + `__vite_rsc_load_module:${this.environment.name}:${environmentName}:${entryName}`, + ) + } + const [start, end] = match.indices![0]! + s.overwrite(start, end, replacement) + } + if (s.hasChanged()) { + return { + code: s.toString(), + map: s.generateMap({ hires: 'boundary' }), + } + } + }, + renderChunk(code, chunk) { + if (!code.includes('__vite_rsc_load_module')) return + const { config } = manager + const s = new MagicString(code) + for (const match of code.matchAll( + /['"]__vite_rsc_load_module:(\w+):(\w+):(\w+)['"]/dg, + )) { + const [fromEnv, toEnv, entryName] = match.slice(1) + const importPath = normalizeRelativePath( + path.relative( + path.join( + config.environments[fromEnv!]!.build.outDir, + chunk.fileName, + '..', + ), + path.join( + config.environments[toEnv!]!.build.outDir, + // TODO: this breaks when custom entyFileNames + `${entryName}.js`, + ), + ), + ) + const replacement = `(import(${JSON.stringify(importPath)}))` + const [start, end] = match.indices![0]! + s.overwrite(start, end, replacement) + } + if (s.hasChanged()) { + return { + code: s.toString(), + map: s.generateMap({ hires: 'boundary' }), + } + } + }, + }, + { + name: 'vite-rsc-load-module-dev-proxy', + configureServer(server) { + if (!rscPluginOptions.loadModuleDevProxy) return + + async function createHandler(url: URL) { + const { environmentName, entryName } = Object.fromEntries( + url.searchParams, + ) + assert(environmentName) + assert(entryName) + const environment = server.environments[ + environmentName + ] as RunnableDevEnvironment + const source = getEntrySource(environment.config, entryName) + const resolvedEntry = + await environment.pluginContainer.resolveId(source) + assert( + resolvedEntry, + `[vite-rsc] failed to resolve entry '${source}'`, + ) + const runnerProxy = new Proxy( + {}, + { + get(_target, p, _receiver) { + if (typeof p !== 'string' || p === 'then') { + return + } + return async (...args: any[]) => { + const mod = await environment.runner.import(resolvedEntry.id) + return (mod as any)[p](...args) + } + }, + }, + ) + return createRpcServer(runnerProxy) + } + + server.middlewares.use(async (req, res, next) => { + const url = new URL(req.url ?? '/', `http://localhost`) + if (url.pathname === '/__vite_rsc_load_module_dev_proxy') { + try { + const handler = await createHandler(url) + createRequestListener(handler)(req, res) + } catch (e) { + next(e) + } + return + } + next() + }) + }, + }, + { + name: 'rsc:virtual:vite-rsc/assets-manifest', + resolveId(source) { + if (source === 'virtual:vite-rsc/assets-manifest') { + if (this.environment.mode === 'build') { + return { id: source, external: true } + } + return `\0` + source + } + }, + load(id) { + if (id === '\0virtual:vite-rsc/assets-manifest') { + assert(this.environment.name !== 'client') + assert(this.environment.mode === 'dev') + const entryUrl = assetsURL( + '@id/__x00__' + VIRTUAL_ENTRIES.browser, + manager, + ) + const manifest: AssetsManifest = { + bootstrapScriptContent: `import(${serializeValueWithRuntime(entryUrl)})`, + clientReferenceDeps: {}, + } + return `export default ${JSON.stringify(manifest, null, 2)}` + } + }, + // client build + generateBundle(_options, bundle) { + // copy assets from rsc build to client build + if (this.environment.name === 'rsc') { + manager.rscBundle = bundle + } + + if (this.environment.name === 'client') { + const filterAssets = + rscPluginOptions.copyServerAssetsToClient ?? (() => true) + const rscBuildOptions = manager.config.environments.rsc!.build + const rscViteManifest = + typeof rscBuildOptions.manifest === 'string' + ? rscBuildOptions.manifest + : rscBuildOptions.manifest && '.vite/manifest.json' + for (const asset of Object.values(manager.rscBundle)) { + if (asset.fileName === rscViteManifest) continue + if (asset.type === 'asset' && filterAssets(asset.fileName)) { + this.emitFile({ + type: 'asset', + fileName: asset.fileName, + source: asset.source, + }) + } + } + + const serverResources: Record = {} + const rscAssetDeps = collectAssetDeps(manager.rscBundle) + for (const [id, meta] of Object.entries( + manager.serverResourcesMetaMap, + )) { + serverResources[meta.key] = assetsURLOfDeps( + { + js: [], + css: rscAssetDeps[id]?.deps.css ?? [], + }, + manager, + ) + } + + const assetDeps = collectAssetDeps(bundle) + const entry = Object.values(assetDeps).find( + (v) => v.chunk.name === 'index', + ) + assert(entry) + const entryUrl = assetsURL(entry.chunk.fileName, manager) + const clientReferenceDeps: Record = {} + for (const meta of Object.values(manager.clientReferenceMetaMap)) { + const deps: AssetDeps = assetDeps[meta.groupChunkId!]?.deps ?? { + js: [], + css: [], + } + clientReferenceDeps[meta.referenceKey] = assetsURLOfDeps( + mergeAssetDeps(deps, entry.deps), + manager, + ) + } + let bootstrapScriptContent: string | RuntimeAsset + if (typeof entryUrl === 'string') { + bootstrapScriptContent = `import(${JSON.stringify(entryUrl)})` + } else { + bootstrapScriptContent = new RuntimeAsset( + `"import(" + JSON.stringify(${entryUrl.runtime}) + ")"`, + ) + } + manager.buildAssetsManifest = { + bootstrapScriptContent, + clientReferenceDeps, + serverResources, + } + } + }, + // non-client builds can load assets manifest as external + renderChunk(code, chunk) { + if (code.includes('virtual:vite-rsc/assets-manifest')) { + assert(this.environment.name !== 'client') + const replacement = normalizeRelativePath( + path.relative( + path.join(chunk.fileName, '..'), + BUILD_ASSETS_MANIFEST_NAME, + ), + ) + code = code.replaceAll( + 'virtual:vite-rsc/assets-manifest', + () => replacement, + ) + return { code } + } + return + }, + }, + createVirtualPlugin('vite-rsc/bootstrap-script-content', function () { + assert(this.environment.name !== 'client') + return `\ +import assetsManifest from "virtual:vite-rsc/assets-manifest"; +export default assetsManifest.bootstrapScriptContent; +` + }), + { + name: 'rsc:bootstrap-script-content', + async transform(code) { + if ( + !code.includes('loadBootstrapScriptContent') || + !/import\s*\.\s*meta\s*\.\s*viteRsc\s*\.\s*loadBootstrapScriptContent/.test( + code, + ) + ) { + return + } + + assert(this.environment.name !== 'client') + const output = new MagicString(code) + + for (const match of code.matchAll( + /import\s*\.\s*meta\s*\.\s*viteRsc\s*\.\s*loadBootstrapScriptContent\(([\s\S]*?)\)/dg, + )) { + const argCode = match[1]!.trim() + const entryName = evalValue(argCode) + assert( + entryName, + `[vite-rsc] expected 'loadBootstrapScriptContent("index")' but got ${argCode}`, + ) + let replacement: string = `Promise.resolve(__vite_rsc_assets_manifest.bootstrapScriptContent)` + const [start, end] = match.indices![0]! + output.overwrite(start, end, replacement) + } + if (output.hasChanged()) { + if (!code.includes('__vite_rsc_assets_manifest')) { + output.prepend( + `import __vite_rsc_assets_manifest from "virtual:vite-rsc/assets-manifest";`, + ) + } + return { + code: output.toString(), + map: output.generateMap({ hires: 'boundary' }), + } + } + }, + }, + createVirtualPlugin( + VIRTUAL_ENTRIES.browser.slice('virtual:'.length), + async function () { + assert(this.environment.mode === 'dev') + let code = '' + // enable hmr only when react plugin is available + const resolved = await this.resolve('/@react-refresh') + if (resolved) { + code += ` +import RefreshRuntime from "/@react-refresh"; +RefreshRuntime.injectIntoGlobalHook(window); +window.$RefreshReg$ = () => {}; +window.$RefreshSig$ = () => (type) => type; +window.__vite_plugin_react_preamble_installed__ = true; +` + } + const source = getEntrySource(this.environment.config, 'index') + const resolvedEntry = await this.resolve(source) + assert(resolvedEntry, `[vite-rsc] failed to resolve entry '${source}'`) + code += `await import(${JSON.stringify(resolvedEntry.id)});` + // server css is normally removed via `RemoveDuplicateServerCss` on useEffect. + // this also makes sure they are removed on hmr in case initial rendering failed. + code += /* js */ ` +const ssrCss = document.querySelectorAll("link[rel='stylesheet']"); +import.meta.hot.on("vite:beforeUpdate", () => { + ssrCss.forEach(node => { + if (node.dataset.precedence?.startsWith("vite-rsc/")) { + node.remove(); + } + }); +}); +` + // close error overlay after syntax error is fixed and hmr is triggered. + // https://github.com/vitejs/vite/blob/8033e5bf8d3ff43995d0620490ed8739c59171dd/packages/vite/src/client/client.ts#L318-L320 + code += ` +import.meta.hot.on("rsc:update", () => { + document.querySelectorAll("vite-error-overlay").forEach((n) => n.close()) +}); +` + return code + }, + ), + ...vitePluginRscMinimal(rscPluginOptions, manager), + ...vitePluginFindSourceMapURL(), + ...vitePluginRscCss(rscPluginOptions, manager), + { + ...validateImportPlugin(), + apply: () => rscPluginOptions.validateImports !== false, + }, + scanBuildStripPlugin({ manager }), + ...cjsModuleRunnerPlugin(), + ...globalAsyncLocalStoragePlugin(), + ] +} + +// make `AsyncLocalStorage` available globally for React edge build (required for React.cache, ssr preload, etc.) +// https://github.com/facebook/react/blob/f14d7f0d2597ea25da12bcf97772e8803f2a394c/packages/react-server/src/forks/ReactFlightServerConfig.dom-edge.js#L16-L19 +function globalAsyncLocalStoragePlugin(): Plugin[] { + return [ + { + name: 'rsc:inject-async-local-storage', + transform: { + handler(code) { + if ( + (this.environment.name === 'ssr' || + this.environment.name === 'rsc') && + code.includes('typeof AsyncLocalStorage') && + code.includes('new AsyncLocalStorage()') && + !code.includes('__viteRscAsyncHooks') + ) { + // for build, we cannot use `import` as it confuses rollup commonjs plugin. + return ( + (this.environment.mode === 'build' && !isRolldownVite + ? `const __viteRscAsyncHooks = require("node:async_hooks");` + : `import * as __viteRscAsyncHooks from "node:async_hooks";`) + + `globalThis.AsyncLocalStorage = __viteRscAsyncHooks.AsyncLocalStorage;` + + code + ) + } + }, + }, + }, + ] +} + +function vitePluginUseClient( + useClientPluginOptions: Pick< + RscPluginOptions, + 'keepUseCientProxy' | 'environment' | 'clientChunks' + >, + manager: RscPluginManager, +): Plugin[] { + const packageSources = new Map() + + // https://github.com/vitejs/vite/blob/4bcf45863b5f46aa2b41f261283d08f12d3e8675/packages/vite/src/node/utils.ts#L175 + const bareImportRE = /^(?![a-zA-Z]:)[\w@](?!.*:\/\/)/ + + const serverEnvironmentName = useClientPluginOptions.environment?.rsc ?? 'rsc' + const browserEnvironmentName = + useClientPluginOptions.environment?.browser ?? 'client' + + let optimizerMetadata: CustomOptimizerMetadata | undefined + + // TODO: warning for late optimizer discovery + function warnInoncistentClientOptimization( + ctx: Rollup.TransformPluginContext, + id: string, + ) { + // path in metafile is relative to cwd + // https://github.com/vitejs/vite/blob/dd96c2cd831ecba3874458b318ad4f0a7f173736/packages/vite/src/node/optimizer/index.ts#L644 + id = normalizePath(path.relative(process.cwd(), id)) + if (optimizerMetadata?.ids.includes(id)) { + ctx.warn( + `client component dependency is inconsistently optimized. ` + + `It's recommended to add the dependency to 'optimizeDeps.exclude'.`, + ) + } + } + + const debug = createDebug('vite-rsc:use-client') + + return [ + { + name: 'rsc:use-client', + async transform(code, id) { + if (this.environment.name !== serverEnvironmentName) return + if (!code.includes('use client')) { + delete manager.clientReferenceMetaMap[id] + return + } + + const ast = await parseAstAsync(code) + if (!hasDirective(ast.body, 'use client')) { + delete manager.clientReferenceMetaMap[id] + return + } + + let importId: string + let referenceKey: string + const packageSource = packageSources.get(id) + if ( + !packageSource && + this.environment.mode === 'dev' && + id.includes('/node_modules/') + ) { + // If non package source reached here (often with ?v=... query), this is a client boundary + // created by a package imported on server environment, which breaks the + // expectation on dependency optimizer on browser. Directly copying over + // "?v=" from client optimizer in client reference can make a hashed + // module stale, so we use another virtual module wrapper to delay such process. + debug( + `internal client reference created through a package imported in '${this.environment.name}' environment: ${id}`, + ) + id = cleanUrl(id) + warnInoncistentClientOptimization(this, id) + importId = `/@id/__x00__virtual:vite-rsc/client-in-server-package-proxy/${encodeURIComponent(id)}` + referenceKey = importId + } else if (packageSource) { + if (this.environment.mode === 'dev') { + importId = `/@id/__x00__virtual:vite-rsc/client-package-proxy/${packageSource}` + referenceKey = importId + } else { + importId = packageSource + referenceKey = hashString(packageSource) + } + } else { + if (this.environment.mode === 'dev') { + importId = normalizeViteImportAnalysisUrl( + manager.server.environments[browserEnvironmentName]!, + id, + ) + referenceKey = importId + } else { + importId = id + referenceKey = hashString(manager.toRelativeId(id)) + } + } + + const transformDirectiveProxyExport_ = withRollupError( + this, + transformDirectiveProxyExport, + ) + const result = transformDirectiveProxyExport_(ast, { + directive: 'use client', + code, + keep: !!useClientPluginOptions.keepUseCientProxy, + runtime: (name, meta) => { + let proxyValue = + `() => { throw new Error("Unexpectedly client reference export '" + ` + + JSON.stringify(name) + + ` + "' is called on server") }` + if (meta?.value) { + proxyValue = `(${meta.value})` + } + return ( + `$$ReactServer.registerClientReference(` + + ` ${proxyValue},` + + ` ${JSON.stringify(referenceKey)},` + + ` ${JSON.stringify(name)})` + ) + }, + }) + if (!result) return + const { output, exportNames } = result + manager.clientReferenceMetaMap[id] = { + importId, + referenceKey, + packageSource, + exportNames, + renderedExports: [], + } + const importSource = resolvePackage(`${PKG_NAME}/react/rsc`) + output.prepend(`import * as $$ReactServer from "${importSource}";\n`) + return { code: output.toString(), map: { mappings: '' } } + }, + }, + { + name: 'rsc:use-client/build-references', + resolveId(source) { + if (source.startsWith('virtual:vite-rsc/client-references')) { + return '\0' + source + } + }, + load(id) { + if (id === '\0virtual:vite-rsc/client-references') { + // not used during dev + if (this.environment.mode === 'dev') { + return { code: `export default {}`, map: null } + } + // no custom chunking needed for scan + if (manager.isScanBuild) { + let code = `` + for (const meta of Object.values(manager.clientReferenceMetaMap)) { + code += `import ${JSON.stringify(meta.importId)};\n` + } + return { code, map: null } + } + let code = '' + // group client reference modules by `clientChunks` option + manager.clientReferenceGroups = {} + for (const meta of Object.values(manager.clientReferenceMetaMap)) { + // no server chunk is associated when the entire "use client" module is tree-shaken + if (!meta.serverChunk) continue + let name = + useClientPluginOptions.clientChunks?.({ + id: meta.importId, + normalizedId: manager.toRelativeId(meta.importId), + serverChunk: meta.serverChunk, + }) ?? meta.serverChunk + // ensure clean virtual id to avoid interfering with other plugins + name = cleanUrl(name.replaceAll('..', '__')) + const group = (manager.clientReferenceGroups[name] ??= []) + group.push(meta) + meta.groupChunkId = `\0virtual:vite-rsc/client-references/group/${name}` + } + debug('client-reference-groups', manager.clientReferenceGroups) + for (const [name, metas] of Object.entries( + manager.clientReferenceGroups, + )) { + const groupVirtual = `virtual:vite-rsc/client-references/group/${name}` + for (const meta of metas) { + code += `\ + ${JSON.stringify(meta.referenceKey)}: async () => { + const m = await import(${JSON.stringify(groupVirtual)}); + return m.export_${meta.referenceKey}; + }, + ` + } + } + code = `export default {${code}};\n` + return { code, map: null } + } + // re-export client reference modules from each group + if (id.startsWith('\0virtual:vite-rsc/client-references/group/')) { + const name = id.slice( + '\0virtual:vite-rsc/client-references/group/'.length, + ) + const metas = manager.clientReferenceGroups[name] + assert(metas, `unknown client reference group: ${name}`) + let code = `` + for (const meta of metas) { + // pick only renderedExports to tree-shake unused client references + const exports = meta.renderedExports + .map((name) => `${name}: import_${meta.referenceKey}.${name},\n`) + .sort() + .join('') + code += ` + import * as import_${meta.referenceKey} from ${JSON.stringify(meta.importId)}; + export const export_${meta.referenceKey} = {${exports}}; + ` + } + return { code, map: null } + } + }, + }, + { + name: 'rsc:virtual-client-in-server-package', + async load(id) { + if ( + id.startsWith('\0virtual:vite-rsc/client-in-server-package-proxy/') + ) { + assert.equal(this.environment.mode, 'dev') + assert(this.environment.name !== serverEnvironmentName) + id = decodeURIComponent( + id.slice( + '\0virtual:vite-rsc/client-in-server-package-proxy/'.length, + ), + ) + // TODO: avoid `export default undefined` + return ` + export * from ${JSON.stringify(id)}; + import * as __all__ from ${JSON.stringify(id)}; + export default __all__.default; + ` + } + }, + }, + { + name: 'rsc:virtual-client-package', + resolveId: { + order: 'pre', + async handler(source, importer, options) { + if ( + this.environment.name === serverEnvironmentName && + bareImportRE.test(source) + ) { + const resolved = await this.resolve(source, importer, options) + if (resolved && resolved.id.includes('/node_modules/')) { + packageSources.set(resolved.id, source) + return resolved + } + } + }, + }, + async load(id) { + if (id.startsWith('\0virtual:vite-rsc/client-package-proxy/')) { + assert(this.environment.mode === 'dev') + const source = id.slice( + '\0virtual:vite-rsc/client-package-proxy/'.length, + ) + const meta = Object.values(manager.clientReferenceMetaMap).find( + (v) => v.packageSource === source, + )! + const exportNames = meta.exportNames + return `export {${exportNames.join(',')}} from ${JSON.stringify( + source, + )};\n` + } + }, + generateBundle(_options, bundle) { + if (manager.isScanBuild) return + if (this.environment.name !== serverEnvironmentName) return + + // analyze rsc build to inform later client reference building. + // - track used client reference exports to tree-shake unused ones + // - generate associated server chunk name by grouping client references + + for (const chunk of Object.values(bundle)) { + if (chunk.type === 'chunk') { + const metas: [string, ClientReferenceMeta][] = [] + for (const id of chunk.moduleIds) { + const meta = manager.clientReferenceMetaMap[id] + if (meta) { + metas.push([id, meta]) + } + } + if (metas.length > 0) { + // this name is used for client reference group virtual chunk name, + // which should have a stable and understandle name. + let serverChunk: string + if (chunk.facadeModuleId) { + serverChunk = + 'facade:' + manager.toRelativeId(chunk.facadeModuleId) + } else { + serverChunk = + 'shared:' + + manager.toRelativeId(metas.map(([id]) => id).sort()[0]!) + } + for (const [id, meta] of metas) { + const mod = chunk.modules[id] + assert(mod) + meta.renderedExports = mod.renderedExports + meta.serverChunk = serverChunk + } + } + } + } + }, + }, + ...customOptimizerMetadataPlugin({ + setMetadata: (metadata) => { + optimizerMetadata = metadata + }, + }), + ] +} + +type CustomOptimizerMetadata = { + ids: string[] +} + +function customOptimizerMetadataPlugin({ + setMetadata, +}: { + setMetadata: (metadata: CustomOptimizerMetadata) => void +}): Plugin[] { + const MEATADATA_FILE = '_metadata-rsc.json' + + type EsbuildPlugin = NonNullable< + NonNullable['plugins'] + >[number] + + function optimizerPluginEsbuild(): EsbuildPlugin { + return { + name: 'vite-rsc-metafile', + setup(build) { + build.onEnd((result) => { + // skip scan + if (!result.metafile?.inputs || !build.initialOptions.outdir) return + + const ids = Object.keys(result.metafile.inputs) + const metadata: CustomOptimizerMetadata = { ids } + setMetadata(metadata) + fs.writeFileSync( + path.join(build.initialOptions.outdir, MEATADATA_FILE), + JSON.stringify(metadata, null, 2), + ) + }) + }, + } + } + + function optimizerPluginRolldown(): Rollup.Plugin { + return { + name: 'vite-rsc-metafile', + writeBundle(options) { + assert(options.dir) + const ids = [...this.getModuleIds()].map((id) => + path.relative(process.cwd(), id), + ) + const metadata: CustomOptimizerMetadata = { ids } + setMetadata(metadata) + fs.writeFileSync( + path.join(options.dir!, MEATADATA_FILE), + JSON.stringify(metadata, null, 2), + ) + }, + } + } + + return [ + { + name: 'rsc:use-client:optimizer-metadata', + apply: 'serve', + config() { + return { + environments: { + client: { + optimizeDeps: + 'rolldownVersion' in vite + ? ({ + rolldownOptions: { + plugins: [optimizerPluginRolldown()], + }, + } as any) + : { + esbuildOptions: { + plugins: [optimizerPluginEsbuild()], + }, + }, + }, + }, + } + }, + configResolved(config) { + // https://github.com/vitejs/vite/blob/84079a84ad94de4c1ef4f1bdb2ab448ff2c01196/packages/vite/src/node/optimizer/index.ts#L941 + const file = path.join(config.cacheDir, 'deps', MEATADATA_FILE) + if (fs.existsSync(file)) { + try { + const metadata = JSON.parse(fs.readFileSync(file, 'utf-8')) + setMetadata(metadata) + } catch (e) { + this.warn(`failed to load '${file}'`) + } + } + }, + }, + ] +} + +function vitePluginDefineEncryptionKey( + useServerPluginOptions: Pick< + RscPluginOptions, + 'defineEncryptionKey' | 'environment' + >, +): Plugin[] { + let defineEncryptionKey: string + let emitEncryptionKey = false + const KEY_PLACEHOLDER = '__vite_rsc_define_encryption_key' + const KEY_FILE = '__vite_rsc_encryption_key.js' + + const serverEnvironmentName = useServerPluginOptions.environment?.rsc ?? 'rsc' + + return [ + { + name: 'rsc:encryption-key', + async configEnvironment(name, _config, env) { + if (name === serverEnvironmentName && !env.isPreview) { + defineEncryptionKey = + useServerPluginOptions.defineEncryptionKey ?? + JSON.stringify(toBase64(await generateEncryptionKey())) + } + }, + resolveId(source) { + if (source === 'virtual:vite-rsc/encryption-key') { + // encryption logic can be tree-shaken if action bind is not used. + return { id: '\0' + source, moduleSideEffects: false } + } + }, + load(id) { + if (id === '\0virtual:vite-rsc/encryption-key') { + if (this.environment.mode === 'build') { + // during build, load key from an external file to make chunks stable. + return `export default () => ${KEY_PLACEHOLDER}` + } + return `export default () => (${defineEncryptionKey})` + } + }, + renderChunk(code, chunk) { + if (code.includes(KEY_PLACEHOLDER)) { + assert.equal(this.environment.name, serverEnvironmentName) + emitEncryptionKey = true + const normalizedPath = normalizeRelativePath( + path.relative(path.join(chunk.fileName, '..'), KEY_FILE), + ) + const replacement = `import(${JSON.stringify( + normalizedPath, + )}).then(__m => __m.default)` + code = code.replaceAll(KEY_PLACEHOLDER, () => replacement) + return { code } + } + }, + writeBundle() { + if ( + this.environment.name === serverEnvironmentName && + emitEncryptionKey + ) { + fs.writeFileSync( + path.join(this.environment.config.build.outDir, KEY_FILE), + `export default ${defineEncryptionKey};\n`, + ) + } + }, + }, + ] +} + +function vitePluginUseServer( + useServerPluginOptions: Pick< + RscPluginOptions, + 'enableActionEncryption' | 'environment' + >, + manager: RscPluginManager, +): Plugin[] { + const serverEnvironmentName = useServerPluginOptions.environment?.rsc ?? 'rsc' + const browserEnvironmentName = + useServerPluginOptions.environment?.browser ?? 'client' + + const debug = createDebug('vite-rsc:use-server') + + return [ + { + name: 'rsc:use-server', + async transform(code, id) { + if (!code.includes('use server')) { + delete manager.serverReferenceMetaMap[id] + return + } + const ast = await parseAstAsync(code) + + let normalizedId_: string | undefined + const getNormalizedId = () => { + if (!normalizedId_) { + if ( + this.environment.mode === 'dev' && + id.includes('/node_modules/') + ) { + // similar situation as `use client` (see `virtual:client-in-server-package-proxy`) + // but module runner has additional resolution step and it's not strict about + // module identity of `import(id)` like browser, so we simply strip queries such as `?v=`. + debug( + `internal server reference created through a package imported in ${this.environment.name} environment: ${id}`, + ) + id = cleanUrl(id) + } + if (manager.config.command === 'build') { + normalizedId_ = hashString(manager.toRelativeId(id)) + } else { + normalizedId_ = normalizeViteImportAnalysisUrl( + manager.server.environments[serverEnvironmentName]!, + id, + ) + } + } + return normalizedId_ + } + + if (this.environment.name === serverEnvironmentName) { + const transformServerActionServer_ = withRollupError( + this, + transformServerActionServer, + ) + const enableEncryption = + useServerPluginOptions.enableActionEncryption ?? true + const result = transformServerActionServer_(code, ast, { + runtime: (value, name) => + `$$ReactServer.registerServerReference(${value}, ${JSON.stringify( + getNormalizedId(), + )}, ${JSON.stringify(name)})`, + rejectNonAsyncFunction: true, + encode: enableEncryption + ? (value) => + `__vite_rsc_encryption_runtime.encryptActionBoundArgs(${value})` + : undefined, + decode: enableEncryption + ? (value) => + `await __vite_rsc_encryption_runtime.decryptActionBoundArgs(${value})` + : undefined, + }) + const output = result.output + if (!result || !output.hasChanged()) { + delete manager.serverReferenceMetaMap[id] + return + } + manager.serverReferenceMetaMap[id] = { + importId: id, + referenceKey: getNormalizedId(), + exportNames: 'names' in result ? result.names : result.exportNames, + } + const importSource = resolvePackage(`${PKG_NAME}/react/rsc`) + output.prepend(`import * as $$ReactServer from "${importSource}";\n`) + if (enableEncryption) { + const importSource = resolvePackage( + `${PKG_NAME}/utils/encryption-runtime`, + ) + output.prepend( + `import * as __vite_rsc_encryption_runtime from ${JSON.stringify(importSource)};\n`, + ) + } + return { + code: output.toString(), + map: output.generateMap({ hires: 'boundary' }), + } + } else { + if (!hasDirective(ast.body, 'use server')) { + delete manager.serverReferenceMetaMap[id] + return + } + const transformDirectiveProxyExport_ = withRollupError( + this, + transformDirectiveProxyExport, + ) + const result = transformDirectiveProxyExport_(ast, { + code, + runtime: (name) => + `$$ReactClient.createServerReference(` + + `${JSON.stringify(getNormalizedId() + '#' + name)},` + + `$$ReactClient.callServer, ` + + `undefined, ` + + (this.environment.mode === 'dev' + ? `$$ReactClient.findSourceMapURL,` + : 'undefined,') + + `${JSON.stringify(name)})`, + directive: 'use server', + rejectNonAsyncFunction: true, + }) + if (!result) return + const output = result?.output + if (!output?.hasChanged()) return + manager.serverReferenceMetaMap[id] = { + importId: id, + referenceKey: getNormalizedId(), + exportNames: result.exportNames, + } + const name = + this.environment.name === browserEnvironmentName ? 'browser' : 'ssr' + const importSource = resolvePackage(`${PKG_NAME}/react/${name}`) + output.prepend(`import * as $$ReactClient from "${importSource}";\n`) + return { + code: output.toString(), + map: output.generateMap({ hires: 'boundary' }), + } + } + }, + }, + createVirtualPlugin('vite-rsc/server-references', function () { + if (this.environment.mode === 'dev') { + return { code: `export {}`, map: null } + } + let code = '' + for (const meta of Object.values(manager.serverReferenceMetaMap)) { + const key = JSON.stringify(meta.referenceKey) + const id = JSON.stringify(meta.importId) + const exports = meta.exportNames + .map((name) => (name === 'default' ? 'default: _default' : name)) + .sort() + code += ` + ${key}: async () => { + const {${exports}} = await import(${id}); + return {${exports}}; + }, +` + } + code = `export default {${code}};\n` + return { code, map: null } + }), + ] +} + +class RuntimeAsset { + runtime: string + constructor(value: string) { + this.runtime = value + } +} + +function serializeValueWithRuntime(value: any) { + const replacements: [string, string][] = [] + let result = JSON.stringify( + value, + (_key, value) => { + if (value instanceof RuntimeAsset) { + const placeholder = `__runtime_placeholder_${replacements.length}__` + replacements.push([placeholder, value.runtime]) + return placeholder + } + + return value + }, + 2, + ) + + for (const [placeholder, runtime] of replacements) { + result = result.replace(`"${placeholder}"`, runtime) + } + + return result +} + +function assetsURL(url: string, manager: RscPluginManager) { + const { config } = manager + if ( + config.command === 'build' && + typeof config.experimental?.renderBuiltUrl === 'function' + ) { + // https://github.com/vitejs/vite/blob/bdde0f9e5077ca1a21a04eefc30abad055047226/packages/vite/src/node/build.ts#L1369 + const result = config.experimental.renderBuiltUrl(url, { + type: 'asset', + hostType: 'js', + ssr: true, + hostId: '', + }) + + if (typeof result === 'object') { + if (result.runtime) { + return new RuntimeAsset(result.runtime) + } + assert( + !result.relative, + '"result.relative" not supported on renderBuiltUrl() for RSC', + ) + } else if (result) { + return result satisfies string + } + } + + // https://github.com/vitejs/vite/blob/2a7473cfed96237711cda9f736465c84d442ddef/packages/vite/src/node/plugins/importAnalysisBuild.ts#L222-L230 + return config.base + url +} + +function assetsURLOfDeps(deps: AssetDeps, manager: RscPluginManager) { + return { + js: deps.js.map((href) => { + assert(typeof href === 'string') + return assetsURL(href, manager) + }), + css: deps.css.map((href) => { + assert(typeof href === 'string') + return assetsURL(href, manager) + }), + } +} + +// +// collect client reference dependency chunk for modulepreload +// + +export type AssetsManifest = { + bootstrapScriptContent: string | RuntimeAsset + clientReferenceDeps: Record + serverResources?: Record> +} + +export type AssetDeps = { + js: (string | RuntimeAsset)[] + css: (string | RuntimeAsset)[] +} + +export type ResolvedAssetsManifest = { + bootstrapScriptContent: string + clientReferenceDeps: Record + serverResources?: Record> +} + +export type ResolvedAssetDeps = { + js: string[] + css: string[] +} + +function mergeAssetDeps(a: AssetDeps, b: AssetDeps): AssetDeps { + return { + js: [...new Set([...a.js, ...b.js])], + css: [...new Set([...a.css, ...b.css])], + } +} + +function collectAssetDeps(bundle: Rollup.OutputBundle) { + const chunkToDeps = new Map() + for (const chunk of Object.values(bundle)) { + if (chunk.type === 'chunk') { + chunkToDeps.set(chunk, collectAssetDepsInner(chunk.fileName, bundle)) + } + } + const idToDeps: Record< + string, + { chunk: Rollup.OutputChunk; deps: ResolvedAssetDeps } + > = {} + for (const [chunk, deps] of chunkToDeps.entries()) { + for (const id of chunk.moduleIds) { + idToDeps[id] = { chunk, deps } + } + } + return idToDeps +} + +function collectAssetDepsInner( + fileName: string, + bundle: Rollup.OutputBundle, +): ResolvedAssetDeps { + const visited = new Set() + const css: string[] = [] + + function recurse(k: string) { + if (visited.has(k)) return + visited.add(k) + const v = bundle[k] + assert(v, `Not found '${k}' in the bundle`) + if (v.type === 'chunk') { + css.push(...(v.viteMetadata?.importedCss ?? [])) + for (const k2 of v.imports) { + // server external imports is not in bundle + if (k2 in bundle) { + recurse(k2) + } + } + } + } + + recurse(fileName) + return { + js: [...visited], + css: [...new Set(css)], + } +} + +// +// css support +// + +function vitePluginRscCss( + rscCssOptions: Pick = {}, + manager: RscPluginManager, +): Plugin[] { + function hasSpecialCssQuery(id: string): boolean { + return /[?&](url|inline|raw)(\b|=|&|$)/.test(id) + } + + function collectCss(environment: DevEnvironment, entryId: string) { + const visited = new Set() + const cssIds = new Set() + const visitedFiles = new Set() + + function recurse(id: string) { + if (visited.has(id)) { + return + } + visited.add(id) + const mod = environment.moduleGraph.getModuleById(id) + if (mod?.file) { + visitedFiles.add(mod.file) + } + for (const next of mod?.importedModules ?? []) { + if (next.id) { + if (isCSSRequest(next.id)) { + if (hasSpecialCssQuery(next.id)) { + continue + } + cssIds.add(next.id) + } else { + recurse(next.id) + } + } + } + } + + recurse(entryId) + + // this doesn't include ?t= query so that RSC won't keep adding styles. + const hrefs = [...cssIds].map((id) => + normalizeViteImportAnalysisUrl(environment, id), + ) + return { ids: [...cssIds], hrefs, visitedFiles: [...visitedFiles] } + } + + function getRscCssTransformFilter({ + id, + code, + }: { + id: string + code: string + }): false | TransformWrapExportFilter { + const { filename, query } = parseIdQuery(id) + if ('vite-rsc-css-export' in query) { + const value = query['vite-rsc-css-export'] + if (value) { + const names = value.split(',') + return (name: string) => names.includes(name) + } + return (name: string) => /^[A-Z]/.test(name) + } + + const options = rscCssOptions?.rscCssTransform + if (options === false) return false + if (options?.filter && !options.filter(filename)) return false + // https://github.com/vitejs/vite/blob/7979f9da555aa16bd221b32ea78ce8cb5292fac4/packages/vite/src/node/constants.ts#L95 + if ( + !/\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)\b/.test(code) || + !/\.[tj]sx?$/.test(filename) + ) + return false + + // skip transform if no css imports + const result = esModuleLexer.parse(code) + if (!result[0].some((i) => i.t === 1 && i.n && isCSSRequest(i.n))) { + return false + } + // transform only function exports with capital names, e.g. + // export default function Page() {} + // export function Page() {} + // export const Page = () => {} + return (_name: string, meta) => + !!( + (meta.isFunction && meta.declName && /^[A-Z]/.test(meta.declName)) || + (meta.defaultExportIdentifierName && + /^[A-Z]/.test(meta.defaultExportIdentifierName)) + ) + } + + return [ + { + name: 'rsc:rsc-css-export-transform', + async transform(code, id) { + if (this.environment.name !== 'rsc') return + const filter = getRscCssTransformFilter({ id, code }) + if (!filter) return + const ast = await parseAstAsync(code) + const result = await transformRscCssExport({ + ast, + code, + filter, + }) + if (result) { + return { + code: result.output.toString(), + map: result.output.generateMap({ hires: 'boundary' }), + } + } + }, + }, + { + name: 'rsc:css-virtual', + resolveId(source) { + if (source.startsWith('virtual:vite-rsc/css?')) { + return '\0' + source + } + }, + async load(id) { + const parsed = parseCssVirtual(id) + if (parsed?.type === 'ssr') { + id = parsed.id + const { server } = manager + const mod = + await server.environments.ssr.moduleGraph.getModuleByUrl(id) + if (!mod?.id || !mod?.file) { + return `export default []` + } + const result = collectCss(server.environments.ssr, mod.id) + // invalidate virtual module on js file changes to reflect added/deleted css import + for (const file of [mod.file, ...result.visitedFiles]) { + this.addWatchFile(file) + } + const hrefs = result.hrefs.map((href) => + assetsURL(href.slice(1), manager), + ) + return `export default ${serializeValueWithRuntime(hrefs)}` + } + }, + }, + { + name: 'rsc:importer-resources', + async transform(code, id) { + if (!code.includes('import.meta.viteRsc.loadCss')) return + + assert(this.environment.name === 'rsc') + const output = new MagicString(code) + let importAdded = false + + for (const match of code.matchAll( + /import\.meta\.viteRsc\.loadCss\(([\s\S]*?)\)/dg, + )) { + const [start, end] = match.indices![0]! + const argCode = match[1]!.trim() + let importer = id + if (argCode) { + const argValue = evalValue(argCode) + const resolved = await this.resolve(argValue, id) + if (resolved) { + importer = resolved.id + } else { + this.warn( + `[vite-rsc] failed to transform 'import.meta.viteRsc.loadCss(${argCode})'`, + ) + output.update(start, end, `null`) + continue + } + } + + const importId = toCssVirtual({ id: importer, type: 'rsc' }) + + // use dynamic import during dev to delay crawling and discover css correctly. + let replacement: string + if (this.environment.mode === 'dev') { + replacement = `__vite_rsc_react__.createElement(async () => { + const __m = await import(${JSON.stringify(importId)}); + return __vite_rsc_react__.createElement(__m.Resources); + })` + } else { + const hash = hashString(importId) + if ( + !importAdded && + !code.includes(`__vite_rsc_importer_resources_${hash}`) + ) { + importAdded = true + output.prepend( + `import * as __vite_rsc_importer_resources_${hash} from ${JSON.stringify( + importId, + )};`, + ) + } + replacement = `__vite_rsc_react__.createElement(__vite_rsc_importer_resources_${hash}.Resources)` + } + output.update(start, end, replacement) + } + + if (output.hasChanged()) { + if (!code.includes('__vite_rsc_react__')) { + output.prepend(`import __vite_rsc_react__ from "react";`) + } + return { + code: output.toString(), + map: output.generateMap({ hires: 'boundary' }), + } + } + }, + load(id) { + const { server } = manager + const parsed = parseCssVirtual(id) + if (parsed?.type === 'rsc') { + assert(this.environment.name === 'rsc') + const importer = parsed.id + if (this.environment.mode === 'dev') { + const result = collectCss(server.environments.rsc!, importer) + const cssHrefs = result.hrefs.map((href) => href.slice(1)) + const jsHrefs = [ + `@id/__x00__${toCssVirtual({ id: importer, type: 'rsc-browser' })}`, + ] + const deps = assetsURLOfDeps( + { css: cssHrefs, js: jsHrefs }, + manager, + ) + return generateResourcesCode( + serializeValueWithRuntime(deps), + manager, + ) + } else { + const key = manager.toRelativeId(importer) + manager.serverResourcesMetaMap[importer] = { key } + return ` + import __vite_rsc_assets_manifest__ from "virtual:vite-rsc/assets-manifest"; + ${generateResourcesCode( + `__vite_rsc_assets_manifest__.serverResources[${JSON.stringify( + key, + )}]`, + manager, + )} + ` + } + } + if (parsed?.type === 'rsc-browser') { + assert(this.environment.name === 'client') + assert(this.environment.mode === 'dev') + const importer = parsed.id + const result = collectCss(server.environments.rsc!, importer) + let code = result.ids + .map((id) => id.replace(/^\0/, '')) + .map((id) => `import ${JSON.stringify(id)};\n`) + .join('') + // ensure hmr boundary at this virtual since otherwise non-self accepting css + // (e.g. css module) causes full reload + code += `if (import.meta.hot) { import.meta.hot.accept() }\n` + return code + } + }, + hotUpdate(ctx) { + if (this.environment.name === 'rsc') { + const { server } = manager + const mods = collectModuleDependents(ctx.modules) + for (const mod of mods) { + if (mod.id) { + invalidteModuleById( + server.environments.rsc!, + `\0` + toCssVirtual({ id: mod.id, type: 'rsc' }), + ) + invalidteModuleById( + server.environments.client, + `\0` + toCssVirtual({ id: mod.id, type: 'rsc-browser' }), + ) + } + } + } + }, + }, + createVirtualPlugin( + 'vite-rsc/remove-duplicate-server-css', + async function () { + // Remove duplicate css during dev due to server rendered and client inline