diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 297419684..000000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -dist -playground-temp -temp diff --git a/.eslintrc.cjs b/.eslintrc.cjs deleted file mode 100644 index c3393c32b..000000000 --- a/.eslintrc.cjs +++ /dev/null @@ -1,176 +0,0 @@ -// @ts-check -const { builtinModules } = require('node:module') -const { defineConfig } = require('eslint-define-config') - -/// -/// - -module.exports = defineConfig({ - root: true, - extends: [ - 'eslint:recommended', - 'plugin:n/recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:regexp/recommended', - ], - plugins: ['import', 'regexp'], - parser: '@typescript-eslint/parser', - parserOptions: { - sourceType: 'module', - ecmaVersion: 2021, - }, - rules: { - eqeqeq: ['warn', 'always', { null: 'never' }], - 'no-debugger': ['error'], - 'no-empty': ['warn', { allowEmptyCatch: true }], - 'no-useless-escape': 'off', - 'prefer-const': [ - 'warn', - { - destructuring: 'all', - }, - ], - - 'n/no-process-exit': 'off', - 'n/no-missing-import': [ - 'error', - { - allowModules: ['types', 'estree', 'less', 'sass', 'stylus'], - tryExtensions: ['.ts', '.js', '.jsx', '.tsx', '.d.ts'], - }, - ], - 'n/no-missing-require': [ - 'error', - { - // for try-catching yarn pnp - allowModules: ['pnpapi', 'vite'], - tryExtensions: ['.ts', '.js', '.jsx', '.tsx', '.d.ts'], - }, - ], - 'n/no-extraneous-import': [ - 'error', - { - allowModules: ['vite', 'less', 'sass', 'vitest'], - }, - ], - 'n/no-extraneous-require': [ - 'error', - { - allowModules: ['vite'], - }, - ], - 'n/no-deprecated-api': 'off', - 'n/no-unpublished-import': 'off', - 'n/no-unpublished-require': 'off', - 'n/no-unsupported-features/es-syntax': 'off', - - '@typescript-eslint/ban-ts-comment': 'off', // TODO: we should turn this on in a new PR - '@typescript-eslint/ban-types': 'off', // TODO: we should turn this on in a new PR - '@typescript-eslint/explicit-module-boundary-types': [ - 'error', - { allowArgumentsExplicitlyTypedAsAny: true }, - ], - '@typescript-eslint/no-empty-function': [ - 'error', - { allow: ['arrowFunctions'] }, - ], - '@typescript-eslint/no-empty-interface': 'off', - '@typescript-eslint/no-explicit-any': 'off', // maybe we should turn this on in a new PR - '@typescript-eslint/no-extra-semi': 'off', // conflicts with prettier - '@typescript-eslint/no-inferrable-types': 'off', - '@typescript-eslint/no-non-null-assertion': 'off', // maybe we should turn this on in a new PR - '@typescript-eslint/no-unused-vars': 'off', // maybe we should turn this on in a new PR - '@typescript-eslint/no-var-requires': 'off', - '@typescript-eslint/consistent-type-imports': [ - 'error', - { prefer: 'type-imports' }, - ], - - 'import/no-nodejs-modules': [ - 'error', - { allow: builtinModules.map((mod) => `node:${mod}`) }, - ], - 'import/no-duplicates': 'error', - 'import/order': 'error', - 'sort-imports': [ - 'error', - { - ignoreCase: false, - ignoreDeclarationSort: true, - ignoreMemberSort: false, - memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], - allowSeparatedGroups: false, - }, - ], - - 'regexp/no-contradiction-with-assertion': 'error', - }, - overrides: [ - { - files: ['packages/**'], - excludedFiles: '**/__tests__/**', - rules: { - 'no-restricted-globals': [ - 'error', - 'require', - '__dirname', - '__filename', - ], - }, - }, - { - files: ['**/build.config.ts'], - rules: { - 'no-undef': 'off', - 'n/no-missing-import': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - }, - }, - { - files: ['playground/**'], - rules: { - 'n/no-extraneous-import': 'off', - 'n/no-extraneous-require': 'off', - 'n/no-missing-import': 'off', - 'n/no-missing-require': 'off', - // engine field doesn't exist in playgrounds - 'n/no-unsupported-features/es-builtins': [ - 'error', - { - version: '^14.18.0 || >=16.0.0', - }, - ], - 'n/no-unsupported-features/node-builtins': [ - 'error', - { - version: '^14.18.0 || >=16.0.0', - }, - ], - '@typescript-eslint/explicit-module-boundary-types': 'off', - }, - }, - { - files: ['playground/**'], - excludedFiles: '**/__tests__/**', - rules: { - 'no-undef': 'off', - 'no-empty': 'off', - 'no-constant-condition': 'off', - '@typescript-eslint/no-empty-function': 'off', - }, - }, - { - files: ['*.js', '*.mjs', '*.cjs'], - rules: { - '@typescript-eslint/explicit-module-boundary-types': 'off', - }, - }, - { - files: ['*.d.ts'], - rules: { - '@typescript-eslint/triple-slash-reference': 'off', - }, - }, - ], - reportUnusedDisableDirectives: true, -}) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ff3ba7917..b2bf121e9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,11 +1,26 @@ name: "\U0001F41E Bug report" description: Report an issue labels: [pending triage] +type: Bug body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! + - type: checkboxes + id: plugins + attributes: + label: Related plugins + description: Select the plugin which is related + options: + - label: | + [plugin-react](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react) + - label: | + [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: @@ -32,7 +47,7 @@ body: id: system-info attributes: label: System Info - description: Output of `npx envinfo --system --npmPackages '{vite,@vitejs/*}' --binaries --browsers` + description: Output of `npx envinfo --system --npmPackages '{vite,@vitejs/*,rollup}' --binaries --browsers` render: shell placeholder: System, Binaries, Browsers validations: @@ -46,6 +61,7 @@ body: - npm - yarn - pnpm + - bun validations: required: true - type: textarea @@ -77,13 +93,13 @@ body: required: true - label: Read the [Contributing Guidelines](https://github.com/vitejs/vite-plugin-react/blob/main/CONTRIBUTING.md). required: true - - label: Read the [docs](https://vitejs.dev/guide). + - label: Read the [docs](https://vite.dev/guide). required: true - label: Check that there isn't [already an issue](https://github.com/vitejs/vite-plugin-react/issues) that reports the same bug to avoid creating a duplicate. required: true - label: Make sure this is a Vite issue and not a framework-specific issue. required: true - - label: Check that this is a concrete bug. For Q&A open a [GitHub Discussion](https://github.com/vitejs/vite-plugin-react/discussions) or join our [Discord Chat Server](https://chat.vitejs.dev/). + - label: Check that this is a concrete bug. For Q&A open a [GitHub Discussion](https://github.com/vitejs/vite-plugin-react/discussions) or join our [Discord Chat Server](https://chat.vite.dev/). required: true - label: The provided reproduction is a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) of the bug. required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index eeb187ace..06ab1320d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: false contact_links: - name: Discord Chat - url: https://chat.vitejs.dev + url: https://chat.vite.dev about: Ask questions and discuss with other Vite users in real time. - name: Questions & Discussions url: https://github.com/vitejs/vite-plugin-react/discussions diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 454ff144e..22ad9c813 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,11 +1,26 @@ name: "\U0001F680 New feature proposal" description: Propose a new feature -labels: ["enhancement: pending triage"] +labels: ["pending triage"] +type: Feature body: - type: markdown attributes: value: | Thanks for your interest in the project and taking the time to fill out this feature report! + - type: checkboxes + id: plugins + attributes: + label: Related plugins + description: Select the plugin which is related + options: + - label: | + [plugin-react](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react) + - label: | + [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: @@ -41,7 +56,7 @@ body: required: true - label: Read the [Contributing Guidelines](https://github.com/vitejs/vite-plugin-react/blob/main/CONTRIBUTING.md). required: true - - label: Read the [docs](https://vitejs.dev/guide). + - label: Read the [docs](https://vite.dev/guide). required: true - label: Check that there isn't already an issue that request the same feature to avoid creating a duplicate. required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 016ba42e3..b48e00747 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,26 +1,14 @@ - - ### Description - - -### Additional context - - - ---- - -### What is the purpose of this pull request? + -- [ ] Bug fix -- [ ] New Feature -- [ ] Documentation update -- [ ] Other + diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 23571e84f..d777dbf4a 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -1,19 +1,52 @@ { "$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": [ + "react-compiler-runtime", + "babel-plugin-react-compiler", + ], + "followTag": "latest", + }, + { + "matchDepTypes": ["action"], + "pinDigests": true, + "matchPackageNames": ["!actions/{/,}**", "!github/{/,}**"], + }, + { + "groupName": "react-related dependencies", + "matchPackageNames": [ + "react", + "react-dom", + "@types/react", + "@types/react-dom", + "react-refresh", + "react-server-dom-webpack", + "use-sync-external-store", + ], + }, ], "ignoreDeps": [ // manually bumping "node", - "pnpm", // breaking changes "source-map", // `source-map:v0.7.0+` needs more investigation diff --git a/.github/workflows/ci-rsc.yml b/.github/workflows/ci-rsc.yml new file mode 100644 index 000000000..509ad48ae --- /dev/null +++ b/.github/workflows/ci-rsc.yml @@ -0,0 +1,97 @@ +name: ci-rsc + +permissions: {} + +on: + workflow_dispatch: + push: + branches: + - main + pull_request: + paths: + - "packages/plugin-rsc/**" + - "pnpm-lock.yaml" + - ".github/workflows/ci-rsc.yml" + schedule: + # Run daily at 00:00 UTC to test canary/experimental React versions + - cron: "0 0 * * *" + +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: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - 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: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - run: pnpm i + - name: install react + if: ${{ matrix.react_version }} + run: | + sed -i "/^overrides:/a\ react: \"$REACT_VERSION\"" pnpm-workspace.yaml + sed -i "/^overrides:/a\ react-dom: \"$REACT_VERSION\"" pnpm-workspace.yaml + sed -i "/^overrides:/a\ react-server-dom-webpack: \"$REACT_VERSION\"" pnpm-workspace.yaml + pnpm i --no-frozen-lockfile + env: + REACT_VERSION: ${{ matrix.react_version }} + - 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 "$BROWSER_NAME" + shell: bash + env: + BROWSER_NAME: ${{ matrix.browser }} + - run: pnpm -C packages/plugin-rsc test-e2e-ci --project="$BROWSER_NAME" + shell: bash + env: + BROWSER_NAME: ${{ 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 d2fc84dd5..c28ac6f9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,8 @@ env: # Vitest auto retry on flaky segfault VITEST_SEGFAULT_RETRY: 3 +permissions: {} + on: push: branches: @@ -27,47 +29,52 @@ jobs: strategy: matrix: os: [ubuntu-latest] - node_version: [18, 20, 22] + node_version: [20, 22, 24] include: # Active LTS + other OS - os: macos-latest - node_version: 20 + node_version: 22 - os: windows-latest - node_version: 20 + node_version: 22 fail-fast: false 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@v4.0.0 + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - name: Set node version to ${{ matrix.node_version }} - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: ${{ matrix.node_version }} - cache: "pnpm" - name: Install deps run: pnpm install # Install playwright's binary under custom directory to cache - - name: Set Playwright path (non-windows) + - name: (non-windows) Set Playwright path and Get playwright version if: runner.os != 'Windows' - run: echo "PLAYWRIGHT_BROWSERS_PATH=$HOME/.cache/playwright-bin" >> $GITHUB_ENV - - name: Set Playwright path (windows) + run: | + echo "PLAYWRIGHT_BROWSERS_PATH=$HOME/.cache/playwright-bin" >> $GITHUB_ENV + PLAYWRIGHT_VERSION="$(pnpm ls --depth 0 --json -w playwright-chromium | jq --raw-output '.[0].devDependencies["playwright-chromium"].version')" + echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_ENV + - name: (windows) Set Playwright path and Get playwright version if: runner.os == 'Windows' - run: echo "PLAYWRIGHT_BROWSERS_PATH=$HOME\.cache\playwright-bin" >> $env:GITHUB_ENV + run: | + echo "PLAYWRIGHT_BROWSERS_PATH=$HOME\.cache\playwright-bin" >> $env:GITHUB_ENV + $env:PLAYWRIGHT_VERSION="$(pnpm ls --depth 0 --json -w playwright-chromium | jq --raw-output '.[0].devDependencies["playwright-chromium"].version')" + echo "PLAYWRIGHT_VERSION=$env:PLAYWRIGHT_VERSION" >> $env:GITHUB_ENV - name: Cache Playwright's binary uses: actions/cache@v4 with: - # Playwright removes unused browsers automatically - # So does not need to add playwright version to key - key: ${{ runner.os }}-playwright-bin-v1 + key: ${{ runner.os }}-playwright-bin-v1-${{ env.PLAYWRIGHT_VERSION }} path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + restore-keys: | + ${{ runner.os }}-playwright-bin-v1- - name: Install Playwright # does not need to explicitly set chromium after https://github.com/microsoft/playwright/issues/14862 is solved @@ -82,24 +89,37 @@ jobs: - 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 - name: Install pnpm - uses: pnpm/action-setup@v4.0.0 + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - name: Set node version to 20 - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: 20 - cache: "pnpm" - name: Install deps run: pnpm install diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 000000000..5be862ff6 --- /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: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - 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 2ab89e488..9a642ad7a 100644 --- a/.github/workflows/issue-close-require.yml +++ b/.github/workflows/issue-close-require.yml @@ -8,9 +8,12 @@ jobs: close-issues: if: github.repository == 'vitejs/vite-plugin-react' runs-on: ubuntu-latest + permissions: + issues: write # for actions-cool/issues-helper to update issues + pull-requests: write # for actions-cool/issues-helper to update PRs steps: - name: need reproduction - uses: actions-cool/issues-helper@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 1e5037eaf..2823e6395 100644 --- a/.github/workflows/issue-labeled.yml +++ b/.github/workflows/issue-labeled.yml @@ -8,10 +8,13 @@ jobs: reply-labeled: if: github.repository == 'vitejs/vite-plugin-react' runs-on: ubuntu-latest + permissions: + issues: write # for actions-cool/issues-helper to update issues + pull-requests: write # for actions-cool/issues-helper to update PRs steps: - name: contribution welcome if: github.event.label.name == 'contribution welcome' || github.event.label.name == 'help wanted' - uses: actions-cool/issues-helper@v3 + uses: actions-cool/issues-helper@45d75b6cf72bf4f254be6230cb887ad002702491 # v3 with: actions: "create-comment, remove-labels" token: ${{ secrets.GITHUB_TOKEN }} @@ -21,26 +24,17 @@ jobs: labels: "pending triage, need reproduction" - name: remove pending - if: contains(github.event.label.description, '(priority)') && contains(github.event.issue.labels.*.name, 'pending triage') - uses: actions-cool/issues-helper@v3 + 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@45d75b6cf72bf4f254be6230cb887ad002702491 # v3 with: actions: "remove-labels" token: ${{ secrets.GITHUB_TOKEN }} issue-number: ${{ github.event.issue.number }} labels: "pending triage" - - name: remove enhancement pending - if: "(github.event.label.name == 'enhancement' || contains(github.event.label.description, '(priority)')) && contains(github.event.issue.labels.*.name, 'enhancement: pending triage')" - uses: actions-cool/issues-helper@v3 - with: - actions: "remove-labels" - token: ${{ secrets.GITHUB_TOKEN }} - issue-number: ${{ github.event.issue.number }} - labels: "enhancement: pending triage" - - name: need reproduction if: github.event.label.name == 'need reproduction' - uses: actions-cool/issues-helper@v3 + uses: actions-cool/issues-helper@45d75b6cf72bf4f254be6230cb887ad002702491 # v3 with: actions: "create-comment, remove-labels" token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lock-closed-issues.yml b/.github/workflows/lock-closed-issues.yml index 117065d0d..b23bae42f 100644 --- a/.github/workflows/lock-closed-issues.yml +++ b/.github/workflows/lock-closed-issues.yml @@ -12,13 +12,13 @@ jobs: if: github.repository == 'vitejs/vite-plugin-react' runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v5 + - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5 with: github-token: ${{ secrets.GITHUB_TOKEN }} issue-inactive-days: "14" #issue-comment: | # This issue has been locked since it has been closed for more than 14 days. # - # If you have found a concrete bug or regression related to it, please open a new [bug report](https://github.com/vitejs/vite-plugin-react/issues/new/choose) with a reproduction against the latest Vite version. If you have any other comments you should join the chat at [Vite Land](https://chat.vitejs.dev) or create a new [discussion](https://github.com/vitejs/vite-plugin-react/discussions). + # If you have found a concrete bug or regression related to it, please open a new [bug report](https://github.com/vitejs/vite-plugin-react/issues/new/choose) with a reproduction against the latest Vite version. If you have any other comments you should join the chat at [Vite Land](https://chat.vite.dev) or create a new [discussion](https://github.com/vitejs/vite-plugin-react/discussions). issue-lock-reason: "" process-only: "issues" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 67e7c8f89..d5f03c746 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,31 +8,66 @@ on: jobs: publish: runs-on: ubuntu-latest + permissions: + contents: write # for ArnaudBarre/github-release to create a release + id-token: write # for provenance generation environment: Release steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install pnpm - uses: pnpm/action-setup@v4.0.0 + uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 - - name: Set node version to 20 - uses: actions/setup-node@v4 + - name: Set node version + uses: actions/setup-node@v5 with: - node-version: 20 + node-version: 22 registry-url: https://registry.npmjs.org/ - cache: "pnpm" + # disable cache, to avoid cache poisoning (https://docs.zizmor.sh/audits/#cache-poisoning) + package-manager-cache: false + + - name: Disallow installation scripts + run: yq '.onlyBuiltDependencies = []' -i pnpm-workspace.yaml - name: Install deps run: pnpm install env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1" + - name: Get pkgName for tag + id: tag + run: | + # Check if the tag contains "alpha" + if [[ $GITHUB_REF_NAME =~ alpha ]]; then + echo "isAlpha=true" >> $GITHUB_OUTPUT + else + echo "isAlpha=false" >> $GITHUB_OUTPUT + fi + + # `%@*` truncates @ and version number from the right side. + # https://stackoverflow.com/questions/9532654/expression-after-last-specific-character + pkgName=${GITHUB_REF_NAME%@*} + echo "pkgName=$pkgName" >> $GITHUB_OUTPUT + + - if: steps.tag.outputs.pkgName == 'plugin-react-swc' + working-directory: packages/plugin-react-swc + run: pnpm build + - name: Publish package - run: pnpm run ci-publish ${{ github.ref_name }} - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm i -g npm@^11.5.2 && pnpm run ci-publish ${{ github.ref_name }} + + - 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 }} - - uses: ArnaudBarre/github-release@v1 + - if: steps.tag.outputs.isAlpha == 'false' && steps.tag.outputs.pkgName == 'plugin-rsc' + uses: yyx990803/release-tag@8cccf7c5aa332d71d222df46677f70f77a8d2dc0 # v1.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - path: packages/plugin-react/CHANGELOG.md + 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..a56e50665 --- /dev/null +++ b/.github/workflows/release-continuous.yml @@ -0,0 +1,42 @@ +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@v5 + with: + node-version: lts/* + # disable cache, to avoid cache poisoning (https://docs.zizmor.sh/audits/#cache-poisoning) + package-manager-cache: false + + - name: Disallow installation scripts + run: yq '.onlyBuiltDependencies = []' -i pnpm-workspace.yaml + + - 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 ea0912db4..8fa3d2a8b 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -12,9 +12,11 @@ jobs: if: github.repository == 'vitejs/vite-plugin-react' runs-on: ubuntu-latest name: Semantic Pull Request + permissions: + pull-requests: read steps: - name: Validate PR title - uses: amannn/action-semantic-pull-request@v5 + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 with: subjectPattern: ^(?![A-Z]).+$ subjectPatternError: | diff --git a/.gitignore b/.gitignore index f7fc8786c..11b68d798 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ dist-ssr explorations node_modules playground-temp +packages/plugin-react-swc/playground-temp temp TODOs.md .eslintcache +test-results/ +.swc/ diff --git a/.npmrc b/.npmrc index e4b07fedc..80af6f76f 100644 --- a/.npmrc +++ b/.npmrc @@ -1,5 +1,9 @@ hoist-pattern[]=@emotion/* # playground/react-emotion hoist-pattern[]=*babel* +hoist-pattern[]=@swc/* # packages/plugin-react-swc/playground/emotion-plugin, packages/plugin-react-swc/playground/styled-components +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/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 8c0ecf5ab..e357d2b15 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -34,7 +34,7 @@ This Code of Conduct applies both within project spaces and in public spaces whe ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team by DM at [Vite Land](https://chat.vitejs.dev). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team by DM at [Vite Land](https://chat.vite.dev). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 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 e51192b80..21c911e9a 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,32 @@

- - Vite logo + + Vite logo


- npm package node compatibility build status - discord chat + discord chat


# Vite Plugin React -See [`@vitejs/plugin-react` documentation](packages/plugin-react/README.md) +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) | +| 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 new file mode 100644 index 000000000..1b2ff21dc --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,171 @@ +// @ts-check +import { builtinModules } from 'node:module' +import eslint from '@eslint/js' +import pluginN from 'eslint-plugin-n' +import pluginImportX from 'eslint-plugin-import-x' +import pluginRegExp from 'eslint-plugin-regexp' +import tseslint from 'typescript-eslint' +import globals from 'globals' + +export default tseslint.config( + { + ignores: [ + '**/dist/**', + '**/playground-temp/**', + '**/temp/**', + 'packages/plugin-rsc/**', + ], + }, + eslint.configs.recommended, + ...tseslint.configs.recommended, + ...tseslint.configs.stylistic, + pluginN.configs['flat/recommended'], + pluginRegExp.configs['flat/recommended'], + { + name: 'main', + languageOptions: { + parser: tseslint.parser, + parserOptions: { + sourceType: 'module', + ecmaVersion: 2021, + }, + globals: { + ...globals.es2021, + ...globals.node, + }, + }, + plugins: { + n: pluginN, + 'import-x': pluginImportX, + }, + rules: { + eqeqeq: ['warn', 'always', { null: 'never' }], + 'no-debugger': ['error'], + 'no-empty': ['warn', { allowEmptyCatch: true }], + 'prefer-const': [ + 'warn', + { + destructuring: 'all', + }, + ], + + 'n/no-process-exit': 'off', + 'n/no-deprecated-api': 'off', + 'n/no-unpublished-import': 'off', + 'n/no-unpublished-require': 'off', + 'n/no-unsupported-features/es-syntax': 'off', + 'n/no-missing-import': [ + 'error', + { + tryExtensions: ['.ts', '.js', '.jsx', '.tsx', '.d.ts'], + }, + ], + + '@typescript-eslint/explicit-module-boundary-types': [ + 'error', + { allowArgumentsExplicitlyTypedAsAny: true }, + ], + '@typescript-eslint/no-empty-function': [ + 'error', + { allow: ['arrowFunctions'] }, + ], + '@typescript-eslint/no-empty-interface': 'off', + '@typescript-eslint/no-explicit-any': 'off', + 'no-extra-semi': 'off', + '@typescript-eslint/no-extra-semi': 'off', // conflicts with prettier + '@typescript-eslint/no-inferrable-types': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + args: 'all', + argsIgnorePattern: '^_', + caughtErrors: 'all', + caughtErrorsIgnorePattern: '^_', + destructuredArrayIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', disallowTypeAnnotations: false }, + ], + // disable rules set in @typescript-eslint/stylistic which conflict with current code + // we should discuss if we want to enable these as they encourage consistent code + '@typescript-eslint/array-type': 'off', + '@typescript-eslint/consistent-type-definitions': 'off', + '@typescript-eslint/prefer-for-of': 'off', + '@typescript-eslint/prefer-function-type': 'off', + + 'import-x/no-nodejs-modules': [ + 'error', + { allow: builtinModules.map((mod) => `node:${mod}`) }, + ], + 'import-x/no-duplicates': 'error', + 'import-x/order': 'error', + 'sort-imports': [ + 'error', + { + ignoreCase: false, + ignoreDeclarationSort: true, + ignoreMemberSort: false, + memberSyntaxSortOrder: ['none', 'all', 'multiple', 'single'], + allowSeparatedGroups: false, + }, + ], + + 'regexp/prefer-regexp-exec': 'error', + 'regexp/prefer-regexp-test': 'error', + // in some cases using explicit letter-casing is more performant than the `i` flag + 'regexp/use-ignore-case': 'off', + }, + }, + { + name: 'vite/globals', + files: ['packages/**/*.?([cm])[jt]s?(x)'], + ignores: ['**/__tests__/**'], + rules: { + 'no-restricted-globals': ['error', 'require', '__dirname', '__filename'], + }, + }, + { + name: 'disables/playground', + files: [ + 'packages/**/*.test.?([cm])[jt]s?(x)', + 'playground/**/*.?([cm])[jt]s?(x)', + 'packages/plugin-react-swc/playground/**/*.?([cm])[jt]s?(x)', + ], + rules: { + 'n/no-extraneous-import': 'off', + 'n/no-extraneous-require': 'off', + 'n/no-missing-import': 'off', + 'n/no-missing-require': 'off', + 'n/no-unsupported-features/es-builtins': 'off', + 'n/no-unsupported-features/node-builtins': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'no-undef': 'off', + 'no-empty': 'off', + 'no-constant-condition': 'off', + '@typescript-eslint/no-empty-function': 'off', + }, + }, + { + name: 'disables/js', + files: ['**/*.js', '**/*.mjs', '**/*.cjs'], + rules: { + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-unused-vars': 'off', + }, + }, + { + name: 'disables/dts', + files: ['**/*.d.ts'], + rules: { + '@typescript-eslint/consistent-indexed-object-style': 'off', + '@typescript-eslint/triple-slash-reference': 'off', + }, + }, +) diff --git a/package.json b/package.json index 53ebec5ba..637fcf6e0 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,9 @@ "private": true, "type": "module", "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, + "packageManager": "pnpm@10.18.0", "homepage": "https://github.com/vitejs/vite-plugin-react/", "keywords": [ "frontend", @@ -20,40 +21,38 @@ "format": "prettier --write --cache .", "lint": "eslint --cache .", "typecheck": "tsc -p scripts && tsc -p playground && tsc -p packages/plugin-react", - "test": "run-s test-serve test-build", + "test": "pnpm run test-unit && pnpm run test-serve && pnpm run test-build && pnpm --filter ./packages/plugin-react-swc run test", + "test-unit": "pnpm -r --filter='./packages/*' run test-unit", "test-serve": "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", "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-types/import": "^2.29.1", - "@eslint-types/typescript-eslint": "^7.5.0", + "@eslint/js": "^9.37.0", "@types/fs-extra": "^11.0.4", - "@types/node": "^20.12.12", - "@typescript-eslint/eslint-plugin": "^7.9.0", - "@typescript-eslint/parser": "^7.9.0", - "@vitejs/release-scripts": "^1.3.1", - "eslint": "^8.57.0", - "eslint-define-config": "^2.1.0", - "eslint-plugin-import": "^2.29.1", - "eslint-plugin-n": "^17.7.0", - "eslint-plugin-regexp": "^2.5.0", - "fs-extra": "^11.2.0", - "lint-staged": "^15.2.2", - "npm-run-all2": "^6.2.0", - "picocolors": "^1.0.1", - "playwright-chromium": "^1.44.0", - "prettier": "^3.0.3", - "simple-git-hooks": "^2.11.1", - "tsx": "^4.10.5", - "typescript": "^5.4.5", - "vite": "^5.2.11", - "vitest": "^1.6.0" + "@types/node": "^22.18.8", + "@vitejs/release-scripts": "^1.6.0", + "eslint": "^9.37.0", + "eslint-plugin-import-x": "^4.16.1", + "eslint-plugin-n": "^17.23.1", + "eslint-plugin-regexp": "^2.10.0", + "fs-extra": "^11.3.2", + "globals": "^16.4.0", + "lint-staged": "^16.2.3", + "picocolors": "^1.1.1", + "playwright-chromium": "^1.55.1", + "prettier": "^3.6.2", + "simple-git-hooks": "^2.13.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.45.0", + "vite": "^7.1.9", + "vite-plugin-inspect": "^11.3.3", + "vitest": "^3.2.4" }, "simple-git-hooks": { "pre-commit": "pnpm exec lint-staged --concurrent false" @@ -71,6 +70,5 @@ "playground/**/__tests__/**/*.ts": [ "eslint --cache --fix" ] - }, - "packageManager": "pnpm@8.11.0" + } } diff --git a/packages/common/index.ts b/packages/common/index.ts new file mode 100644 index 000000000..e194bed42 --- /dev/null +++ b/packages/common/index.ts @@ -0,0 +1,2 @@ +export * from './refresh-utils' +export * from './warning' diff --git a/packages/common/package.json b/packages/common/package.json new file mode 100644 index 000000000..1bb806f8c --- /dev/null +++ b/packages/common/package.json @@ -0,0 +1,13 @@ +{ + "name": "@vitejs/react-common", + "version": "0.0.0", + "type": "module", + "private": true, + "exports": { + ".": "./index.ts", + "./refresh-runtime": "./refresh-runtime.js" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } +} diff --git a/packages/common/refresh-runtime.js b/packages/common/refresh-runtime.js new file mode 100644 index 000000000..e798d000e --- /dev/null +++ b/packages/common/refresh-runtime.js @@ -0,0 +1,663 @@ +/* global window */ +/* eslint-disable eqeqeq, prefer-const, @typescript-eslint/no-empty-function */ + +/*! Copyright (c) Meta Platforms, Inc. and affiliates. **/ +/** + * This is simplified pure-js version of https://github.com/facebook/react/blob/main/packages/react-refresh/src/ReactFreshRuntime.js + * without IE11 compatibility and verbose isDev checks. + * Some utils are appended at the bottom for HMR integration. + */ + +const REACT_FORWARD_REF_TYPE = Symbol.for('react.forward_ref') +const REACT_MEMO_TYPE = Symbol.for('react.memo') + +// We never remove these associations. +// It's OK to reference families, but use WeakMap/Set for types. +let allFamiliesByID = new Map() +let allFamiliesByType = new WeakMap() +let allSignaturesByType = new WeakMap() + +// This WeakMap is read by React, so we only put families +// that have actually been edited here. This keeps checks fast. +const updatedFamiliesByType = new WeakMap() + +// This is cleared on every performReactRefresh() call. +// It is an array of [Family, NextType] tuples. +let pendingUpdates = [] + +// This is injected by the renderer via DevTools global hook. +const helpersByRendererID = new Map() + +const helpersByRoot = new Map() + +// We keep track of mounted roots so we can schedule updates. +const mountedRoots = new Set() +// If a root captures an error, we remember it so we can retry on edit. +const failedRoots = new Set() + +// We also remember the last element for every root. +// It needs to be weak because we do this even for roots that failed to mount. +// If there is no WeakMap, we won't attempt to do retrying. +let rootElements = new WeakMap() +let isPerformingRefresh = false + +function computeFullKey(signature) { + if (signature.fullKey !== null) { + return signature.fullKey + } + + let fullKey = signature.ownKey + let hooks + try { + hooks = signature.getCustomHooks() + } catch (err) { + // This can happen in an edge case, e.g. if expression like Foo.useSomething + // depends on Foo which is lazily initialized during rendering. + // In that case just assume we'll have to remount. + signature.forceReset = true + signature.fullKey = fullKey + return fullKey + } + + for (let i = 0; i < hooks.length; i++) { + const hook = hooks[i] + if (typeof hook !== 'function') { + // Something's wrong. Assume we need to remount. + signature.forceReset = true + signature.fullKey = fullKey + return fullKey + } + const nestedHookSignature = allSignaturesByType.get(hook) + if (nestedHookSignature === undefined) { + // No signature means Hook wasn't in the source code, e.g. in a library. + // We'll skip it because we can assume it won't change during this session. + continue + } + const nestedHookKey = computeFullKey(nestedHookSignature) + if (nestedHookSignature.forceReset) { + signature.forceReset = true + } + fullKey += '\n---\n' + nestedHookKey + } + + signature.fullKey = fullKey + return fullKey +} + +function haveEqualSignatures(prevType, nextType) { + const prevSignature = allSignaturesByType.get(prevType) + const nextSignature = allSignaturesByType.get(nextType) + + if (prevSignature === undefined && nextSignature === undefined) { + return true + } + if (prevSignature === undefined || nextSignature === undefined) { + return false + } + if (computeFullKey(prevSignature) !== computeFullKey(nextSignature)) { + return false + } + if (nextSignature.forceReset) { + return false + } + + return true +} + +function isReactClass(type) { + return type.prototype && type.prototype.isReactComponent +} + +function canPreserveStateBetween(prevType, nextType) { + if (isReactClass(prevType) || isReactClass(nextType)) { + return false + } + if (haveEqualSignatures(prevType, nextType)) { + return true + } + return false +} + +function resolveFamily(type) { + // Only check updated types to keep lookups fast. + return updatedFamiliesByType.get(type) +} + +// This is a safety mechanism to protect against rogue getters and Proxies. +function getProperty(object, property) { + try { + return object[property] + } catch (err) { + // Intentionally ignore. + return undefined + } +} + +function performReactRefresh() { + if (pendingUpdates.length === 0) { + return null + } + if (isPerformingRefresh) { + return null + } + + isPerformingRefresh = true + try { + const staleFamilies = new Set() + const updatedFamilies = new Set() + + const updates = pendingUpdates + pendingUpdates = [] + updates.forEach(([family, nextType]) => { + // Now that we got a real edit, we can create associations + // that will be read by the React reconciler. + const prevType = family.current + updatedFamiliesByType.set(prevType, family) + updatedFamiliesByType.set(nextType, family) + family.current = nextType + + // Determine whether this should be a re-render or a re-mount. + if (canPreserveStateBetween(prevType, nextType)) { + updatedFamilies.add(family) + } else { + staleFamilies.add(family) + } + }) + + // TODO: rename these fields to something more meaningful. + const update = { + updatedFamilies, // Families that will re-render preserving state + staleFamilies, // Families that will be remounted + } + + helpersByRendererID.forEach((helpers) => { + // Even if there are no roots, set the handler on first update. + // This ensures that if *new* roots are mounted, they'll use the resolve handler. + helpers.setRefreshHandler(resolveFamily) + }) + + let didError = false + let firstError = null + + // We snapshot maps and sets that are mutated during commits. + // If we don't do this, there is a risk they will be mutated while + // we iterate over them. For example, trying to recover a failed root + // may cause another root to be added to the failed list -- an infinite loop. + const failedRootsSnapshot = new Set(failedRoots) + const mountedRootsSnapshot = new Set(mountedRoots) + const helpersByRootSnapshot = new Map(helpersByRoot) + + failedRootsSnapshot.forEach((root) => { + const helpers = helpersByRootSnapshot.get(root) + if (helpers === undefined) { + throw new Error( + 'Could not find helpers for a root. This is a bug in React Refresh.', + ) + } + if (!failedRoots.has(root)) { + // No longer failed. + } + if (rootElements === null) { + return + } + if (!rootElements.has(root)) { + return + } + const element = rootElements.get(root) + try { + helpers.scheduleRoot(root, element) + } catch (err) { + if (!didError) { + didError = true + firstError = err + } + // Keep trying other roots. + } + }) + mountedRootsSnapshot.forEach((root) => { + const helpers = helpersByRootSnapshot.get(root) + if (helpers === undefined) { + throw new Error( + 'Could not find helpers for a root. This is a bug in React Refresh.', + ) + } + if (!mountedRoots.has(root)) { + // No longer mounted. + } + try { + helpers.scheduleRefresh(root, update) + } catch (err) { + if (!didError) { + didError = true + firstError = err + } + // Keep trying other roots. + } + }) + if (didError) { + throw firstError + } + return update + } finally { + isPerformingRefresh = false + } +} + +export function register(type, id) { + if (type === null) { + return + } + if (typeof type !== 'function' && typeof type !== 'object') { + return + } + + // This can happen in an edge case, e.g. if we register + // return value of a HOC but it returns a cached component. + // Ignore anything but the first registration for each type. + if (allFamiliesByType.has(type)) { + return + } + // Create family or remember to update it. + // None of this bookkeeping affects reconciliation + // until the first performReactRefresh() call above. + let family = allFamiliesByID.get(id) + if (family === undefined) { + family = { current: type } + allFamiliesByID.set(id, family) + } else { + pendingUpdates.push([family, type]) + } + allFamiliesByType.set(type, family) + + // Visit inner types because we might not have registered them. + if (typeof type === 'object' && type !== null) { + switch (getProperty(type, '$$typeof')) { + case REACT_FORWARD_REF_TYPE: + register(type.render, id + '$render') + break + case REACT_MEMO_TYPE: + register(type.type, id + '$type') + break + } + } +} + +function setSignature(type, key, forceReset, getCustomHooks) { + if (!allSignaturesByType.has(type)) { + allSignaturesByType.set(type, { + forceReset, + ownKey: key, + fullKey: null, + getCustomHooks: getCustomHooks || (() => []), + }) + } + // Visit inner types because we might not have signed them. + if (typeof type === 'object' && type !== null) { + switch (getProperty(type, '$$typeof')) { + case REACT_FORWARD_REF_TYPE: + setSignature(type.render, key, forceReset, getCustomHooks) + break + case REACT_MEMO_TYPE: + setSignature(type.type, key, forceReset, getCustomHooks) + break + } + } +} + +// This is lazily called during first render for a type. +// It captures Hook list at that time so inline requires don't break comparisons. +function collectCustomHooksForSignature(type) { + const signature = allSignaturesByType.get(type) + if (signature !== undefined) { + computeFullKey(signature) + } +} + +export function injectIntoGlobalHook(globalObject) { + // For React Native, the global hook will be set up by require('react-devtools-core'). + // That code will run before us. So we need to monkeypatch functions on existing hook. + + // For React Web, the global hook will be set up by the extension. + // This will also run before us. + let hook = globalObject.__REACT_DEVTOOLS_GLOBAL_HOOK__ + if (hook === undefined) { + // However, if there is no DevTools extension, we'll need to set up the global hook ourselves. + // Note that in this case it's important that renderer code runs *after* this method call. + // Otherwise, the renderer will think that there is no global hook, and won't do the injection. + let nextID = 0 + globalObject.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook = { + renderers: new Map(), + supportsFiber: true, + inject: (injected) => nextID++, + onScheduleFiberRoot: (id, root, children) => {}, + onCommitFiberRoot: (id, root, maybePriorityLevel, didError) => {}, + onCommitFiberUnmount() {}, + } + } + + if (hook.isDisabled) { + // This isn't a real property on the hook, but it can be set to opt out + // of DevTools integration and associated warnings and logs. + // Using console['warn'] to evade Babel and ESLint + console['warn']( + 'Something has shimmed the React DevTools global hook (__REACT_DEVTOOLS_GLOBAL_HOOK__). ' + + 'Fast Refresh is not compatible with this shim and will be disabled.', + ) + return + } + + // Here, we just want to get a reference to scheduleRefresh. + const oldInject = hook.inject + hook.inject = function (injected) { + const id = oldInject.apply(this, arguments) + if ( + typeof injected.scheduleRefresh === 'function' && + typeof injected.setRefreshHandler === 'function' + ) { + // This version supports React Refresh. + helpersByRendererID.set(id, injected) + } + return id + } + + // Do the same for any already injected roots. + // This is useful if ReactDOM has already been initialized. + // https://github.com/facebook/react/issues/17626 + hook.renderers.forEach((injected, id) => { + if ( + typeof injected.scheduleRefresh === 'function' && + typeof injected.setRefreshHandler === 'function' + ) { + // This version supports React Refresh. + helpersByRendererID.set(id, injected) + } + }) + + // We also want to track currently mounted roots. + const oldOnCommitFiberRoot = hook.onCommitFiberRoot + const oldOnScheduleFiberRoot = hook.onScheduleFiberRoot || (() => {}) + hook.onScheduleFiberRoot = function (id, root, children) { + if (!isPerformingRefresh) { + // If it was intentionally scheduled, don't attempt to restore. + // This includes intentionally scheduled unmounts. + failedRoots.delete(root) + if (rootElements !== null) { + rootElements.set(root, children) + } + } + return oldOnScheduleFiberRoot.apply(this, arguments) + } + hook.onCommitFiberRoot = function (id, root, maybePriorityLevel, didError) { + const helpers = helpersByRendererID.get(id) + if (helpers !== undefined) { + helpersByRoot.set(root, helpers) + + const current = root.current + const alternate = current.alternate + + // We need to determine whether this root has just (un)mounted. + // This logic is copy-pasted from similar logic in the DevTools backend. + // If this breaks with some refactoring, you'll want to update DevTools too. + + if (alternate !== null) { + const wasMounted = + alternate.memoizedState != null && + alternate.memoizedState.element != null && + mountedRoots.has(root) + + const isMounted = + current.memoizedState != null && current.memoizedState.element != null + + if (!wasMounted && isMounted) { + // Mount a new root. + mountedRoots.add(root) + failedRoots.delete(root) + } else if (wasMounted && isMounted) { + // Update an existing root. + // This doesn't affect our mounted root Set. + } else if (wasMounted && !isMounted) { + // Unmount an existing root. + mountedRoots.delete(root) + if (didError) { + // We'll remount it on future edits. + failedRoots.add(root) + } else { + helpersByRoot.delete(root) + } + } else if (!wasMounted && !isMounted) { + if (didError) { + // We'll remount it on future edits. + failedRoots.add(root) + } + } + } else { + // Mount a new root. + mountedRoots.add(root) + } + } + + // Always call the decorated DevTools hook. + return oldOnCommitFiberRoot.apply(this, arguments) + } +} + +// This is a wrapper over more primitive functions for setting signature. +// Signatures let us decide whether the Hook order has changed on refresh. +// +// This function is intended to be used as a transform target, e.g.: +// var _s = createSignatureFunctionForTransform() +// +// function Hello() { +// const [foo, setFoo] = useState(0); +// const value = useCustomHook(); +// _s(); /* Call without arguments triggers collecting the custom Hook list. +// * This doesn't happen during the module evaluation because we +// * don't want to change the module order with inline requires. +// * Next calls are noops. */ +// return

Hi

; +// } +// +// /* Call with arguments attaches the signature to the type: */ +// _s( +// Hello, +// 'useState{[foo, setFoo]}(0)', +// () => [useCustomHook], /* Lazy to avoid triggering inline requires */ +// ); +export function createSignatureFunctionForTransform() { + let savedType + let hasCustomHooks + let didCollectHooks = false + return function (type, key, forceReset, getCustomHooks) { + if (typeof key === 'string') { + // We're in the initial phase that associates signatures + // with the functions. Note this may be called multiple times + // in HOC chains like _s(hoc1(_s(hoc2(_s(actualFunction))))). + if (!savedType) { + // We're in the innermost call, so this is the actual type. + // $FlowFixMe[escaped-generic] discovered when updating Flow + savedType = type + hasCustomHooks = typeof getCustomHooks === 'function' + } + // Set the signature for all types (even wrappers!) in case + // they have no signatures of their own. This is to prevent + // problems like https://github.com/facebook/react/issues/20417. + if ( + type != null && + (typeof type === 'function' || typeof type === 'object') + ) { + setSignature(type, key, forceReset, getCustomHooks) + } + return type + } else { + // We're in the _s() call without arguments, which means + // this is the time to collect custom Hook signatures. + // Only do this once. This path is hot and runs *inside* every render! + if (!didCollectHooks && hasCustomHooks) { + didCollectHooks = true + collectCustomHooksForSignature(savedType) + } + } + } +} + +function isLikelyComponentType(type) { + switch (typeof type) { + case 'function': { + // First, deal with classes. + if (type.prototype != null) { + if (type.prototype.isReactComponent) { + // React class. + return true + } + const ownNames = Object.getOwnPropertyNames(type.prototype) + if (ownNames.length > 1 || ownNames[0] !== 'constructor') { + // This looks like a class. + return false + } + + if (type.prototype.__proto__ !== Object.prototype) { + // It has a superclass. + return false + } + // Pass through. + // This looks like a regular function with empty prototype. + } + // For plain functions and arrows, use name as a heuristic. + const name = type.name || type.displayName + return typeof name === 'string' && /^[A-Z]/.test(name) + } + case 'object': { + if (type != null) { + switch (getProperty(type, '$$typeof')) { + case REACT_FORWARD_REF_TYPE: + case REACT_MEMO_TYPE: + // Definitely React components. + return true + default: + return false + } + } + return false + } + default: { + return false + } + } +} + +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 + */ + +// 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) { + for (const key in moduleExports) { + if (key === '__esModule') continue + const exportValue = moduleExports[key] + if (isLikelyComponentType(exportValue)) { + // 'export' is required to avoid key collision when renamed exports that + // shadow a local component name: https://github.com/vitejs/vite-plugin-react/issues/116 + // 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, + ) + } + } + } +} + +function debounce(fn, delay) { + let handle + return () => { + clearTimeout(handle) + handle = setTimeout(fn, delay) + } +} + +const hooks = [] +window.__registerBeforePerformReactRefresh = (cb) => { + hooks.push(cb) +} +const enqueueUpdate = debounce(async () => { + if (hooks.length) await Promise.all(hooks.map((cb) => cb())) + performReactRefresh() +}, 16) + +export function validateRefreshBoundaryAndEnqueueUpdate( + id, + prevExports, + nextExports, +) { + const ignoredExports = window.__getReactRefreshIgnoredExports?.({ id }) ?? [] + if ( + predicateOnExport( + ignoredExports, + prevExports, + (key) => key in nextExports, + ) !== true + ) { + return 'Could not Fast Refresh (export removed)' + } + if ( + predicateOnExport( + ignoredExports, + nextExports, + (key) => key in prevExports, + ) !== true + ) { + return 'Could not Fast Refresh (new export)' + } + + let hasExports = false + const allExportsAreComponentsOrUnchanged = predicateOnExport( + ignoredExports, + nextExports, + (key, value) => { + hasExports = true + if (isLikelyComponentType(value)) return true + if (isCompoundComponent(value)) return true + return prevExports[key] === nextExports[key] + }, + ) + if (hasExports && allExportsAreComponentsOrUnchanged === true) { + enqueueUpdate() + } else { + return `Could not Fast Refresh ("${allExportsAreComponentsOrUnchanged}" export is incompatible). Learn more at __README_URL__#consistent-components-exports` + } +} + +function predicateOnExport(ignoredExports, moduleExports, predicate) { + for (const key in moduleExports) { + if (ignoredExports.includes(key)) continue + if (!predicate(key, moduleExports[key])) return key + } + return true +} + +// Hides vite-ignored dynamic import so that Vite can skip analysis if no other +// dynamic import is present (https://github.com/vitejs/vite/pull/12732) +export const __hmr_import = (module) => import(/* @vite-ignore */ module) + +// For backwards compatibility with @vitejs/plugin-react. +export default { injectIntoGlobalHook } diff --git a/packages/common/refresh-utils.ts b/packages/common/refresh-utils.ts new file mode 100644 index 000000000..db0e587e4 --- /dev/null +++ b/packages/common/refresh-utils.ts @@ -0,0 +1,62 @@ +export const runtimePublicPath = '/@react-refresh' + +const reactCompRE = /extends\s+(?:React\.)?(?:Pure)?Component/ +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;` + +export const getPreambleCode = (base: string): string => + preambleCode.replace('__BASE__', base) + +export function addRefreshWrapper( + code: string, + pluginName: string, + id: string, + reactRefreshHost = '', +): string | undefined { + const hasRefresh = refreshContentRE.test(code) + const onlyReactComp = !hasRefresh && reactCompRE.test(code) + + if (!hasRefresh && !onlyReactComp) return undefined + + let newCode = code + newCode += ` + +import * as RefreshRuntime from "${reactRefreshHost}${runtimePublicPath}"; +const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; +if (import.meta.hot && !inWebWorker) { + if (!window.$RefreshReg$) { + throw new Error( + "${pluginName} can't detect preamble. Something is wrong." + ); + } + + RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => { + RefreshRuntime.registerExportsForReactRefresh(${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); + }); + }); +} +` + + if (hasRefresh) { + newCode += `function $RefreshReg$(type, id) { return RefreshRuntime.register(type, ${JSON.stringify(id)} + ' ' + id) } +function $RefreshSig$() { return RefreshRuntime.createSignatureFunctionForTransform(); } +` + } + + return newCode +} diff --git a/packages/common/warning.ts b/packages/common/warning.ts new file mode 100644 index 000000000..85e0cf434 --- /dev/null +++ b/packages/common/warning.ts @@ -0,0 +1,30 @@ +import type { BuildOptions, UserConfig } from 'vite' + +export const silenceUseClientWarning = ( + userConfig: UserConfig, +): BuildOptions => ({ + 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) + } + }, + }, +}) diff --git a/packages/plugin-react-oxc/CHANGELOG.md b/packages/plugin-react-oxc/CHANGELOG.md new file mode 100644 index 000000000..1d0236e52 --- /dev/null +++ b/packages/plugin-react-oxc/CHANGELOG.md @@ -0,0 +1,86 @@ +# Changelog + +## Unreleased + +## 0.4.2 (2025-09-17) + +### 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) + +- Create Oxc plugin diff --git a/packages/plugin-react-oxc/LICENSE b/packages/plugin-react-oxc/LICENSE new file mode 100644 index 000000000..9c1b313d7 --- /dev/null +++ b/packages/plugin-react-oxc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019-present, Yuxi (Evan) You and Vite contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/plugin-react-oxc/README.md b/packages/plugin-react-oxc/README.md new file mode 100644 index 000000000..bc6b77646 --- /dev/null +++ b/packages/plugin-react-oxc/README.md @@ -0,0 +1,85 @@ +> [!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. + +- enable [Fast Refresh](https://www.npmjs.com/package/react-refresh) in development +- use the [automatic JSX runtime](https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html) +- small installation size + +```js +// vite.config.js +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-oxc' + +export default defineConfig({ + plugins: [react()], +}) +``` + +## Caveats + +- `jsx runtime` is always `automatic` +- this plugin only works with [`rolldown-vite`](https://vitejs.dev/guide/rolldown) + +## Options + +### include/exclude + +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' +import react from '@vitejs/plugin-react' +import mdx from '@mdx-js/rollup' + +export default defineConfig({ + plugins: [ + { enforce: 'pre', ...mdx() }, + react({ include: /\.(mdx|js|jsx|ts|tsx)$/ }), + ], +}) +``` + +### jsxImportSource + +Control where the JSX factory is imported from. Default to `'react'` + +```js +react({ jsxImportSource: '@emotion/react' }) +``` + +## Middleware mode + +In [middleware mode](https://vite.dev/config/server-options.html#server-middlewaremode), you should make sure your entry `index.html` file is transformed by Vite. Here's an example for an Express server: + +```js +app.get('/', async (req, res, next) => { + try { + let html = fs.readFileSync(path.resolve(root, 'index.html'), 'utf-8') + + // Transform HTML using Vite plugins. + html = await viteServer.transformIndexHtml(req.url, html) + + res.send(html) + } catch (e) { + return next(e) + } +}) +``` + +Otherwise, you'll probably get this error: + +``` +Uncaught Error: @vitejs/plugin-react-oxc can't detect preamble. Something is wrong. +``` + +## 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). + +If an incompatible change in exports is found, the module will be invalidated and HMR will propagate. To make it easier to export simple constants alongside your component, the module is only invalidated when their value changes. + +You can catch mistakes and get more detailed warning with this [eslint rule](https://github.com/ArnaudBarre/eslint-plugin-react-refresh). diff --git a/packages/plugin-react-oxc/package.json b/packages/plugin-react-oxc/package.json new file mode 100644 index 000000000..04539f2b7 --- /dev/null +++ b/packages/plugin-react-oxc/package.json @@ -0,0 +1,52 @@ +{ + "name": "@vitejs/plugin-react-oxc", + "version": "0.4.2", + "license": "MIT", + "author": "Evan You", + "contributors": [ + "Alec Larson", + "Arnaud Barré" + ], + "description": "The future default Vite plugin for React projects", + "keywords": [ + "vite", + "vite-plugin", + "react", + "oxc", + "react-refresh", + "fast refresh" + ], + "files": [ + "dist" + ], + "type": "module", + "exports": "./dist/index.js", + "scripts": { + "dev": "tsdown --watch ./src --watch ../common", + "build": "tsdown", + "prepublishOnly": "npm run build" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vitejs/vite-plugin-react.git", + "directory": "packages/plugin-react-oxc" + }, + "bugs": { + "url": "https://github.com/vitejs/vite-plugin-react/issues" + }, + "homepage": "https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#readme", + "peerDependencies": { + "vite": "^6.3.0 || ^7.0.0" + }, + "devDependencies": { + "@vitejs/react-common": "workspace:*", + "tsdown": "^0.15.6", + "vite": "catalog:rolldown-vite" + }, + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.41" + } +} diff --git a/packages/plugin-react-oxc/src/index.ts b/packages/plugin-react-oxc/src/index.ts new file mode 100644 index 000000000..e42b7356a --- /dev/null +++ b/packages/plugin-react-oxc/src/index.ts @@ -0,0 +1,159 @@ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' +import type { BuildOptions, Plugin } from 'vite' +import { + addRefreshWrapper, + getPreambleCode, + runtimePublicPath, + silenceUseClientWarning, +} from '@vitejs/react-common' +import { exactRegex } from '@rolldown/pluginutils' + +const _dirname = dirname(fileURLToPath(import.meta.url)) +const refreshRuntimePath = join(_dirname, 'refresh-runtime.js') + +export interface Options { + include?: string | RegExp | Array + exclude?: string | RegExp | Array + /** + * Control where the JSX factory is imported from. + * @default 'react' + */ + jsxImportSource?: string +} + +const defaultIncludeRE = /\.[tj]sx?(?:$|\?)/ +const defaultExcludeRE = /\/node_modules\// + +export default function viteReact(opts: Options = {}): Plugin[] { + const include = opts.include ?? defaultIncludeRE + const exclude = opts.exclude ?? defaultExcludeRE + + const jsxImportSource = opts.jsxImportSource ?? 'react' + const jsxImportRuntime = `${jsxImportSource}/jsx-runtime` + const jsxImportDevRuntime = `${jsxImportSource}/jsx-dev-runtime` + + const viteConfig: Plugin = { + name: 'vite:react-oxc:config', + config(userConfig, { command }) { + return { + // @ts-expect-error rolldown-vite Vite type incompatibility + build: silenceUseClientWarning(userConfig) as BuildOptions, + oxc: { + jsx: { + runtime: 'automatic', + importSource: jsxImportSource, + refresh: command === 'serve', + development: command === 'serve', + }, + jsxRefreshInclude: include, + jsxRefreshExclude: exclude, + }, + optimizeDeps: { + include: [ + 'react', + 'react-dom', + jsxImportDevRuntime, + jsxImportRuntime, + ], + 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( + '@vitejs/plugin-react-oxc requires rolldown-vite to be used. ' + + 'See https://vitejs.dev/guide/rolldown for more details about rolldown-vite.', + ) + } + }, + } + + 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', + resolveId: { + filter: { id: exactRegex(runtimePublicPath) }, + handler(id) { + return id + }, + }, + load: { + filter: { id: exactRegex(runtimePublicPath) }, + handler(_id) { + return readFileSync(refreshRuntimePath, 'utf-8').replace( + /__README_URL__/g, + 'https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-oxc', + ) + }, + }, + } + + let skipFastRefresh = false + + const viteRefreshWrapper: Plugin = { + name: 'vite:react-oxc:refresh-wrapper', + apply: 'serve', + configResolved(config) { + skipFastRefresh = config.isProduction || config.server.hmr === false + }, + transform: { + filter: { + id: { include, 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-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), + }, + ] + }, + } + + return [viteConfig, viteConfigPost, viteRefreshRuntime, viteRefreshWrapper] +} diff --git a/packages/plugin-react-oxc/tsconfig.json b/packages/plugin-react-oxc/tsconfig.json new file mode 100644 index 000000000..70c7eacff --- /dev/null +++ b/packages/plugin-react-oxc/tsconfig.json @@ -0,0 +1,15 @@ +{ + "include": ["src"], + "compilerOptions": { + "outDir": "dist", + "target": "es2023", + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "sourceMap": true, + "noEmit": true, + "noUnusedLocals": true, + "esModuleInterop": 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 new file mode 100644 index 000000000..532ef9ed2 --- /dev/null +++ b/packages/plugin-react-swc/CHANGELOG.md @@ -0,0 +1,322 @@ +# Changelog + +## Unreleased + +## 4.1.0 (2025-09-17) + +### 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 + +This plugin is now compatible with rolldown-powered version of Vite. + +## 3.9.0-beta.3 (2025-04-15) + +### Add `reactRefreshHost` option + +Add `reactRefreshHost` option to set a React Fast Refresh runtime URL prefix. +This is useful in a module federation context to enable HMR by specifying the host application URL in the Vite config of a remote application. +See full discussion here: https://github.com/module-federation/vite/issues/183#issuecomment-2751825367 + +```ts +export default defineConfig({ + plugins: [react({ reactRefreshHost: 'http://localhost:3000' })], +}) +``` + +## 3.9.0-beta.2 (2025-04-09) + +## 3.9.0-beta.0 (2025-04-09) + +## 3.8.1 + +### Remove WebContainers warning [#268](https://github.com/vitejs/vite-plugin-react-swc/pull/268) + +SWC is now supported in WebContainers 🎉 + +## 3.8.0 + +### Add useAtYourOwnRisk_mutateSwcOptions option + +The future of Vite is with OXC, and from the beginning this was a design choice to not exposed too many specialties from SWC so that Vite React users can move to another transformer later. +Also debugging why some specific version of decorators with some other unstable/legacy feature doesn't work is not fun, so we won't provide support for it, hence the name `useAtYourOwnRisk`. + +```ts +react({ + useAtYourOwnRisk_mutateSwcOptions(options) { + options.jsc.parser.decorators = true; + options.jsc.transform.decoratorVersion = "2022-03"; + }, +}); +``` + +## 3.7.2 + +### Add Vite 6 to peerDependencies range [#207](https://github.com/vitejs/vite-plugin-react-swc/pull/207) + +Thanks @RobinTail + +### Revert throw when refresh runtime is loaded twice [#237](https://github.com/vitejs/vite-plugin-react-swc/issues/237) + +Revert the throw when refresh runtime is loaded twice to enable usage in micro frontend apps. This was added to help fix setup usage, and this is not worth an annoying warning for others or a config parameter. + +This revert was done in the Babel plugin last year and I didn't port it back. + +## 3.7.1 + +Ignore directive sourcemap error [#231](https://github.com/vitejs/vite-plugin-react-swc/issues/231) + +## 3.7.0 + +### Support HMR for class components + +This is a long overdue and should fix some issues people had with HMR when migrating from CRA. + +## 3.6.0 + +### Add parserConfig option + +This will unlock to use the plugin in some use cases where the original source code is not in TS. Using this option to keep using JSX inside `.js` files is highly discouraged and can be removed in any future version. + +## 3.5.0 + +### Update peer dependency range to target Vite 5 + +There were no breaking change that impacted this plugin, so any combination of React plugins and Vite core version will work. + +### Align jsx runtime for optimized dependencies + +This will only affect people using internal libraries that contains untranspiled JSX. This change aligns the optimizer with the source code and avoid issues when the published source don't have `React` in the scope. + +Reminder: While being partially supported in Vite, publishing TS & JSX outside of internal libraries is highly discouraged. + +## 3.4.1 + +### Add support for `.mts` (fixes [#161](https://github.com/vitejs/vite-plugin-react-swc/issues/161)) + +Using CJS in source code will not work in Vite (and will never be supported), so this is better to only use `.ts`. + +But to better align with [Vite core defaults](https://vite.dev/config/shared-options.html#resolve-extensions), `.mts` extension will now be processed like `.ts`. This maybe reverted in a future major. + +## 3.4.0 + +- Add `devTarget` option (fixes [#141](https://github.com/vitejs/vite-plugin-react-swc/issues/141)) +- Disable Fast Refresh based on `config.server.hmr === false` instead of `process.env.TEST` +- Warn when plugin is in WebContainers (see [#118](https://github.com/vitejs/vite-plugin-react-swc/issues/118)) +- Better invalidation message when an export is added & fix HMR for export of nullish values ([#143](https://github.com/vitejs/vite-plugin-react-swc/issues/143)) + +## 3.3.2 + +- Support [Vitest deps.experimentalOptimizer](https://vitest.dev/config/#deps-experimentaloptimizer) ([#115](https://github.com/vitejs/vite-plugin-react-swc/pull/115)) + +## 3.3.1 + +- Add `type: module` to package.json ([#101](https://github.com/vitejs/vite-plugin-react-swc/pull/101)). Because the library already publish `.cjs` & `.mjs` files, the only change is for typing when using the node16 module resolution (fixes [#95](https://github.com/vitejs/vite-plugin-react-swc/issues/95)) +- Throw an error when the MDX plugin is after this one ([#100](https://github.com/vitejs/vite-plugin-react-swc/pull/100)). This is an expected breaking change added in `3.2.0` and this should people that were using both plugins before this version to migrate. + +## 3.3.0 + +- Support TS/JSX in node_modules to help the community experiment with it. Note that for now this not supported by TS and errors from these files cannot be silenced if the user is using a stricter configuration than the library author: https://github.com/microsoft/TypeScript/issues/30511. I advise to use it only for internal libraries for now (fixes #53) +- Silence `"use client"` warning when building library like `@tanstack/react-query` +- Fix fast refresh issue when exporting a component with a name that shadow another local component + +This release goes in hand with the upcoming Vite 4.3 release focusing on performances: + +- Move resolve of runtime code into a "pre" plugin ([#79](https://github.com/vitejs/vite-plugin-react-swc/pull/79)) +- Wrap dynamic import to speedup analysis ([#80](https://github.com/vitejs/vite-plugin-react-swc/pull/80)) + +## 3.2.0 + +- Support HMR for MDX (fixes #52) +- Fix: when using plugins, apply SWC before esbuild so that automatic runtime is respected for JSX (fixes #56) +- Fix: use jsxImportSource in optimizeDeps + +## 3.1.0 + +- Support plugins via the new `plugins` options +- Support TypeScript decorators via the new `tsDecorators` option. This requires `experimentalDecorators` in tsconfig. +- Fix HMR for styled components exported alongside other components +- Update embedded refresh runtime to 0.14 (fixes [#46](https://github.com/vitejs/vite-plugin-react-swc/issues/46)) + +## 3.0.1 + +- Support Emotion via the new `jsxImportSource` option (fixes [#25](https://github.com/vitejs/vite-plugin-react-swc/issues/25)) + +To use it with Emotion, update your config to: + +```ts +export default defineConfig({ + plugins: [react({ jsxImportSource: "@emotion/react" })], +}); +``` + +- Fix HMR when using Vite `base` option (fixes [#18](https://github.com/vitejs/vite-plugin-react-swc/issues/18)) +- Fix usage with workers (fixes [#23](https://github.com/vitejs/vite-plugin-react-swc/issues/23)) +- Fix usage with `Vite Ruby` and `Laravel Vite` ([#20](https://github.com/vitejs/vite-plugin-react-swc/pull/20)) +- Fix plugin default export when using commonjs (fixes [#14](https://github.com/vitejs/vite-plugin-react-swc/issues/14)) + +## 3.0.0 + +This is plugin is now stable! 🎉 + +To migrate from `vite-plugin-swc-react-refresh`, see the `3.0.0-beta.0` changelog. + +## 3.0.0-beta.2 + +- breaking: update plugin name to `vite:react-swc` to match official plugins naming +- fix: don't add React Refresh wrapper for SSR transform (fixes [#11](https://github.com/vitejs/vite-plugin-react-swc/issues/11)) + +## 3.0.0-beta.1 + +Fix package.json exports fields + +## 3.0.0-beta.0 + +This is the first beta version of the official plugin for using [SWC](https://swc.rs/) with React in Vite! + +Some breaking changes have been made to make the plugin closer to the Babel one while keeping the smallest API surface possible to reduce bugs, encourage future-proof compilation output and allow easier opt-in into future perf improvements (caching, move to other native toolchain, ...): + +- Automatically enable automatic JSX runtime. "classic" runtime is not supported +- Skip transformation for `.js` files +- Enforce [useDefineForClassFields](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#the-usedefineforclassfields-flag-and-the-declare-property-modifier) +- Don't pass `esbuild.define` config option to SWC. You can use the [top level define option](https://vite.dev/config/shared-options.html#define) instead +- Use default export + +To migrate, change your config to: + +```js +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; + +export default defineConfig({ + plugins: [react()], +}); +``` + +This new release also include a runtime check for React refresh boundaries. When the conditions are not met (most of the time, exporting React components alongside functions or constant), the module is invalidated with a warning message to help you catch issues while keeping you page up-to date with code changes. + +## 2.2.1 + +Skip react-refresh on SSR (Fixes [#2](https://github.com/vitejs/vite-plugin-react-swc/issues/2)) + +## 2.2.0 + +- Always provide parser options to fix issue with `.jsx` imports. Relying on file extension for this is more buggy [than I though](https://github.com/swc-project/swc/issues/3297) +- Extract line and column in SWC errors to make overlay filename clickable +- Fix plugin name (`react-refresh` -> `swc-react-refresh`) + +## 2.1.0 + +Add source maps support + +## 2.0.3 + +Include `react/jsx-dev-runtime` for dependencies optimisation when using automatic runtime. + +## 2.0.2 + +Unpinned `@swc/core` to get new features (like TS instantiation expression) despite a [30mb bump of bundle size](https://github.com/swc-project/swc/issues/3899) + +## 2.0.1 + +Fix esbuild property in documentation. + +## 2.0.0 + +Breaking: Use named export instead of default export for better esm/cjs interop. + +To migrate, replace your import by `import { swcReactRefresh } from "vite-plugin-swc-react-refresh";` + +The JSX automatic runtime is also now supported if you bump esbuild to at least [0.14.51](https://github.com/evanw/esbuild/releases/tag/v0.14.51). + +To use it, update your config from `esbuild: { jsxInject: 'import React from "react"' },` to `esbuild: { jsx: "automatic" },` + +## 0.1.2 + +- Add vite as peer dependency +- Pin @swc/core version to 1.2.141 to avoid a 30mb bump of bundle size + +## 0.1.1 + +Add LICENSE + +## 0.1.0 + +Initial release diff --git a/packages/plugin-react-swc/LICENSE b/packages/plugin-react-swc/LICENSE new file mode 100644 index 000000000..75488f19b --- /dev/null +++ b/packages/plugin-react-swc/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Arnaud Barré (https://github.com/ArnaudBarre) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/plugin-react-swc/README.md b/packages/plugin-react-swc/README.md new file mode 100644 index 000000000..c74f11d72 --- /dev/null +++ b/packages/plugin-react-swc/README.md @@ -0,0 +1,134 @@ +# @vitejs/plugin-react-swc [![npm](https://img.shields.io/npm/v/@vitejs/plugin-react-swc)](https://www.npmjs.com/package/@vitejs/plugin-react-swc) + +Speed up your Vite dev server with [SWC](https://swc.rs/) + +- ✅ A fast Fast Refresh (~20x faster than Babel) +- ✅ Enable [automatic JSX runtime](https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html) + +## Installation + +```sh +npm i -D @vitejs/plugin-react-swc +``` + +## Usage + +```ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [react()], +}) +``` + +## Caveats + +This plugin has limited options to enable good performances and be transpiler agnostic. Here is the list of non-configurable options that impact runtime behaviour: + +- [useDefineForClassFields](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#the-usedefineforclassfields-flag-and-the-declare-property-modifier) is always activated, as this matches the current ECMAScript spec +- `jsx runtime` is always `automatic` +- In development: + - esbuild is disabled, so the [esbuild configuration](https://vite.dev/config/shared-options.html#esbuild) has no effect + - `target` is ignored and defaults to `es2020` (see [`devTarget`](#devtarget)) + - JS files are not transformed + - tsconfig is not resolved, so properties other than the ones listed above behaves like TS defaults + +## Options + +### jsxImportSource + +Control where the JSX factory is imported from. + +`@default` "react" + +```ts +react({ jsxImportSource: '@emotion/react' }) +``` + +### tsDecorators + +Enable TypeScript decorators. Requires `experimentalDecorators` in tsconfig. + +`@default` false + +```ts +react({ tsDecorators: true }) +``` + +### plugins + +Use SWC plugins. Enable SWC at build time. + +```ts +react({ plugins: [['@swc/plugin-styled-components', {}]] }) +``` + +### devTarget + +Set the target for SWC in dev. This can avoid to down-transpile private class method for example. + +For production target, see https://vite.dev/config/build-options.html#build-target. + +`@default` "es2020" + +```ts +react({ devTarget: 'es2022' }) +``` + +### parserConfig + +Override the default include list (.ts, .tsx, .mts, .jsx, .mdx). + +This requires to redefine the config for any file you want to be included (ts, mdx, ...). + +If you want to trigger fast refresh on compiled JS, use `jsx: true`. Exclusion of node_modules should be handled by the function if needed. Using this option to use JSX inside `.js` files is highly discouraged and can be removed in any future version. + +```ts +react({ + parserConfig(id) { + if (id.endsWith('.res')) return { syntax: 'ecmascript', jsx: true } + if (id.endsWith('.ts')) return { syntax: 'typescript', tsx: false } + }, +}) +``` + +### reactRefreshHost + +The `reactRefreshHost` option is only necessary in a module federation context. It enables HMR to work between a remote & host application. In your remote Vite config, you would add your host origin: + +```js +react({ reactRefreshHost: 'http://localhost:3000' }) +``` + +Under the hood, this simply updates the React Fash Refresh runtime URL from `/@react-refresh` to `http://localhost:3000/@react-refresh` to ensure there is only one Refresh runtime across the whole application. Note that if you define `base` option in the host application, you need to include it in the option, like: `http://localhost:3000/{base}`. + +### useAtYourOwnRisk_mutateSwcOptions + +The future of Vite is with OXC, and from the beginning this was a design choice to not exposed too many specialties from SWC so that Vite React users can move to another transformer later. +Also debugging why some specific version of decorators with some other unstable/legacy feature doesn't work is not fun, so we won't provide support for it, hence the name `useAtYourOwnRisk`. + +```ts +react({ + useAtYourOwnRisk_mutateSwcOptions(options) { + options.jsc.parser.decorators = true + options.jsc.transform.decoratorVersion = '2022-03' + }, +}) +``` + +### 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). + +If an incompatible change in exports is found, the module will be invalidated and HMR will propagate. To make it easier to export simple constants alongside your component, the module is only invalidated when their value changes. + +You can catch mistakes and get more detailed warning with this [eslint rule](https://github.com/ArnaudBarre/eslint-plugin-react-refresh). diff --git a/packages/plugin-react-swc/package.json b/packages/plugin-react-swc/package.json new file mode 100644 index 000000000..880a7ba51 --- /dev/null +++ b/packages/plugin-react-swc/package.json @@ -0,0 +1,51 @@ +{ + "name": "@vitejs/plugin-react-swc", + "version": "4.1.0", + "license": "MIT", + "author": "Arnaud Barré (https://github.com/ArnaudBarre)", + "description": "Speed up your Vite dev server with SWC", + "keywords": [ + "vite", + "vite-plugin", + "react", + "swc", + "react-refresh", + "fast refresh" + ], + "type": "module", + "private": true, + "scripts": { + "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", + "directory": "packages/plugin-react-swc" + }, + "bugs": { + "url": "https://github.com/vitejs/vite-plugin-react/issues" + }, + "homepage": "https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-swc#readme", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.41", + "@swc/core": "^1.13.5" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7" + }, + "devDependencies": { + "@playwright/test": "^1.55.1", + "@types/fs-extra": "^11.0.4", + "@types/node": "^22.18.8", + "@vitejs/react-common": "workspace:*", + "fs-extra": "^11.3.2", + "prettier": "^3.0.3", + "tsdown": "^0.15.6", + "typescript": "^5.9.3" + } +} diff --git a/packages/plugin-react-swc/playground/base-path/__tests__/base-path.spec.ts b/packages/plugin-react-swc/playground/base-path/__tests__/base-path.spec.ts new file mode 100644 index 000000000..715eb7a7c --- /dev/null +++ b/packages/plugin-react-swc/playground/base-path/__tests__/base-path.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from '@playwright/test' +import { setupDevServer, setupWaitForLogs } from '../../utils.ts' + +test('Base path HMR', async ({ page }) => { + const { testUrl, server, editFile } = await setupDevServer('base-path') + const waitForLogs = await setupWaitForLogs(page) + await page.goto(testUrl) + + const button = page.locator('button') + await button.click() + await expect(button).toHaveText('count is 1') + + editFile('src/App.tsx', ['{count}', '{count}!']) + await waitForLogs('[vite] hot updated: /src/App.tsx') + await expect(button).toHaveText('count is 1!') + + await server.close() +}) diff --git a/packages/plugin-react-swc/playground/base-path/index.html b/packages/plugin-react-swc/playground/base-path/index.html new file mode 100644 index 000000000..75fabbb1e --- /dev/null +++ b/packages/plugin-react-swc/playground/base-path/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + base path + + +
+ + + diff --git a/packages/plugin-react-swc/playground/base-path/package.json b/packages/plugin-react-swc/playground/base-path/package.json new file mode 100644 index 000000000..ee1e7c8c5 --- /dev/null +++ b/packages/plugin-react-swc/playground/base-path/package.json @@ -0,0 +1,19 @@ +{ + "name": "playground-base-test", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react-swc": "../../dist" + } +} diff --git a/packages/plugin-react-swc/playground/base-path/public/vite.svg b/packages/plugin-react-swc/playground/base-path/public/vite.svg new file mode 100644 index 000000000..4dcd77ad0 --- /dev/null +++ b/packages/plugin-react-swc/playground/base-path/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/base-path/src/App.tsx b/packages/plugin-react-swc/playground/base-path/src/App.tsx new file mode 100644 index 000000000..d4a5f8f75 --- /dev/null +++ b/packages/plugin-react-swc/playground/base-path/src/App.tsx @@ -0,0 +1,7 @@ +import { useState } from 'react' + +export const App = () => { + const [count, setCount] = useState(0) + + return +} diff --git a/packages/plugin-react-swc/playground/base-path/src/index.tsx b/packages/plugin-react-swc/playground/base-path/src/index.tsx new file mode 100644 index 000000000..5e7046ce2 --- /dev/null +++ b/packages/plugin-react-swc/playground/base-path/src/index.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/plugin-react-swc/playground/base-path/tsconfig.json b/packages/plugin-react-swc/playground/base-path/tsconfig.json new file mode 100644 index 000000000..6a2d67033 --- /dev/null +++ b/packages/plugin-react-swc/playground/base-path/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true + } +} diff --git a/packages/plugin-react-swc/playground/base-path/vite.config.ts b/packages/plugin-react-swc/playground/base-path/vite.config.ts new file mode 100644 index 000000000..7bc60be8f --- /dev/null +++ b/packages/plugin-react-swc/playground/base-path/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [react()], + base: '/base-test/', +}) diff --git a/packages/plugin-react-swc/playground/class-components/__tests__/class-components.spec.ts b/packages/plugin-react-swc/playground/class-components/__tests__/class-components.spec.ts new file mode 100644 index 000000000..14424e927 --- /dev/null +++ b/packages/plugin-react-swc/playground/class-components/__tests__/class-components.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test' +import { setupDevServer, setupWaitForLogs } from '../../utils.ts' + +test('Class component HMR', async ({ page }) => { + const { testUrl, server, editFile } = await setupDevServer('class-components') + const waitForLogs = await setupWaitForLogs(page) + await page.goto(testUrl) + + await expect(page.locator('body')).toHaveText('Hello World') + editFile('src/App.tsx', ['World', 'class components']) + await waitForLogs('[vite] hot updated: /src/App.tsx') + await expect(page.locator('body')).toHaveText('Hello class components') + + editFile('src/utils.tsx', ['Hello', 'Hi']) + await waitForLogs('[vite] hot updated: /src/App.tsx') + await expect(page.locator('body')).toHaveText('Hi class components') + + await server.close() +}) diff --git a/packages/plugin-react-swc/playground/class-components/index.html b/packages/plugin-react-swc/playground/class-components/index.html new file mode 100644 index 000000000..f8e6bda99 --- /dev/null +++ b/packages/plugin-react-swc/playground/class-components/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + class components + + +
+ + + diff --git a/packages/plugin-react-swc/playground/class-components/package.json b/packages/plugin-react-swc/playground/class-components/package.json new file mode 100644 index 000000000..0104afa81 --- /dev/null +++ b/packages/plugin-react-swc/playground/class-components/package.json @@ -0,0 +1,19 @@ +{ + "name": "class-components", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react-swc": "../../dist" + } +} diff --git a/packages/plugin-react-swc/playground/class-components/public/vite.svg b/packages/plugin-react-swc/playground/class-components/public/vite.svg new file mode 100644 index 000000000..4dcd77ad0 --- /dev/null +++ b/packages/plugin-react-swc/playground/class-components/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/class-components/src/App.tsx b/packages/plugin-react-swc/playground/class-components/src/App.tsx new file mode 100644 index 000000000..2b2cb5558 --- /dev/null +++ b/packages/plugin-react-swc/playground/class-components/src/App.tsx @@ -0,0 +1,8 @@ +import { Component } from 'react' +import { getGetting } from './utils.tsx' + +export class App extends Component { + render() { + return {getGetting()} World + } +} diff --git a/packages/plugin-react-swc/playground/class-components/src/index.tsx b/packages/plugin-react-swc/playground/class-components/src/index.tsx new file mode 100644 index 000000000..5e7046ce2 --- /dev/null +++ b/packages/plugin-react-swc/playground/class-components/src/index.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/plugin-react-swc/playground/class-components/src/utils.tsx b/packages/plugin-react-swc/playground/class-components/src/utils.tsx new file mode 100644 index 000000000..27e61766a --- /dev/null +++ b/packages/plugin-react-swc/playground/class-components/src/utils.tsx @@ -0,0 +1 @@ +export const getGetting = () => Hello diff --git a/packages/plugin-react-swc/playground/class-components/tsconfig.json b/packages/plugin-react-swc/playground/class-components/tsconfig.json new file mode 100644 index 000000000..6a2d67033 --- /dev/null +++ b/packages/plugin-react-swc/playground/class-components/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true + } +} diff --git a/packages/plugin-react-swc/playground/class-components/vite.config.ts b/packages/plugin-react-swc/playground/class-components/vite.config.ts new file mode 100644 index 000000000..7af9f6f97 --- /dev/null +++ b/packages/plugin-react-swc/playground/class-components/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/plugin-react-swc/playground/decorators/__tests__/decorators.spec.ts b/packages/plugin-react-swc/playground/decorators/__tests__/decorators.spec.ts new file mode 100644 index 000000000..89783fc2b --- /dev/null +++ b/packages/plugin-react-swc/playground/decorators/__tests__/decorators.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from '@playwright/test' +import { setupBuildAndPreview, setupDevServer } from '../../utils.ts' + +test('Decorators build', async ({ page }) => { + const { testUrl, server } = await setupBuildAndPreview('decorators') + await page.goto(testUrl) + + await expect(page.locator('body')).toHaveText('Hello World') + + await server.httpServer.close() +}) + +test('Decorators dev', async ({ page }) => { + const { testUrl, server } = await setupDevServer('decorators') + await page.goto(testUrl) + + await expect(page.locator('body')).toHaveText('Hello World') + + await server.close() +}) diff --git a/packages/plugin-react-swc/playground/decorators/index.html b/packages/plugin-react-swc/playground/decorators/index.html new file mode 100644 index 000000000..cb20e5147 --- /dev/null +++ b/packages/plugin-react-swc/playground/decorators/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + decorators + + +
+ + + diff --git a/packages/plugin-react-swc/playground/decorators/package.json b/packages/plugin-react-swc/playground/decorators/package.json new file mode 100644 index 000000000..a126f1590 --- /dev/null +++ b/packages/plugin-react-swc/playground/decorators/package.json @@ -0,0 +1,19 @@ +{ + "name": "playground-decorators", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react-swc": "../../dist" + } +} diff --git a/packages/plugin-react-swc/playground/decorators/public/vite.svg b/packages/plugin-react-swc/playground/decorators/public/vite.svg new file mode 100644 index 000000000..4dcd77ad0 --- /dev/null +++ b/packages/plugin-react-swc/playground/decorators/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/decorators/src/App.tsx b/packages/plugin-react-swc/playground/decorators/src/App.tsx new file mode 100644 index 000000000..f8327828c --- /dev/null +++ b/packages/plugin-react-swc/playground/decorators/src/App.tsx @@ -0,0 +1,17 @@ +import type { ComponentClass } from 'react' +import { Component } from 'react' + +function decorated(target: ComponentClass) { + const original = target.prototype.render + + target.prototype.render = () => { + return
Hello {original()}
+ } +} + +@decorated +export class App extends Component { + render() { + return World + } +} diff --git a/packages/plugin-react-swc/playground/decorators/src/index.tsx b/packages/plugin-react-swc/playground/decorators/src/index.tsx new file mode 100644 index 000000000..5e7046ce2 --- /dev/null +++ b/packages/plugin-react-swc/playground/decorators/src/index.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/plugin-react-swc/playground/decorators/tsconfig.json b/packages/plugin-react-swc/playground/decorators/tsconfig.json new file mode 100644 index 000000000..e027db2c3 --- /dev/null +++ b/packages/plugin-react-swc/playground/decorators/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true + } +} diff --git a/packages/plugin-react-swc/playground/decorators/vite.config.ts b/packages/plugin-react-swc/playground/decorators/vite.config.ts new file mode 100644 index 000000000..a2d1b8860 --- /dev/null +++ b/packages/plugin-react-swc/playground/decorators/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [react({ tsDecorators: true })], +}) diff --git a/packages/plugin-react-swc/playground/emotion-plugin/__tests__/emotion-plugin.spec.ts b/packages/plugin-react-swc/playground/emotion-plugin/__tests__/emotion-plugin.spec.ts new file mode 100644 index 000000000..9fcdd8be8 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion-plugin/__tests__/emotion-plugin.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test' +import { + expectColor, + setupBuildAndPreview, + setupDevServer, + setupWaitForLogs, +} from '../../utils.ts' + +test('Emotion plugin build', async ({ page }) => { + const { testUrl, server } = await setupBuildAndPreview('emotion-plugin') + await page.goto(testUrl) + + const button = page.locator('button') + await button.hover() + await expectColor(button, 'color', '#646cff') + + await button.click() + await expect(button).toHaveText('count is 1') + + const code = page.locator('code') + await expectColor(code, 'color', '#646cff') + + await server.httpServer.close() +}) + +test('Emotion plugin HMR', async ({ page }) => { + const { testUrl, server, editFile } = await setupDevServer('emotion-plugin') + const waitForLogs = await setupWaitForLogs(page) + await page.goto(testUrl) + await waitForLogs('[vite] connected.') + + const button = page.locator('button') + await button.hover() + await expectColor(button, 'color', '#646cff') + + await button.click() + await expect(button).toHaveText('count is 1') + + const code = page.locator('code') + await expectColor(code, 'color', '#646cff') + + editFile('src/Button.jsx', [ + 'background-color: #d26ac2;', + 'background-color: #646cff;', + ]) + await waitForLogs('[vite] hot updated: /src/Button.jsx') + await expect(button).toHaveText('count is 1') + await expectColor(button, 'backgroundColor', '#646cff') + + editFile('src/App.jsx', ['color="#646cff"', 'color="#d26ac2"']) + await waitForLogs('[vite] hot updated: /src/App.jsx') + await expect(button).toHaveText('count is 1') + await expectColor(button, 'color', '#d26ac2') + + editFile('src/Button.jsx', ['color: #646cff;', 'color: #d26ac2;']) + await waitForLogs('[vite] hot updated: /src/Button.jsx') + await expect(button).toHaveText('count is 1') + await expectColor(code, 'color', '#d26ac2') + + await server.close() +}) diff --git a/packages/plugin-react-swc/playground/emotion-plugin/index.html b/packages/plugin-react-swc/playground/emotion-plugin/index.html new file mode 100644 index 000000000..b42e2d1d4 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion-plugin/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + Emotion + + +
+ + + diff --git a/packages/plugin-react-swc/playground/emotion-plugin/package.json b/packages/plugin-react-swc/playground/emotion-plugin/package.json new file mode 100644 index 000000000..54eef5362 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion-plugin/package.json @@ -0,0 +1,22 @@ +{ + "name": "playground-emotion-plugin", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@swc/plugin-emotion": "^11.1.0", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react-swc": "../../dist" + } +} diff --git a/packages/plugin-react-swc/playground/emotion-plugin/public/vite.svg b/packages/plugin-react-swc/playground/emotion-plugin/public/vite.svg new file mode 100644 index 000000000..4dcd77ad0 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion-plugin/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/emotion-plugin/src/App.css b/packages/plugin-react-swc/playground/emotion-plugin/src/App.css new file mode 100644 index 000000000..e9f861b1b --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion-plugin/src/App.css @@ -0,0 +1,26 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.emotion:hover { + filter: drop-shadow(0 0 2em #d26ac2aa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/packages/plugin-react-swc/playground/emotion-plugin/src/App.jsx b/packages/plugin-react-swc/playground/emotion-plugin/src/App.jsx new file mode 100644 index 000000000..44f5fd166 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion-plugin/src/App.jsx @@ -0,0 +1,28 @@ +import './App.css' +import { Button, StyledCode } from './Button.jsx' + +export const App = () => ( +
+ +
+
+

+ Click on the Vite and Emotion logos to learn more +

+
+) diff --git a/packages/plugin-react-swc/playground/emotion-plugin/src/Button.jsx b/packages/plugin-react-swc/playground/emotion-plugin/src/Button.jsx new file mode 100644 index 000000000..56c363572 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion-plugin/src/Button.jsx @@ -0,0 +1,30 @@ +import styled from '@emotion/styled' +import { css } from '@emotion/react' +import { useState } from 'react' + +// Ensure HMR of styled component alongside other components +export const StyledCode = styled.code` + color: #646cff; +` + +export const Button = ({ color }) => { + const [count, setCount] = useState(0) + + return ( + + ) +} diff --git a/packages/plugin-react-swc/playground/emotion-plugin/src/index.css b/packages/plugin-react-swc/playground/emotion-plugin/src/index.css new file mode 100644 index 000000000..8d79b2457 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion-plugin/src/index.css @@ -0,0 +1,24 @@ +:root { + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + 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; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} diff --git a/packages/plugin-react-swc/playground/emotion-plugin/src/index.jsx b/packages/plugin-react-swc/playground/emotion-plugin/src/index.jsx new file mode 100644 index 000000000..67f3599ee --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion-plugin/src/index.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App.jsx' +import './index.css' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/packages/plugin-react-swc/playground/emotion-plugin/vite.config.js b/packages/plugin-react-swc/playground/emotion-plugin/vite.config.js new file mode 100644 index 000000000..98ac529c9 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion-plugin/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [ + react({ + jsxImportSource: '@emotion/react', + plugins: [['@swc/plugin-emotion', {}]], + }), + ], +}) diff --git a/packages/plugin-react-swc/playground/emotion/__tests__/emotion.spec.ts b/packages/plugin-react-swc/playground/emotion/__tests__/emotion.spec.ts new file mode 100644 index 000000000..455fab7ba --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion/__tests__/emotion.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from '@playwright/test' +import { + expectColor, + setupBuildAndPreview, + setupDevServer, + setupWaitForLogs, +} from '../../utils.ts' + +test('Emotion build', async ({ page }) => { + const { testUrl, server } = await setupBuildAndPreview('emotion') + await page.goto(testUrl) + + const button = page.locator('button') + await button.hover() + await expectColor(button, 'color', '#646cff') + + await button.click() + await expect(button).toHaveText('count is 1') + + const code = page.locator('code') + await expectColor(code, 'color', '#646cff') + + await server.httpServer.close() +}) + +test('Emotion HMR', async ({ page }) => { + const { testUrl, server, editFile } = await setupDevServer('emotion') + const waitForLogs = await setupWaitForLogs(page) + await page.goto(testUrl) + await waitForLogs('[vite] connected.') + + const button = page.locator('button') + await button.hover() + await expectColor(button, 'color', '#646cff') + + await button.click() + await expect(button).toHaveText('count is 1') + + const code = page.locator('code') + await expectColor(code, 'color', '#646cff') + + editFile('src/Button.tsx', [ + 'background-color: #d26ac2;', + 'background-color: #646cff;', + ]) + await waitForLogs('[vite] hot updated: /src/Button.tsx') + await expect(button).toHaveText('count is 1') + await expectColor(button, 'backgroundColor', '#646cff') + + editFile('src/App.tsx', ['color="#646cff"', 'color="#d26ac2"']) + await waitForLogs('[vite] hot updated: /src/App.tsx') + await expect(button).toHaveText('count is 1') + await expectColor(button, 'color', '#d26ac2') + + editFile('src/Button.tsx', ['color: #646cff;', 'color: #d26ac2;']) + await waitForLogs('[vite] hot updated: /src/Button.tsx') + await expect(button).toHaveText('count is 1') + await expectColor(code, 'color', '#d26ac2') + + await server.close() +}) diff --git a/packages/plugin-react-swc/playground/emotion/index.html b/packages/plugin-react-swc/playground/emotion/index.html new file mode 100644 index 000000000..add7c83ca --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + Emotion + + +
+ + + diff --git a/packages/plugin-react-swc/playground/emotion/package.json b/packages/plugin-react-swc/playground/emotion/package.json new file mode 100644 index 000000000..c671123bb --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion/package.json @@ -0,0 +1,21 @@ +{ + "name": "playground-emotion", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react-swc": "../../dist" + } +} diff --git a/packages/plugin-react-swc/playground/emotion/public/vite.svg b/packages/plugin-react-swc/playground/emotion/public/vite.svg new file mode 100644 index 000000000..4dcd77ad0 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/emotion/src/App.css b/packages/plugin-react-swc/playground/emotion/src/App.css new file mode 100644 index 000000000..e9f861b1b --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion/src/App.css @@ -0,0 +1,26 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.emotion:hover { + filter: drop-shadow(0 0 2em #d26ac2aa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/packages/plugin-react-swc/playground/emotion/src/App.tsx b/packages/plugin-react-swc/playground/emotion/src/App.tsx new file mode 100644 index 000000000..73f71fcb3 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion/src/App.tsx @@ -0,0 +1,28 @@ +import './App.css' +import { Button, StyledCode } from './Button.tsx' + +export const App = () => ( +
+ +
+
+

+ Click on the Vite and Emotion logos to learn more +

+
+) diff --git a/packages/plugin-react-swc/playground/emotion/src/Button.tsx b/packages/plugin-react-swc/playground/emotion/src/Button.tsx new file mode 100644 index 000000000..8bd3ad6e3 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion/src/Button.tsx @@ -0,0 +1,30 @@ +import styled from '@emotion/styled' +import { css } from '@emotion/react' +import { useState } from 'react' + +// Ensure HMR of styled component alongside other components +export const StyledCode = styled.code` + color: #646cff; +` + +export const Button = ({ color }: { color: string }) => { + const [count, setCount] = useState(0) + + return ( + + ) +} diff --git a/packages/plugin-react-swc/playground/emotion/src/index.css b/packages/plugin-react-swc/playground/emotion/src/index.css new file mode 100644 index 000000000..8d79b2457 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion/src/index.css @@ -0,0 +1,24 @@ +:root { + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + 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; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} diff --git a/packages/plugin-react-swc/playground/emotion/src/index.tsx b/packages/plugin-react-swc/playground/emotion/src/index.tsx new file mode 100644 index 000000000..ee852372c --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion/src/index.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App.tsx' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/plugin-react-swc/playground/emotion/tsconfig.json b/packages/plugin-react-swc/playground/emotion/tsconfig.json new file mode 100644 index 000000000..ce4174207 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "jsx": "react-jsx", + "jsxImportSource": "@emotion/react", + "types": ["vite/client", "@emotion/react"], + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true + } +} diff --git a/packages/plugin-react-swc/playground/emotion/vite.config.ts b/packages/plugin-react-swc/playground/emotion/vite.config.ts new file mode 100644 index 000000000..cfb4c0283 --- /dev/null +++ b/packages/plugin-react-swc/playground/emotion/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [react({ jsxImportSource: '@emotion/react' })], +}) diff --git a/packages/plugin-react-swc/playground/hmr/__tests__/hmr.spec.ts b/packages/plugin-react-swc/playground/hmr/__tests__/hmr.spec.ts new file mode 100644 index 000000000..714bf8a5d --- /dev/null +++ b/packages/plugin-react-swc/playground/hmr/__tests__/hmr.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test' +import { + setupBuildAndPreview, + setupDevServer, + setupWaitForLogs, +} from '../../utils.ts' + +test('Default build', async ({ page }) => { + const { testUrl, server } = await setupBuildAndPreview('hmr') + await page.goto(testUrl) + + await page.click('button') + await expect(page.locator('button')).toHaveText('count is 1') + + await server.httpServer.close() +}) + +test('HMR invalidate', async ({ page }) => { + const { testUrl, server, editFile } = await setupDevServer('hmr') + const waitForLogs = await setupWaitForLogs(page) + await page.goto(testUrl) + await waitForLogs('[vite] connected.') + + await page.click('button') + await expect(page.locator('button')).toHaveText('count is 1') + + editFile('src/App.tsx', ['{count}', '{count}!']) + await waitForLogs('[vite] hot updated: /src/App.tsx') + await expect(page.locator('button')).toHaveText('count is 1!') + + // Edit component + editFile('src/TitleWithExport.tsx', ['Vite +', 'Vite *']) + await waitForLogs('[vite] hot updated: /src/TitleWithExport.tsx') + + // Edit export + editFile('src/TitleWithExport.tsx', ['React', 'React!']) + await waitForLogs( + '[vite] invalidate /src/TitleWithExport.tsx: Could not Fast Refresh ("framework" export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-swc#consistent-components-exports', + '[vite] hot updated: /src/App.tsx', + ) + await expect(page.locator('h1')).toHaveText('Vite * React!') + + // Add non-component export + editFile('src/TitleWithExport.tsx', [ + "React!'", + "React!'\nexport const useless = 3", + ]) + await waitForLogs( + '[vite] invalidate /src/TitleWithExport.tsx: Could not Fast Refresh (new export)', + '[vite] hot updated: /src/App.tsx', + ) + + // Add component export + editFile('src/TitleWithExport.tsx', [ + '', + '\nexport const Title2 = () =>

Title2

', + ]) + await waitForLogs('[vite] hot updated: /src/TitleWithExport.tsx') + + // Import new component + editFile( + 'src/App.tsx', + ['import { TitleWithExport', 'import { TitleWithExport, Title2'], + ['', ' '], + ) + await waitForLogs('[vite] hot updated: /src/App.tsx') + await expect(page.locator('h2')).toHaveText('Title2') + + // Remove component export + editFile('src/TitleWithExport.tsx', [ + '\nexport const Title2 = () =>

Title2

', + '', + ]) + await waitForLogs( + '[vite] invalidate /src/TitleWithExport.tsx: Could not Fast Refresh (export removed)', + '[vite] hot updated: /src/App.tsx', + /Failed to reload \/src\/App\.tsx. This could be due to syntax errors or importing non-existent modules\. \(see errors above\)$/, + ) + + // Remove usage from App + editFile( + 'src/App.tsx', + ['import { TitleWithExport, Title2', 'import { TitleWithExport'], + [' ', ''], + ) + await waitForLogs('[vite] hot updated: /src/App.tsx') + await expect(page.locator('button')).toHaveText('count is 1!') + + // Remove useless export + editFile('src/TitleWithExport.tsx', ['\nexport const useless = 3', '']) + await waitForLogs( + '[vite] invalidate /src/TitleWithExport.tsx: Could not Fast Refresh (export removed)', + '[vite] hot updated: /src/App.tsx', + ) + + await server.close() +}) diff --git a/packages/plugin-react-swc/playground/hmr/index.html b/packages/plugin-react-swc/playground/hmr/index.html new file mode 100644 index 000000000..f828b817d --- /dev/null +++ b/packages/plugin-react-swc/playground/hmr/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/packages/plugin-react-swc/playground/hmr/package.json b/packages/plugin-react-swc/playground/hmr/package.json new file mode 100644 index 000000000..02f69f28b --- /dev/null +++ b/packages/plugin-react-swc/playground/hmr/package.json @@ -0,0 +1,19 @@ +{ + "name": "playground-hmr", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react-swc": "../../dist" + } +} diff --git a/packages/plugin-react-swc/playground/hmr/public/vite.svg b/packages/plugin-react-swc/playground/hmr/public/vite.svg new file mode 100644 index 000000000..4dcd77ad0 --- /dev/null +++ b/packages/plugin-react-swc/playground/hmr/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/hmr/src/App.css b/packages/plugin-react-swc/playground/hmr/src/App.css new file mode 100644 index 000000000..2c5e2ef5c --- /dev/null +++ b/packages/plugin-react-swc/playground/hmr/src/App.css @@ -0,0 +1,41 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; +} +.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: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/packages/plugin-react-swc/playground/hmr/src/App.tsx b/packages/plugin-react-swc/playground/hmr/src/App.tsx new file mode 100644 index 000000000..b97d2d061 --- /dev/null +++ b/packages/plugin-react-swc/playground/hmr/src/App.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react' +import reactLogo from './react.svg' +import './App.css' +import { TitleWithExport, framework } from './TitleWithExport.tsx' + +export const App = () => { + const [count, setCount] = useState(0) + + return ( +
+ + +
+ +

+ Edit src/App.tsx and save to test HMR +

+
+

+ Click on the Vite and {framework} logos to learn more +

+
+ ) +} diff --git a/packages/plugin-react-swc/playground/hmr/src/TitleWithExport.tsx b/packages/plugin-react-swc/playground/hmr/src/TitleWithExport.tsx new file mode 100644 index 000000000..c0173bc18 --- /dev/null +++ b/packages/plugin-react-swc/playground/hmr/src/TitleWithExport.tsx @@ -0,0 +1,3 @@ +export const framework = 'React' + +export const TitleWithExport = () =>

Vite + {framework}

diff --git a/packages/plugin-react-swc/playground/hmr/src/index.css b/packages/plugin-react-swc/playground/hmr/src/index.css new file mode 100644 index 000000000..917888c1d --- /dev/null +++ b/packages/plugin-react-swc/playground/hmr/src/index.css @@ -0,0 +1,70 @@ +:root { + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + 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; + -webkit-text-size-adjust: 100%; +} + +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; + } +} diff --git a/packages/plugin-react-swc/playground/hmr/src/index.tsx b/packages/plugin-react-swc/playground/hmr/src/index.tsx new file mode 100644 index 000000000..ee852372c --- /dev/null +++ b/packages/plugin-react-swc/playground/hmr/src/index.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App.tsx' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/plugin-react-swc/playground/hmr/src/react.svg b/packages/plugin-react-swc/playground/hmr/src/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/plugin-react-swc/playground/hmr/src/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/hmr/tsconfig.json b/packages/plugin-react-swc/playground/hmr/tsconfig.json new file mode 100644 index 000000000..6a2d67033 --- /dev/null +++ b/packages/plugin-react-swc/playground/hmr/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true + } +} diff --git a/packages/plugin-react-swc/playground/hmr/vite.config.ts b/packages/plugin-react-swc/playground/hmr/vite.config.ts new file mode 100644 index 000000000..7af9f6f97 --- /dev/null +++ b/packages/plugin-react-swc/playground/hmr/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/plugin-react-swc/playground/mdx/__tests__/mdx.spec.ts b/packages/plugin-react-swc/playground/mdx/__tests__/mdx.spec.ts new file mode 100644 index 000000000..41562b946 --- /dev/null +++ b/packages/plugin-react-swc/playground/mdx/__tests__/mdx.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' +import { + setupBuildAndPreview, + setupDevServer, + setupWaitForLogs, +} from '../../utils.ts' + +test('MDX build', async ({ page }) => { + const { testUrl, server } = await setupBuildAndPreview('mdx') + await page.goto(testUrl) + await expect(page.getByRole('heading', { name: 'Hello' })).toBeVisible() + await server.httpServer.close() +}) + +test('MDX HMR', async ({ page }) => { + const { testUrl, server, editFile } = await setupDevServer('mdx') + const waitForLogs = await setupWaitForLogs(page) + await page.goto(testUrl) + await waitForLogs('[vite] connected.') + + await expect(page.getByRole('heading', { name: 'Hello' })).toBeVisible() + + editFile('src/Counter.tsx', ['{count}', '{count}!']) + await waitForLogs('[vite] hot updated: /src/Counter.tsx') + const button = await page.locator('button') + await button.click() + await expect(button).toHaveText('count is 1!') + + editFile('src/hello.mdx', ['Hello', 'Hello world']) + await waitForLogs('[vite] hot updated: /src/hello.mdx') + await expect(page.getByRole('heading', { name: 'Hello world' })).toBeVisible() + await expect(button).toHaveText('count is 1!') + + await server.close() +}) diff --git a/packages/plugin-react-swc/playground/mdx/index.html b/packages/plugin-react-swc/playground/mdx/index.html new file mode 100644 index 000000000..167faa8c4 --- /dev/null +++ b/packages/plugin-react-swc/playground/mdx/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + MDX + + +
+ + + diff --git a/packages/plugin-react-swc/playground/mdx/package.json b/packages/plugin-react-swc/playground/mdx/package.json new file mode 100644 index 000000000..5c3f13a43 --- /dev/null +++ b/packages/plugin-react-swc/playground/mdx/package.json @@ -0,0 +1,20 @@ +{ + "name": "playground-mdx", + "type": "module", + "private": true, + "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.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react-swc": "../../dist" + } +} diff --git a/packages/plugin-react-swc/playground/mdx/public/vite.svg b/packages/plugin-react-swc/playground/mdx/public/vite.svg new file mode 100644 index 000000000..4dcd77ad0 --- /dev/null +++ b/packages/plugin-react-swc/playground/mdx/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/mdx/src/Counter.tsx b/packages/plugin-react-swc/playground/mdx/src/Counter.tsx new file mode 100644 index 000000000..a415e55ed --- /dev/null +++ b/packages/plugin-react-swc/playground/mdx/src/Counter.tsx @@ -0,0 +1,7 @@ +import { useState } from 'react' + +export const Counter = () => { + const [count, setCount] = useState(0) + + return +} diff --git a/packages/plugin-react-swc/playground/mdx/src/env.d.ts b/packages/plugin-react-swc/playground/mdx/src/env.d.ts new file mode 100644 index 000000000..72b58495e --- /dev/null +++ b/packages/plugin-react-swc/playground/mdx/src/env.d.ts @@ -0,0 +1,4 @@ +declare module '*.mdx' { + import { JSX } from 'react' + export default () => JSX.Element +} diff --git a/packages/plugin-react-swc/playground/mdx/src/hello.mdx b/packages/plugin-react-swc/playground/mdx/src/hello.mdx new file mode 100644 index 000000000..4e3922803 --- /dev/null +++ b/packages/plugin-react-swc/playground/mdx/src/hello.mdx @@ -0,0 +1,7 @@ +import { Counter } from './Counter.tsx' + +# Hello + +This text is written in Markdown. + +MDX allows Rich React components to be used directly in Markdown: diff --git a/packages/plugin-react-swc/playground/mdx/src/index.tsx b/packages/plugin-react-swc/playground/mdx/src/index.tsx new file mode 100644 index 000000000..952de6f5c --- /dev/null +++ b/packages/plugin-react-swc/playground/mdx/src/index.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import Hello from './hello.mdx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/plugin-react-swc/playground/mdx/tsconfig.json b/packages/plugin-react-swc/playground/mdx/tsconfig.json new file mode 100644 index 000000000..6a2d67033 --- /dev/null +++ b/packages/plugin-react-swc/playground/mdx/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true + } +} diff --git a/packages/plugin-react-swc/playground/mdx/vite.config.ts b/packages/plugin-react-swc/playground/mdx/vite.config.ts new file mode 100644 index 000000000..ff4f655ad --- /dev/null +++ b/packages/plugin-react-swc/playground/mdx/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import mdx from '@mdx-js/rollup' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [mdx(), react()], +}) diff --git a/packages/plugin-react-swc/playground/react-18/__tests__/react-18.spec.ts b/packages/plugin-react-swc/playground/react-18/__tests__/react-18.spec.ts new file mode 100644 index 000000000..2902307fc --- /dev/null +++ b/packages/plugin-react-swc/playground/react-18/__tests__/react-18.spec.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test' +import { + setupBuildAndPreview, + setupDevServer, + setupWaitForLogs, +} from '../../utils.ts' + +test('Default build', async ({ page }) => { + const { testUrl, server } = await setupBuildAndPreview('react-18') + await page.goto(testUrl) + + await page.click('button') + await expect(page.locator('button')).toHaveText('count is 1') + + await server.httpServer.close() +}) + +test('HMR invalidate', async ({ page }) => { + const { testUrl, server, editFile } = await setupDevServer('react-18') + const waitForLogs = await setupWaitForLogs(page) + await page.goto(testUrl) + await waitForLogs('[vite] connected.') + + await page.click('button') + await expect(page.locator('button')).toHaveText('count is 1') + + editFile('src/App.tsx', ['{count}', '{count}!']) + await waitForLogs('[vite] hot updated: /src/App.tsx') + await expect(page.locator('button')).toHaveText('count is 1!') + + // Edit component + editFile('src/TitleWithExport.tsx', ['Vite +', 'Vite *']) + await waitForLogs('[vite] hot updated: /src/TitleWithExport.tsx') + + // Edit export + editFile('src/TitleWithExport.tsx', ['React', 'React!']) + await waitForLogs( + '[vite] invalidate /src/TitleWithExport.tsx: Could not Fast Refresh ("framework" export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react-swc#consistent-components-exports', + '[vite] hot updated: /src/App.tsx', + ) + await expect(page.locator('h1')).toHaveText('Vite * React!') + + // Add non-component export + editFile('src/TitleWithExport.tsx', [ + "React!'", + "React!'\nexport const useless = 3", + ]) + await waitForLogs( + '[vite] invalidate /src/TitleWithExport.tsx: Could not Fast Refresh (new export)', + '[vite] hot updated: /src/App.tsx', + ) + + // Add component export + editFile('src/TitleWithExport.tsx', [ + '', + '\nexport const Title2 = () =>

Title2

', + ]) + await waitForLogs('[vite] hot updated: /src/TitleWithExport.tsx') + + // Import new component + editFile( + 'src/App.tsx', + ['import { TitleWithExport', 'import { TitleWithExport, Title2'], + ['', ' '], + ) + await waitForLogs('[vite] hot updated: /src/App.tsx') + await expect(page.locator('h2')).toHaveText('Title2') + + // Remove component export + editFile('src/TitleWithExport.tsx', [ + '\nexport const Title2 = () =>

Title2

', + '', + ]) + await waitForLogs( + '[vite] invalidate /src/TitleWithExport.tsx: Could not Fast Refresh (export removed)', + '[vite] hot updated: /src/App.tsx', + /Failed to reload \/src\/App\.tsx. This could be due to syntax errors or importing non-existent modules\. \(see errors above\)$/, + ) + + // Remove usage from App + editFile( + 'src/App.tsx', + ['import { TitleWithExport, Title2', 'import { TitleWithExport'], + [' ', ''], + ) + await waitForLogs('[vite] hot updated: /src/App.tsx') + await expect(page.locator('button')).toHaveText('count is 1!') + + // Remove useless export + editFile('src/TitleWithExport.tsx', ['\nexport const useless = 3', '']) + await waitForLogs( + '[vite] invalidate /src/TitleWithExport.tsx: Could not Fast Refresh (export removed)', + '[vite] hot updated: /src/App.tsx', + ) + + await server.close() +}) diff --git a/packages/plugin-react-swc/playground/react-18/index.html b/packages/plugin-react-swc/playground/react-18/index.html new file mode 100644 index 000000000..f828b817d --- /dev/null +++ b/packages/plugin-react-swc/playground/react-18/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/packages/plugin-react-swc/playground/react-18/package.json b/packages/plugin-react-swc/playground/react-18/package.json new file mode 100644 index 000000000..f2fd31476 --- /dev/null +++ b/packages/plugin-react-swc/playground/react-18/package.json @@ -0,0 +1,19 @@ +{ + "name": "playground-react-18", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react-swc": "../../dist" + } +} diff --git a/packages/plugin-react-swc/playground/react-18/public/vite.svg b/packages/plugin-react-swc/playground/react-18/public/vite.svg new file mode 100644 index 000000000..4dcd77ad0 --- /dev/null +++ b/packages/plugin-react-swc/playground/react-18/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/react-18/src/App.css b/packages/plugin-react-swc/playground/react-18/src/App.css new file mode 100644 index 000000000..2c5e2ef5c --- /dev/null +++ b/packages/plugin-react-swc/playground/react-18/src/App.css @@ -0,0 +1,41 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; +} +.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: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/packages/plugin-react-swc/playground/react-18/src/App.tsx b/packages/plugin-react-swc/playground/react-18/src/App.tsx new file mode 100644 index 000000000..b97d2d061 --- /dev/null +++ b/packages/plugin-react-swc/playground/react-18/src/App.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react' +import reactLogo from './react.svg' +import './App.css' +import { TitleWithExport, framework } from './TitleWithExport.tsx' + +export const App = () => { + const [count, setCount] = useState(0) + + return ( +
+ + +
+ +

+ Edit src/App.tsx and save to test HMR +

+
+

+ Click on the Vite and {framework} logos to learn more +

+
+ ) +} diff --git a/packages/plugin-react-swc/playground/react-18/src/TitleWithExport.tsx b/packages/plugin-react-swc/playground/react-18/src/TitleWithExport.tsx new file mode 100644 index 000000000..c0173bc18 --- /dev/null +++ b/packages/plugin-react-swc/playground/react-18/src/TitleWithExport.tsx @@ -0,0 +1,3 @@ +export const framework = 'React' + +export const TitleWithExport = () =>

Vite + {framework}

diff --git a/packages/plugin-react-swc/playground/react-18/src/index.css b/packages/plugin-react-swc/playground/react-18/src/index.css new file mode 100644 index 000000000..917888c1d --- /dev/null +++ b/packages/plugin-react-swc/playground/react-18/src/index.css @@ -0,0 +1,70 @@ +:root { + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + 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; + -webkit-text-size-adjust: 100%; +} + +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; + } +} diff --git a/packages/plugin-react-swc/playground/react-18/src/index.tsx b/packages/plugin-react-swc/playground/react-18/src/index.tsx new file mode 100644 index 000000000..ee852372c --- /dev/null +++ b/packages/plugin-react-swc/playground/react-18/src/index.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App.tsx' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/plugin-react-swc/playground/react-18/src/react.svg b/packages/plugin-react-swc/playground/react-18/src/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/packages/plugin-react-swc/playground/react-18/src/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/react-18/tsconfig.json b/packages/plugin-react-swc/playground/react-18/tsconfig.json new file mode 100644 index 000000000..6a2d67033 --- /dev/null +++ b/packages/plugin-react-swc/playground/react-18/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true + } +} diff --git a/packages/plugin-react-swc/playground/react-18/vite.config.ts b/packages/plugin-react-swc/playground/react-18/vite.config.ts new file mode 100644 index 000000000..7af9f6f97 --- /dev/null +++ b/packages/plugin-react-swc/playground/react-18/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/plugin-react-swc/playground/shadow-export/__tests__/shadow-export.spec.ts b/packages/plugin-react-swc/playground/shadow-export/__tests__/shadow-export.spec.ts new file mode 100644 index 000000000..b1aadffb9 --- /dev/null +++ b/packages/plugin-react-swc/playground/shadow-export/__tests__/shadow-export.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from '@playwright/test' +import { setupDevServer, setupWaitForLogs } from '../../utils.ts' + +test('Shadow export HMR', async ({ page }) => { + const { testUrl, server, editFile } = await setupDevServer('shadow-export') + const waitForLogs = await setupWaitForLogs(page) + await page.goto(testUrl) + await waitForLogs('[vite] connected.') + await expect(page.locator('body')).toHaveText('Shadow export') + + editFile('src/App.tsx', ['Shadow export', 'Shadow export updates!']) + await expect(page.locator('body')).toHaveText('Shadow export updates!') + + await server.close() +}) diff --git a/packages/plugin-react-swc/playground/shadow-export/index.html b/packages/plugin-react-swc/playground/shadow-export/index.html new file mode 100644 index 000000000..de754ff17 --- /dev/null +++ b/packages/plugin-react-swc/playground/shadow-export/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + shadow export + + +
+ + + diff --git a/packages/plugin-react-swc/playground/shadow-export/package.json b/packages/plugin-react-swc/playground/shadow-export/package.json new file mode 100644 index 000000000..31f57b21f --- /dev/null +++ b/packages/plugin-react-swc/playground/shadow-export/package.json @@ -0,0 +1,19 @@ +{ + "name": "playground-shadow-export", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react-swc": "../../dist" + } +} diff --git a/packages/plugin-react-swc/playground/shadow-export/public/vite.svg b/packages/plugin-react-swc/playground/shadow-export/public/vite.svg new file mode 100644 index 000000000..4dcd77ad0 --- /dev/null +++ b/packages/plugin-react-swc/playground/shadow-export/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/shadow-export/src/App.tsx b/packages/plugin-react-swc/playground/shadow-export/src/App.tsx new file mode 100644 index 000000000..764fa55e9 --- /dev/null +++ b/packages/plugin-react-swc/playground/shadow-export/src/App.tsx @@ -0,0 +1,11 @@ +import { memo } from 'react' + +function App() { + return
Shadow export
+} + +// For anyone reading this, don't do that +// Use PascalCase for all components and export them directly without rename, +// you're just making grep more complex. +const withMemo = memo(App) +export { withMemo as App } diff --git a/packages/plugin-react-swc/playground/shadow-export/src/index.tsx b/packages/plugin-react-swc/playground/shadow-export/src/index.tsx new file mode 100644 index 000000000..5e7046ce2 --- /dev/null +++ b/packages/plugin-react-swc/playground/shadow-export/src/index.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/plugin-react-swc/playground/shadow-export/tsconfig.json b/packages/plugin-react-swc/playground/shadow-export/tsconfig.json new file mode 100644 index 000000000..6a2d67033 --- /dev/null +++ b/packages/plugin-react-swc/playground/shadow-export/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true + } +} diff --git a/packages/plugin-react-swc/playground/shadow-export/vite.config.ts b/packages/plugin-react-swc/playground/shadow-export/vite.config.ts new file mode 100644 index 000000000..7af9f6f97 --- /dev/null +++ b/packages/plugin-react-swc/playground/shadow-export/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/plugin-react-swc/playground/styled-components/__tests__/styled-components.spec.ts b/packages/plugin-react-swc/playground/styled-components/__tests__/styled-components.spec.ts new file mode 100644 index 000000000..da8e44713 --- /dev/null +++ b/packages/plugin-react-swc/playground/styled-components/__tests__/styled-components.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from '@playwright/test' +import { + expectColor, + setupBuildAndPreview, + setupDevServer, + setupWaitForLogs, +} from '../../utils.ts' + +test('styled-components build', async ({ page }) => { + const { testUrl, server } = await setupBuildAndPreview('styled-components') + await page.goto(testUrl) + + const button = page.locator('button') + await button.click() + await expect(button).toHaveText('count is 1') + await expectColor(button, 'color', '#ffffff') + + const code = page.locator('code') + await expectColor(code, 'color', '#db7093') + await expect(code).toHaveClass(/Button__StyledCode/) + + await server.httpServer.close() +}) + +test('styled-components HMR', async ({ page }) => { + const { testUrl, server, editFile } = + await setupDevServer('styled-components') + const waitForLogs = await setupWaitForLogs(page) + await page.goto(testUrl) + await waitForLogs('[vite] connected.') + + const button = page.locator('button') + await expect(button).toHaveText('count is 0', { timeout: 30000 }) + await expectColor(button, 'color', '#ffffff') + await button.click() + await expect(button).toHaveText('count is 1') + + const code = page.locator('code') + await expectColor(code, 'color', '#db7093') + await expect(code).toHaveClass(/Button__StyledCode/) + + editFile('src/App.tsx', ['', '']) + await waitForLogs('[vite] hot updated: /src/App.tsx') + await expect(button).toHaveText('count is 1') + await expectColor(button, 'color', '#000000') + + editFile('src/Button.tsx', ['color: black;', 'color: palevioletred;']) + await waitForLogs('[vite] hot updated: /src/Button.tsx') + await expect(button).toHaveText('count is 1') + await expectColor(button, 'color', '#db7093') + + editFile('src/Button.tsx', ['color: palevioletred;', 'color: white;']) + await waitForLogs('[vite] hot updated: /src/Button.tsx') + await expect(button).toHaveText('count is 1') + await expectColor(code, 'color', '#ffffff') + + await server.close() +}) diff --git a/packages/plugin-react-swc/playground/styled-components/index.html b/packages/plugin-react-swc/playground/styled-components/index.html new file mode 100644 index 000000000..6ab3796f1 --- /dev/null +++ b/packages/plugin-react-swc/playground/styled-components/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + Styled Components + + +
+ + + diff --git a/packages/plugin-react-swc/playground/styled-components/package.json b/packages/plugin-react-swc/playground/styled-components/package.json new file mode 100644 index 000000000..a638e3266 --- /dev/null +++ b/packages/plugin-react-swc/playground/styled-components/package.json @@ -0,0 +1,22 @@ +{ + "name": "playground-styled-components", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1", + "styled-components": "^6.1.19" + }, + "devDependencies": { + "@swc/plugin-styled-components": "^9.1.0", + "@types/react": "^19.1.16", + "@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/styled-components/public/vite.svg b/packages/plugin-react-swc/playground/styled-components/public/vite.svg new file mode 100644 index 000000000..4dcd77ad0 --- /dev/null +++ b/packages/plugin-react-swc/playground/styled-components/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/styled-components/src/App.css b/packages/plugin-react-swc/playground/styled-components/src/App.css new file mode 100644 index 000000000..7021891dd --- /dev/null +++ b/packages/plugin-react-swc/playground/styled-components/src/App.css @@ -0,0 +1,26 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.styled-components:hover { + filter: drop-shadow(0 0 2em #db7093aa); +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/packages/plugin-react-swc/playground/styled-components/src/App.tsx b/packages/plugin-react-swc/playground/styled-components/src/App.tsx new file mode 100644 index 000000000..f00df29ed --- /dev/null +++ b/packages/plugin-react-swc/playground/styled-components/src/App.tsx @@ -0,0 +1,28 @@ +import './App.css' +import { Counter, StyledCode } from './Button.tsx' + +export const App = () => ( +
+ +
+ +

+ Edit src/Button.tsx and save to test HMR +

+
+

+ Click on the Vite and Styled Components logos to learn more +

+
+) diff --git a/packages/plugin-react-swc/playground/styled-components/src/Button.tsx b/packages/plugin-react-swc/playground/styled-components/src/Button.tsx new file mode 100644 index 000000000..44be3f5bf --- /dev/null +++ b/packages/plugin-react-swc/playground/styled-components/src/Button.tsx @@ -0,0 +1,31 @@ +import styled, { css } from 'styled-components' +import { useState } from 'react' + +// Ensure HMR of styled component alongside other components +export const StyledCode = styled.code` + color: palevioletred; +` + +const Button = styled.button` + border-radius: 3px; + padding: 0.5rem 1rem; + color: white; + background: transparent; + border: 2px solid white; + ${(props: { primary?: boolean }) => + props.primary && + css` + background: white; + color: black; + `} +` + +export const Counter = ({ primary }: { primary?: boolean }) => { + const [count, setCount] = useState(0) + + return ( + + ) +} diff --git a/packages/plugin-react-swc/playground/styled-components/src/index.css b/packages/plugin-react-swc/playground/styled-components/src/index.css new file mode 100644 index 000000000..8d79b2457 --- /dev/null +++ b/packages/plugin-react-swc/playground/styled-components/src/index.css @@ -0,0 +1,24 @@ +:root { + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + 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; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} diff --git a/packages/plugin-react-swc/playground/styled-components/src/index.tsx b/packages/plugin-react-swc/playground/styled-components/src/index.tsx new file mode 100644 index 000000000..ee852372c --- /dev/null +++ b/packages/plugin-react-swc/playground/styled-components/src/index.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App.tsx' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/plugin-react-swc/playground/styled-components/tsconfig.json b/packages/plugin-react-swc/playground/styled-components/tsconfig.json new file mode 100644 index 000000000..6a2d67033 --- /dev/null +++ b/packages/plugin-react-swc/playground/styled-components/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true + } +} diff --git a/packages/plugin-react-swc/playground/styled-components/vite.config.ts b/packages/plugin-react-swc/playground/styled-components/vite.config.ts new file mode 100644 index 000000000..f852b23ee --- /dev/null +++ b/packages/plugin-react-swc/playground/styled-components/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [react({ plugins: [['@swc/plugin-styled-components', {}]] })], +}) 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 new file mode 100644 index 000000000..7f3357908 --- /dev/null +++ b/packages/plugin-react-swc/playground/ts-lib/__tests__/ts-lib.spec.ts @@ -0,0 +1,22 @@ +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 testNonJs(page) + await server.httpServer.close() +}) + +test('TS lib dev', async ({ page }) => { + const { testUrl, server } = await setupDevServer('ts-lib') + await page.goto(testUrl) + 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/index.html b/packages/plugin-react-swc/playground/ts-lib/index.html new file mode 100644 index 000000000..47589b590 --- /dev/null +++ b/packages/plugin-react-swc/playground/ts-lib/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS lib + + +
+ + + diff --git a/packages/plugin-react-swc/playground/ts-lib/package.json b/packages/plugin-react-swc/playground/ts-lib/package.json new file mode 100644 index 000000000..64a3fcb62 --- /dev/null +++ b/packages/plugin-react-swc/playground/ts-lib/package.json @@ -0,0 +1,20 @@ +{ + "name": "playground-ts-lib", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vitejs/test-dep-non-js": "file:./test-dep/non-js", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react-swc": "../../dist" + } +} diff --git a/packages/plugin-react-swc/playground/ts-lib/public/vite.svg b/packages/plugin-react-swc/playground/ts-lib/public/vite.svg new file mode 100644 index 000000000..4dcd77ad0 --- /dev/null +++ b/packages/plugin-react-swc/playground/ts-lib/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file 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 new file mode 100644 index 000000000..c0e262737 --- /dev/null +++ b/packages/plugin-react-swc/playground/ts-lib/src/index.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './app' + +createRoot(document.getElementById('root')!).render( + + + , +) 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/tsconfig.json b/packages/plugin-react-swc/playground/ts-lib/tsconfig.json new file mode 100644 index 000000000..12ab2344b --- /dev/null +++ b/packages/plugin-react-swc/playground/ts-lib/tsconfig.json @@ -0,0 +1,25 @@ +{ + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true + } +} diff --git a/packages/plugin-react-swc/playground/ts-lib/vite.config.ts b/packages/plugin-react-swc/playground/ts-lib/vite.config.ts new file mode 100644 index 000000000..7af9f6f97 --- /dev/null +++ b/packages/plugin-react-swc/playground/ts-lib/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/plugin-react-swc/playground/utils.ts b/packages/plugin-react-swc/playground/utils.ts new file mode 100644 index 000000000..751b22f79 --- /dev/null +++ b/packages/plugin-react-swc/playground/utils.ts @@ -0,0 +1,120 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { type Locator, type Page, expect } from '@playwright/test' +import { + build, + createServer, + loadConfigFromFile, + mergeConfig, + preview, +} from 'vite' + +export const setupWaitForLogs = async (page: Page) => { + let logs: string[] = [] + page.on('console', (log) => { + logs.push(log.text()) + }) + return (...messages: (string | RegExp)[]) => + expect + .poll(() => { + if ( + messages.every((m) => + typeof m === 'string' + ? logs.includes(m) + : logs.some((l) => m.test(l)), + ) + ) { + logs = [] + return true + } + return logs + }) + .toBe(true) +} + +let port = 5173 +export const setupDevServer = async (name: string) => { + process.env['NODE_ENV'] = 'development' + const root = `playground-temp/${name}` + const res = await loadConfigFromFile( + { command: 'serve', mode: 'development' }, + undefined, + root, + ) + const testConfig = mergeConfig(res!.config, { + root, + logLevel: 'silent', + configFile: false, + server: { port: port++ }, + }) + const server = await (await createServer(testConfig)).listen() + return { + testUrl: `http://localhost:${server.config.server.port}${server.config.base}`, + server, + editFile: ( + name: string, + ...replacements: [searchValue: string, replaceValue: string][] + ) => { + const path = `${root}/${name}` + let content = readFileSync(path, 'utf-8') + for (const [search, replace] of replacements) { + if (!content.includes(search)) { + throw new Error(`'${search}' not found in ${name}`) + } + content = content.replace(search, replace) + } + writeFileSync(path, content) + }, + } +} + +export const setupBuildAndPreview = async (name: string) => { + process.env['NODE_ENV'] = 'production' + const root = `playground-temp/${name}` + const res = await loadConfigFromFile( + { command: 'build', mode: 'production' }, + undefined, + root, + ) + const testConfig = mergeConfig( + { root, logLevel: 'silent', configFile: false, preview: { port: port++ } }, + res!.config, + ) + await build(testConfig) + const server = await preview(testConfig) + return { + testUrl: server.resolvedUrls!.local[0], + server, + } +} + +export const expectColor = async ( + locator: Locator, + property: 'color' | 'backgroundColor', + color: string, +) => { + await expect + .poll(async () => + rgbToHex( + await locator.evaluate( + (el, prop) => getComputedStyle(el)[prop], + property, + ), + ), + ) + .toBe(color) +} + +const rgbToHex = (rgb: string): string => { + const [_, rs, gs, bs] = rgb.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)! + return ( + '#' + + componentToHex(parseInt(rs, 10)) + + componentToHex(parseInt(gs, 10)) + + componentToHex(parseInt(bs, 10)) + ) +} + +const componentToHex = (c: number): string => { + const hex = c.toString(16) + return hex.length === 1 ? '0' + hex : hex +} diff --git a/packages/plugin-react-swc/playground/worker/__tests__/worker.spec.ts b/packages/plugin-react-swc/playground/worker/__tests__/worker.spec.ts new file mode 100644 index 000000000..b0c7b2ce0 --- /dev/null +++ b/packages/plugin-react-swc/playground/worker/__tests__/worker.spec.ts @@ -0,0 +1,27 @@ +import { test } from '@playwright/test' +import { + setupBuildAndPreview, + setupDevServer, + setupWaitForLogs, +} from '../../utils.ts' + +test('Worker build', async ({ page }) => { + const { testUrl, server } = await setupBuildAndPreview('worker') + const waitForLogs = await setupWaitForLogs(page) + await page.goto(testUrl) + await waitForLogs('Worker lives!', 'Worker imported!') + + await server.httpServer.close() +}) + +test('Worker HMR', async ({ page }) => { + const { testUrl, server, editFile } = await setupDevServer('worker') + const waitForLogs = await setupWaitForLogs(page) + await page.goto(testUrl) + await waitForLogs('Worker lives!', 'Worker imported!') + + editFile('src/worker-via-url.ts', ['Worker lives!', 'Worker updates!']) + await waitForLogs('Worker updates!') + + await server.close() +}) diff --git a/packages/plugin-react-swc/playground/worker/index.html b/packages/plugin-react-swc/playground/worker/index.html new file mode 100644 index 000000000..5f14b23a4 --- /dev/null +++ b/packages/plugin-react-swc/playground/worker/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + Worker + + +
+ + + diff --git a/packages/plugin-react-swc/playground/worker/package.json b/packages/plugin-react-swc/playground/worker/package.json new file mode 100644 index 000000000..a54a1d437 --- /dev/null +++ b/packages/plugin-react-swc/playground/worker/package.json @@ -0,0 +1,19 @@ +{ + "name": "playground-worker", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react-swc": "../../dist" + } +} diff --git a/packages/plugin-react-swc/playground/worker/public/vite.svg b/packages/plugin-react-swc/playground/worker/public/vite.svg new file mode 100644 index 000000000..4dcd77ad0 --- /dev/null +++ b/packages/plugin-react-swc/playground/worker/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-react-swc/playground/worker/src/App.tsx b/packages/plugin-react-swc/playground/worker/src/App.tsx new file mode 100644 index 000000000..055838c28 --- /dev/null +++ b/packages/plugin-react-swc/playground/worker/src/App.tsx @@ -0,0 +1,10 @@ +import { useState } from 'react' +import MyWorker from './worker-via-import.ts?worker&inline' + +new MyWorker() + +export const App = () => { + const [count, setCount] = useState(0) + + return +} diff --git a/packages/plugin-react-swc/playground/worker/src/index.tsx b/packages/plugin-react-swc/playground/worker/src/index.tsx new file mode 100644 index 000000000..317caffbd --- /dev/null +++ b/packages/plugin-react-swc/playground/worker/src/index.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App.tsx' + +new Worker(new URL('./worker-via-url.ts', import.meta.url), { type: 'module' }) + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/plugin-react-swc/playground/worker/src/worker-via-import.ts b/packages/plugin-react-swc/playground/worker/src/worker-via-import.ts new file mode 100644 index 000000000..a90f7ac3d --- /dev/null +++ b/packages/plugin-react-swc/playground/worker/src/worker-via-import.ts @@ -0,0 +1,7 @@ +function printAlive(): void { + console.log('Worker imported!') +} + +printAlive() + +export {} diff --git a/packages/plugin-react-swc/playground/worker/src/worker-via-url.ts b/packages/plugin-react-swc/playground/worker/src/worker-via-url.ts new file mode 100644 index 000000000..06a383a2a --- /dev/null +++ b/packages/plugin-react-swc/playground/worker/src/worker-via-url.ts @@ -0,0 +1,7 @@ +function printAlive(): void { + console.log('Worker lives!') +} + +printAlive() + +export {} diff --git a/packages/plugin-react-swc/playground/worker/tsconfig.json b/packages/plugin-react-swc/playground/worker/tsconfig.json new file mode 100644 index 000000000..6a2d67033 --- /dev/null +++ b/packages/plugin-react-swc/playground/worker/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src", "vite.config.ts"], + "compilerOptions": { + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "jsx": "react-jsx", + "types": ["vite/client"], + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "useUnknownInCatchVariables": true + } +} diff --git a/packages/plugin-react-swc/playground/worker/vite.config.ts b/packages/plugin-react-swc/playground/worker/vite.config.ts new file mode 100644 index 000000000..7af9f6f97 --- /dev/null +++ b/packages/plugin-react-swc/playground/worker/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +export default defineConfig({ + plugins: [react()], +}) diff --git a/packages/plugin-react-swc/playwright.config.ts b/packages/plugin-react-swc/playwright.config.ts new file mode 100644 index 000000000..d7c8ae487 --- /dev/null +++ b/packages/plugin-react-swc/playwright.config.ts @@ -0,0 +1,27 @@ +import { fileURLToPath } from 'node:url' +import { type PlaywrightTestConfig, devices } from '@playwright/test' +import fs from 'fs-extra' + +const tempDir = fileURLToPath(new URL('playground-temp', import.meta.url)) +fs.ensureDirSync(tempDir) +fs.emptyDirSync(tempDir) +fs.copySync(fileURLToPath(new URL('playground', import.meta.url)), tempDir, { + filter: (src) => { + src = src.replaceAll('\\', '/') + return ( + !src.includes('/__tests__') && + !src.includes('/.vite') && + !src.includes('/dist') + ) + }, +}) + +const config: PlaywrightTestConfig = { + forbidOnly: !!process.env['CI'], + workers: 1, + timeout: 10_000, + reporter: process.env['CI'] ? 'github' : 'list', + projects: [{ name: 'chromium', use: devices['Desktop Chrome'] }], +} + +export default config diff --git a/packages/plugin-react-swc/src/index.ts b/packages/plugin-react-swc/src/index.ts new file mode 100644 index 000000000..425bac15c --- /dev/null +++ b/packages/plugin-react-swc/src/index.ts @@ -0,0 +1,324 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createRequire } from 'node:module' +import { + type JscTarget, + type Output, + type ParserConfig, + type ReactConfig, + type Options as SWCOptions, + transform, +} from '@swc/core' +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 = + typeof __dirname !== 'undefined' + ? __dirname + : dirname(fileURLToPath(import.meta.url)) +const resolve = createRequire( + typeof __filename !== 'undefined' ? __filename : import.meta.url, +).resolve +/* eslint-enable no-restricted-globals */ + +type Options = { + /** + * Control where the JSX factory is imported from. + * @default "react" + */ + jsxImportSource?: string + /** + * Enable TypeScript decorators. Requires experimentalDecorators in tsconfig. + * @default false + */ + tsDecorators?: boolean + /** + * Use SWC plugins. Enable SWC at build time. + * @default undefined + */ + plugins?: [string, Record][] + /** + * Set the target for SWC in dev. This can avoid to down-transpile private class method for example. + * For production target, see https://vite.dev/config/build-options.html#build-target + * @default "es2020" + */ + devTarget?: JscTarget + /** + * Override the default include list (.ts, .tsx, .mts, .jsx, .mdx). + * This requires to redefine the config for any file you want to be included. + * If you want to trigger fast refresh on compiled JS, use `jsx: true`. + * Exclusion of node_modules should be handled by the function if needed. + */ + parserConfig?: (id: string) => ParserConfig | undefined + /** + * React Fast Refresh runtime URL prefix. + * Useful in a module federation context to enable HMR by specifying + * the host application URL in a Vite config of a remote application. + * @example + * reactRefreshHost: 'http://localhost:3000' + */ + reactRefreshHost?: string + /** + * The future of Vite is with OXC, and from the beginning this was a design choice + * to not exposed too many specialties from SWC so that Vite React users can move to + * another transformer later. + * Also debugging why some specific version of decorators with some other unstable/legacy + * 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): Plugin[] => { + let hmrDisabled = false + let viteCacheRoot: string | undefined + const options = { + jsxImportSource: _options?.jsxImportSource ?? 'react', + tsDecorators: _options?.tsDecorators, + plugins: _options?.plugins + ? _options?.plugins.map((el): typeof el => [resolve(el[0]), el[1]]) + : undefined, + devTarget: _options?.devTarget ?? 'es2020', + parserConfig: _options?.parserConfig, + reactRefreshHost: _options?.reactRefreshHost, + useAtYourOwnRisk_mutateSwcOptions: + _options?.useAtYourOwnRisk_mutateSwcOptions, + disableOxcRecommendation: _options?.disableOxcRecommendation, + } + + return [ + { + name: 'vite:react-swc:resolve-runtime', + apply: 'serve', + enforce: 'pre', // Run before Vite default resolve to avoid syscalls + 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', + apply: 'serve', + config: () => ({ + esbuild: false, + // NOTE: oxc option only exists in rolldown-vite + oxc: false, + optimizeDeps: { + include: [`${options.jsxImportSource}/jsx-dev-runtime`], + ...('rolldownVersion' in vite + ? { + rollupOptions: { transform: { jsx: { runtime: 'automatic' } } }, + } + : { esbuildOptions: { jsx: 'automatic' } }), + }, + }), + configResolved(config) { + viteCacheRoot = config.cacheDir + if (config.server.hmr === false) hmrDisabled = true + const mdxIndex = config.plugins.findIndex( + (p) => p.name === '@mdx-js/rollup', + ) + if ( + mdxIndex !== -1 && + mdxIndex > + config.plugins.findIndex((p) => p.name === 'vite:react-swc') + ) { + throw new Error( + '[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) => { + if (!hmrDisabled) { + return [ + { + tag: 'script', + attrs: { type: 'module' }, + children: getPreambleCode(config.server!.config.base), + }, + ] + } + }, + async transform(code, _id, transformOptions) { + const id = _id.split('?')[0] + const refresh = !transformOptions?.ssr && !hmrDisabled + + const result = await transformWithOptions( + id, + code, + options.devTarget, + options, + viteCacheRoot, + { + refresh, + development: true, + runtime: 'automatic', + importSource: options.jsxImportSource, + }, + ) + if (!result) return + if (!refresh) return result + + const newCode = addRefreshWrapper( + result.code, + '@vitejs/plugin-react-swc', + id, + options.reactRefreshHost, + ) + return { code: newCode ?? result.code, map: result.map } + }, + }, + options.plugins + ? { + name: 'vite:react-swc', + apply: 'build', + enforce: 'pre', // Run before esbuild + config: (userConfig) => ({ + build: silenceUseClientWarning(userConfig), + }), + configResolved(config) { + viteCacheRoot = config.cacheDir + }, + transform: (code, _id) => + transformWithOptions( + _id.split('?')[0], + code, + 'esnext', + options, + viteCacheRoot, + { + runtime: 'automatic', + importSource: options.jsxImportSource, + }, + ), + } + : { + name: 'vite:react-swc', + apply: 'build', + config: (userConfig) => ({ + build: silenceUseClientWarning(userConfig), + esbuild: { + jsx: 'automatic', + jsxImportSource: options.jsxImportSource, + tsconfigRaw: { + compilerOptions: { useDefineForClassFields: true }, + }, + }, + }), + configResolved(config) { + viteCacheRoot = config.cacheDir + }, + }, + ] +} + +const transformWithOptions = async ( + id: string, + code: string, + target: JscTarget, + options: Options, + viteCacheRoot: string | undefined, + reactConfig: ReactConfig, +) => { + const decorators = options?.tsDecorators ?? false + const parser: ParserConfig | undefined = options.parserConfig + ? options.parserConfig(id) + : id.endsWith('.tsx') + ? { syntax: 'typescript', tsx: true, decorators } + : id.endsWith('.ts') || id.endsWith('.mts') + ? { syntax: 'typescript', tsx: false, decorators } + : id.endsWith('.jsx') + ? { syntax: 'ecmascript', jsx: true } + : id.endsWith('.mdx') + ? // JSX is required to trigger fast refresh transformations, even if MDX already transforms it + { syntax: 'ecmascript', jsx: true } + : undefined + if (!parser) return + + let result: Output + try { + const swcOptions: SWCOptions = { + filename: id, + swcrc: false, + configFile: false, + sourceMaps: true, + jsc: { + target, + parser, + experimental: { + plugins: options.plugins, + cacheRoot: join(viteCacheRoot ?? 'node_modules/.vite', '.swc'), + }, + transform: { + useDefineForClassFields: true, + react: reactConfig, + }, + }, + } + if (options.useAtYourOwnRisk_mutateSwcOptions) { + options.useAtYourOwnRisk_mutateSwcOptions(swcOptions) + } + result = await transform(code, swcOptions) + } catch (e: any) { + const message: string = e.message + const fileStartIndex = message.indexOf('╭─[') + if (fileStartIndex !== -1) { + const match = message.slice(fileStartIndex).match(/:(\d+):(\d+)\]/) + if (match) { + e.line = match[1] + e.column = match[2] + } + } + throw e + } + + return result +} + +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 new file mode 100644 index 000000000..ac687cd46 --- /dev/null +++ b/packages/plugin-react-swc/tsconfig.json @@ -0,0 +1,7 @@ +{ + "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 969cbb31d..6ef1b7750 100644 --- a/packages/plugin-react/CHANGELOG.md +++ b/packages/plugin-react/CHANGELOG.md @@ -2,6 +2,197 @@ ## Unreleased +## 5.0.4 (2025-09-27) + +### Perf: use native refresh wrapper plugin in rolldown-vite ([#881](https://github.com/vitejs/vite-plugin-react/pull/881)) + +## 5.0.3 (2025-09-17) + +### HMR did not work for components imported with queries with rolldown-vite ([#872](https://github.com/vitejs/vite-plugin-react/pull/872)) + +### 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) + +## 4.4.0 (2025-04-15) + +### Make compatible with rolldown-vite + +This plugin is now compatible with rolldown-powered version of Vite. +Note that currently the `__source` property value position might be incorrect. This will be fixed in the near future. + +## 4.4.0-beta.2 (2025-04-15) + +### Add `reactRefreshHost` option + +Add `reactRefreshHost` option to set a React Fast Refresh runtime URL prefix. +This is useful in a module federation context to enable HMR by specifying the host application URL in the Vite config of a remote application. +See full discussion here: https://github.com/module-federation/vite/issues/183#issuecomment-2751825367 + +```ts +export default defineConfig({ + plugins: [react({ reactRefreshHost: 'http://localhost:3000' })], +}) +``` + +## 4.4.0-beta.1 (2025-04-09) + +## 4.4.0-beta.0 (2025-04-09) + +## 4.3.4 (2024-11-26) + +### Add Vite 6 to peerDependencies range + +Vite 6 is highly backward compatible, not much to add! + +### Force Babel to output spec compliant import attributes [#386](https://github.com/vitejs/vite-plugin-react/pull/386) + +The default was an old spec (`with type: "json"`). We now enforce spec compliant (`with { type: "json" }`) + +## 4.3.3 (2024-10-19) + +### React Compiler runtimeModule option removed + +React Compiler was updated to accept a `target` option and `runtimeModule` was removed. vite-plugin-react will still detect `runtimeModule` for backwards compatibility. + +When using a custom `runtimeModule` or `target !== '19'`, the plugin will not try to pre-optimize `react/compiler-runtime` dependency. + +The [react-compiler-runtime](https://www.npmjs.com/package/react-compiler-runtime) is now available on npm can be used instead of the local shim for people using the compiler with React < 19. + +Here is the configuration to use the compiler with React 18 and correct source maps in development: + +```bash +npm install babel-plugin-react-compiler react-compiler-runtime @babel/plugin-transform-react-jsx-development +``` + +```ts +export default defineConfig(({ command }) => { + const babelPlugins = [['babel-plugin-react-compiler', { target: '18' }]] + if (command === 'serve') { + babelPlugins.push(['@babel/plugin-transform-react-jsx-development', {}]) + } + + return { + plugins: [react({ babel: { plugins: babelPlugins } })], + } +}) +```` + +## 4.3.2 (2024-09-29) + +Ignore directive sourcemap error [#369](https://github.com/vitejs/vite-plugin-react/issues/369) + ## 4.3.1 (2024-06-10) ### Fix support for React Compiler with React 18 @@ -424,7 +615,7 @@ See the [readme](https://github.com/aleclarson/vite/blob/f8129ce6e87684eb7a4edd8 - Support for [automatic JSX runtime](https://github.com/alloc/vite-react-jsx) - Babel integration for both development and production builds -- Add `react` and `react-dom` to [`resolve.dedupe`](https://vitejs.dev/config/#resolve-dedupe) automatically +- Add `react` and `react-dom` to [`resolve.dedupe`](https://vite.dev/config/#resolve-dedupe) automatically Thanks to @aleclarson and @pengx17 for preparing this release! diff --git a/packages/plugin-react/README.md b/packages/plugin-react/README.md index 5220bae4d..c607891ca 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' }) @@ -94,9 +92,19 @@ This option does not enable _code transformation_. That is handled by esbuild. Here's the [complete list of Babel parser plugins](https://babeljs.io/docs/en/babel-parser#ecmascript-proposalshttpsgithubcombabelproposals). +### reactRefreshHost + +The `reactRefreshHost` option is only necessary in a module federation context. It enables HMR to work between a remote & host application. In your remote Vite config, you would add your host origin: + +```js +react({ reactRefreshHost: 'http://localhost:3000' }) +``` + +Under the hood, this simply updates the React Fash Refresh runtime URL from `/@react-refresh` to `http://localhost:3000/@react-refresh` to ensure there is only one Refresh runtime across the whole application. Note that if you define `base` option in the host application, you need to include it in the option, like: `http://localhost:3000/{base}`. + ## Middleware mode -In [middleware mode](https://vitejs.dev/config/server-options.html#server-middlewaremode), you should make sure your entry `index.html` file is transformed by Vite. Here's an example for an Express server: +In [middleware mode](https://vite.dev/config/server-options.html#server-middlewaremode), you should make sure your entry `index.html` file is transformed by Vite. Here's an example for an Express server: ```js app.get('/', async (req, res, next) => { diff --git a/packages/plugin-react/build.config.ts b/packages/plugin-react/build.config.ts deleted file mode 100644 index 9d05aa4fe..000000000 --- a/packages/plugin-react/build.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineBuildConfig } from 'unbuild' - -export default defineBuildConfig({ - entries: ['src/index'], - externals: ['vite'], - clean: true, - declaration: true, - rollup: { - emitCJS: true, - inlineDependencies: true, - }, -}) diff --git a/packages/plugin-react/package.json b/packages/plugin-react/package.json index e4a225264..1417828c5 100644 --- a/packages/plugin-react/package.json +++ b/packages/plugin-react/package.json @@ -1,8 +1,17 @@ { "name": "@vitejs/plugin-react", - "version": "4.3.1", + "version": "5.0.4", "license": "MIT", "author": "Evan You", + "description": "The default Vite plugin for React projects", + "keywords": [ + "vite", + "vite-plugin", + "react", + "babel", + "react-refresh", + "fast refresh" + ], "contributors": [ "Alec Larson", "Arnaud Barré" @@ -10,23 +19,16 @@ "files": [ "dist" ], - "main": "./dist/index.cjs", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.cjs" - } - }, + "type": "module", + "exports": "./dist/index.js", "scripts": { - "dev": "unbuild --stub", - "build": "unbuild && pnpm run patch-cjs && tsx scripts/copyRefreshUtils.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", @@ -38,16 +40,22 @@ }, "homepage": "https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#readme", "dependencies": { - "@babel/core": "^7.24.5", - "@babel/plugin-transform-react-jsx-self": "^7.24.5", - "@babel/plugin-transform-react-jsx-source": "^7.24.1", + "@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.41", "@types/babel__core": "^7.20.5", - "react-refresh": "^0.14.2" + "react-refresh": "^0.17.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "devDependencies": { - "unbuild": "^2.0.0" + "@vitejs/react-common": "workspace:*", + "babel-plugin-react-compiler": "19.1.0-rc.3", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "rolldown": "1.0.0-beta.41", + "tsdown": "^0.15.6" } } diff --git a/packages/plugin-react/scripts/copyRefreshUtils.ts b/packages/plugin-react/scripts/copyRefreshUtils.ts deleted file mode 100644 index 977f6442f..000000000 --- a/packages/plugin-react/scripts/copyRefreshUtils.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { copyFileSync } from 'node:fs' - -copyFileSync('src/refreshUtils.js', 'dist/refreshUtils.js') diff --git a/packages/plugin-react/src/fast-refresh.ts b/packages/plugin-react/src/fast-refresh.ts deleted file mode 100644 index 3f9ffa605..000000000 --- a/packages/plugin-react/src/fast-refresh.ts +++ /dev/null @@ -1,90 +0,0 @@ -import fs from 'node:fs' -import path from 'node:path' -import { createRequire } from 'node:module' - -export const runtimePublicPath = '/@react-refresh' - -const _require = createRequire(import.meta.url) -const reactRefreshDir = path.dirname( - _require.resolve('react-refresh/package.json'), -) -const runtimeFilePath = path.join( - reactRefreshDir, - 'cjs/react-refresh-runtime.development.js', -) - -export const runtimeCode = ` -const exports = {} -${fs.readFileSync(runtimeFilePath, 'utf-8')} -${fs.readFileSync(_require.resolve('./refreshUtils.js'), 'utf-8')} -export default exports -` - -export const preambleCode = ` -import RefreshRuntime from "__BASE__${runtimePublicPath.slice(1)}" -RefreshRuntime.injectIntoGlobalHook(window) -window.$RefreshReg$ = () => {} -window.$RefreshSig$ = () => (type) => type -window.__vite_plugin_react_preamble_installed__ = true -` - -const sharedHeader = ` -import RefreshRuntime from "${runtimePublicPath}"; - -const inWebWorker = typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope; -`.replace(/\n+/g, '') -const functionHeader = ` -let prevRefreshReg; -let prevRefreshSig; - -if (import.meta.hot && !inWebWorker) { - if (!window.__vite_plugin_react_preamble_installed__) { - throw new Error( - "@vitejs/plugin-react can't detect preamble. Something is wrong. " + - "See https://github.com/vitejs/vite-plugin-react/pull/11#discussion_r430879201" - ); - } - - prevRefreshReg = window.$RefreshReg$; - prevRefreshSig = window.$RefreshSig$; - window.$RefreshReg$ = (type, id) => { - RefreshRuntime.register(type, __SOURCE__ + " " + id) - }; - window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform; -}`.replace(/\n+/g, '') - -const functionFooter = ` -if (import.meta.hot && !inWebWorker) { - window.$RefreshReg$ = prevRefreshReg; - window.$RefreshSig$ = prevRefreshSig; -}` -const sharedFooter = ` -if (import.meta.hot && !inWebWorker) { - RefreshRuntime.__hmr_import(import.meta.url).then((currentExports) => { - RefreshRuntime.registerExportsForReactRefresh(__SOURCE__, currentExports); - import.meta.hot.accept((nextExports) => { - if (!nextExports) return; - const invalidateMessage = RefreshRuntime.validateRefreshBoundaryAndEnqueueUpdate(currentExports, nextExports); - if (invalidateMessage) import.meta.hot.invalidate(invalidateMessage); - }); - }); -}` - -export function addRefreshWrapper(code: string, id: string): string { - return ( - sharedHeader + - functionHeader.replace('__SOURCE__', JSON.stringify(id)) + - code + - functionFooter + - sharedFooter.replace('__SOURCE__', JSON.stringify(id)) - ) -} - -export function addClassComponentRefreshWrapper( - code: string, - id: string, -): string { - return ( - sharedHeader + code + sharedFooter.replace('__SOURCE__', JSON.stringify(id)) - ) -} diff --git a/packages/plugin-react/src/index.ts b/packages/plugin-react/src/index.ts index 9d2356e3f..a01f693e5 100644 --- a/packages/plugin-react/src/index.ts +++ b/packages/plugin-react/src/index.ts @@ -1,22 +1,25 @@ -// eslint-disable-next-line import/no-duplicates +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readFileSync } from 'node:fs' import type * as babelCore from '@babel/core' -// eslint-disable-next-line import/no-duplicates import type { ParserOptions, TransformOptions } from '@babel/core' import { createFilter } from 'vite' -import type { - BuildOptions, - Plugin, - PluginOption, - ResolvedConfig, - UserConfig, -} from 'vite' +import * as vite from 'vite' +import type { Plugin, ResolvedConfig } from 'vite' import { - addClassComponentRefreshWrapper, addRefreshWrapper, + getPreambleCode, preambleCode, - runtimeCode, runtimePublicPath, -} from './fast-refresh' + silenceUseClientWarning, +} from '@vitejs/react-common' +import { + exactRegex, + makeIdFiltersToMatchWithQuery, +} from '@rolldown/pluginutils' + +const _dirname = dirname(fileURLToPath(import.meta.url)) +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 @@ -47,6 +50,14 @@ export interface Options { babel?: | BabelOptions | ((id: string, options: { ssr?: boolean }) => BabelOptions) + /** + * React Fast Refresh runtime URL prefix. + * Useful in a module federation context to enable HMR by specifying + * the host application URL in the Vite config of a remote application. + * @example + * reactRefreshHost: 'http://localhost:3000' + */ + reactRefreshHost?: string } export type BabelOptions = Omit< @@ -87,21 +98,27 @@ export type ViteReactPluginApi = { reactBabel?: ReactBabelHook } -const reactCompRE = /extends\s+(?:React\.)?(?:Pure)?Component/ -const refreshContentRE = /\$Refresh(?:Reg|Sig)\$\(/ 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[] { - // Provide default values for Rollup compat. - let devBase = '/' - 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 isFullBundle = false let runPluginOverrides: | ((options: ReactBabelOptions, context: ReactBabelHookContext) => void) | undefined @@ -116,7 +133,41 @@ export default function viteReact(opts: Options = {}): PluginOption[] { const viteBabel: Plugin = { name: 'vite:react-babel', enforce: 'pre', - config() { + 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: makeIdFiltersToMatchWithQuery(include), + jsxRefreshExclude: makeIdFiltersToMatchWithQuery(exclude), + }, + } + } else { + return { + oxc: { + jsx: { + runtime: 'automatic', + importSource: opts.jsxImportSource, + refresh: command === 'serve', + }, + jsxRefreshInclude: makeIdFiltersToMatchWithQuery(include), + jsxRefreshExclude: makeIdFiltersToMatchWithQuery(exclude), + }, + optimizeDeps: { + rollupOptions: { transform: { jsx: { runtime: 'automatic' } } }, + }, + } + } + } + if (opts.jsxRuntime === 'classic') { return { esbuild: { @@ -127,6 +178,7 @@ export default function viteReact(opts: Options = {}): PluginOption[] { 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' } }, @@ -134,7 +186,12 @@ export default function viteReact(opts: Options = {}): PluginOption[] { } }, configResolved(config) { - devBase = config.base + runningInVite = true + base = config.base + // @ts-expect-error only available in newer rolldown-vite + if (config.experimental.fullBundleMode) { + isFullBundle = true + } projectRoot = config.root isProduction = config.isProduction skipFastRefresh = @@ -142,12 +199,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) @@ -161,121 +212,270 @@ 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 } + }, + 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 + } + + // 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 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 (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'), + ) + } + } + + // Avoid parsing if no special transformation is needed + if (canSkipBabel(plugins, babelOptions)) { + return + } - 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'), + 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: 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, + }) + + 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 } } + }, + }, + } + + // for rolldown-vite + const viteRefreshWrapper: Plugin = { + name: 'vite:react:refresh-wrapper', + apply: 'serve', + async applyToEnvironment(env) { + if (env.config.consumer !== 'client' || skipFastRefresh) { + return false } - // Avoid parsing if no special transformation is needed + let nativePlugin: ((options: any) => Plugin) | undefined + try { + // NOTE: `+` is to bypass lint & typecheck. vite/internal exists for newer rolldown-vite + const vite = 'vite' + nativePlugin = (await import(vite + '/internal')) + .reactRefreshWrapperPlugin + } catch {} if ( - !plugins.length && - !babelOptions.presets.length && - !babelOptions.configFile && - !babelOptions.babelrc + !nativePlugin || + ['7.1.10', '7.1.11', '7.1.12'].includes(vite.version) ) { - return - } - - const parserPlugins = [...babelOptions.parserOpts.plugins] - - if (!filepath.endsWith('.ts')) { - parserPlugins.push('jsx') + // the native plugin in 7.1.10 and 7.1.11 and 7.1.12 does not support dev properly + return true } - if (tsRE.test(filepath)) { - parserPlugins.push('typescript') - } + delete viteRefreshWrapper.transform - 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 crates 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: hasCompiler(plugins) - ? false - : !isProduction && isJSX && opts.jsxRuntime !== 'classic', - parserOpts: { - ...babelOptions.parserOpts, - sourceType: 'module', - allowAwaitOutsideFunction: true, - plugins: parserPlugins, - }, - generatorOpts: { - ...babelOptions.generatorOpts, - decoratorsBeforeExport: true, + return nativePlugin({ + cwd: process.cwd(), + include: makeIdFiltersToMatchWithQuery(include), + exclude: makeIdFiltersToMatchWithQuery(exclude), + jsxImportSource, + reactRefreshHost: opts.reactRefreshHost ?? '', + }) as unknown as boolean + }, + // we can remove this transform hook when we drop support for rolldown-vite 7.1.12 and below + transform: { + filter: { + id: { + include: makeIdFiltersToMatchWithQuery(include), + exclude: makeIdFiltersToMatchWithQuery(exclude), }, - plugins, - sourceMaps: true, - }) + }, + 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 + }, + }, + } - if (result) { - let code = result.code! - if (useFastRefresh) { - if (refreshContentRE.test(code)) { - code = addRefreshWrapper(code, id) - } else if (reactCompRE.test(code)) { - code = addClassComponentRefreshWrapper(code, id) - } - } - return { code, map: result.map } + // 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 } }, } - // We can't add `react-dom` because the dependency is `react-dom/client` - // for React 18 while it's `react-dom` for React 17. We'd need to detect - // what React version the user has installed. - const dependencies = ['react', jsxImportDevRuntime, jsxImportRuntime] + // for rolldown-vite + full bundle mode + const viteReactRefreshFullBundleMode: Plugin = { + name: 'vite:react-refresh-fbm', + enforce: 'pre', + transformIndexHtml: { + handler() { + if (!skipFastRefresh && isFullBundle) + 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', + }, + } + + const dependencies = [ + 'react', + 'react-dom', + jsxImportDevRuntime, + jsxImportRuntime, + ] const staticBabelPlugins = - typeof opts.babel === 'object' ? opts.babel?.plugins ?? [] : [] - if (hasCompilerWithDefaultRuntime(staticBabelPlugins)) { - dependencies.push('react/compiler-runtime') + typeof opts.babel === 'object' ? (opts.babel?.plugins ?? []) : [] + const reactCompilerPlugin = getReactCompilerPlugin(staticBabelPlugins) + if (reactCompilerPlugin != null) { + const reactCompilerRuntimeModule = + getReactCompilerRuntimeModule(reactCompilerPlugin) + dependencies.push(reactCompilerRuntimeModule) } const viteReactRefresh: Plugin = { @@ -286,54 +486,69 @@ 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 runtimeCode - } + 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() { - if (!skipFastRefresh) + if (!skipFastRefresh && !isFullBundle) return [ { tag: 'script', attrs: { type: 'module' }, - children: preambleCode.replace(`__BASE__`, devBase), + children: getPreambleCode(base), }, ] }, } - return [viteBabel, viteReactRefresh] + return [ + viteBabel, + ...(isRolldownVite + ? [viteRefreshWrapper, viteConfigPost, viteReactRefreshFullBundleMode] + : []), + viteReactRefresh, + ] } viteReact.preambleCode = preambleCode -const silenceUseClientWarning = (userConfig: UserConfig): BuildOptions => ({ - rollupOptions: { - onwarn(warning, defaultHandler) { - if ( - warning.code === 'MODULE_LEVEL_DIRECTIVE' && - warning.message.includes('use client') - ) { - return - } - if (userConfig.build?.rollupOptions?.onwarn) { - userConfig.build.rollupOptions.onwarn(warning, defaultHandler) - } else { - defaultHandler(warning) - } - }, - }, +// 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 { @@ -369,21 +584,25 @@ function defined(value: T | undefined): value is T { return value !== undefined } -function hasCompiler(plugins: ReactBabelOptions['plugins']) { - return plugins.some( +function getReactCompilerPlugin(plugins: ReactBabelOptions['plugins']) { + return plugins.find( (p) => p === 'babel-plugin-react-compiler' || (Array.isArray(p) && p[0] === 'babel-plugin-react-compiler'), ) } -// https://gist.github.com/poteto/37c076bf112a07ba39d0e5f0645fec43 -function hasCompilerWithDefaultRuntime(plugins: ReactBabelOptions['plugins']) { - return plugins.some( - (p) => - p === 'babel-plugin-react-compiler' || - (Array.isArray(p) && - p[0] === 'babel-plugin-react-compiler' && - p[1]?.runtimeModule === undefined), - ) +type ReactCompilerRuntimeModule = + | 'react/compiler-runtime' // from react namespace + | 'react-compiler-runtime' // npm package +function getReactCompilerRuntimeModule( + plugin: babelCore.PluginItem, +): ReactCompilerRuntimeModule { + let moduleName: ReactCompilerRuntimeModule = 'react/compiler-runtime' + if (Array.isArray(plugin)) { + if (plugin[1]?.target === '17' || plugin[1]?.target === '18') { + moduleName = 'react-compiler-runtime' + } + } + return moduleName } diff --git a/packages/plugin-react/src/refreshUtils.js b/packages/plugin-react/src/refreshUtils.js deleted file mode 100644 index 0ca2b0117..000000000 --- a/packages/plugin-react/src/refreshUtils.js +++ /dev/null @@ -1,71 +0,0 @@ -function debounce(fn, delay) { - let handle - return () => { - clearTimeout(handle) - handle = setTimeout(fn, delay) - } -} - -/* eslint-disable no-undef */ -const enqueueUpdate = debounce(exports.performReactRefresh, 16) - -// 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 -function registerExportsForReactRefresh(filename, moduleExports) { - for (const key in moduleExports) { - if (key === '__esModule') continue - const exportValue = moduleExports[key] - if (exports.isLikelyComponentType(exportValue)) { - // 'export' is required to avoid key collision when renamed exports that - // shadow a local component name: https://github.com/vitejs/vite-plugin-react/issues/116 - // 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. - exports.register(exportValue, filename + ' export ' + key) - } - } -} - -function validateRefreshBoundaryAndEnqueueUpdate(prevExports, nextExports) { - if (!predicateOnExport(prevExports, (key) => key in nextExports)) { - return 'Could not Fast Refresh (export removed)' - } - if (!predicateOnExport(nextExports, (key) => key in prevExports)) { - return 'Could not Fast Refresh (new export)' - } - - let hasExports = false - const allExportsAreComponentsOrUnchanged = predicateOnExport( - nextExports, - (key, value) => { - hasExports = true - if (exports.isLikelyComponentType(value)) return true - return prevExports[key] === nextExports[key] - }, - ) - if (hasExports && allExportsAreComponentsOrUnchanged) { - enqueueUpdate() - } else { - return 'Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports' - } -} - -function predicateOnExport(moduleExports, predicate) { - for (const key in moduleExports) { - if (key === '__esModule') continue - const desc = Object.getOwnPropertyDescriptor(moduleExports, key) - if (desc && desc.get) return false - if (!predicate(key, moduleExports[key])) return false - } - return true -} - -// Hides vite-ignored dynamic import so that Vite can skip analysis if no other -// dynamic import is present (https://github.com/vitejs/vite/pull/12732) -function __hmr_import(module) { - return import(/* @vite-ignore */ module) -} - -exports.__hmr_import = __hmr_import -exports.registerExportsForReactRefresh = registerExportsForReactRefresh -exports.validateRefreshBoundaryAndEnqueueUpdate = - validateRefreshBoundaryAndEnqueueUpdate 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..54fb7cfe9 --- /dev/null +++ b/packages/plugin-rsc/CHANGELOG.md @@ -0,0 +1,608 @@ +## [0.4.33](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.32...plugin-rsc@0.4.33) (2025-10-08) +### Bug Fixes + +* **deps:** update all non-major dependencies ([#887](https://github.com/vitejs/vite-plugin-react/issues/887)) ([407795d](https://github.com/vitejs/vite-plugin-react/commit/407795dbd0129b069cf3ac842846687485a5ef00)) +* **deps:** update all non-major dependencies ([#896](https://github.com/vitejs/vite-plugin-react/issues/896)) ([2d239fc](https://github.com/vitejs/vite-plugin-react/commit/2d239fc8dec2ab499282eaea45b2bffb8d182f26)) +* **rsc/cjs:** add `__filename` and `__dirname` ([#908](https://github.com/vitejs/vite-plugin-react/issues/908)) ([0ba0d71](https://github.com/vitejs/vite-plugin-react/commit/0ba0d71bc92822946f327760691db3d6f7d87106)) +* **rsc/cjs:** unwrap `default` based on `__cjs_module_runner_transform` marker ([#905](https://github.com/vitejs/vite-plugin-react/issues/905)) ([1216caf](https://github.com/vitejs/vite-plugin-react/commit/1216caf70621b8760c4226624939b77e7ece4f42)) + +### Code Refactoring + +* **rsc:** move common code for `transformCjsToEsm` ([#909](https://github.com/vitejs/vite-plugin-react/issues/909)) ([ac61c62](https://github.com/vitejs/vite-plugin-react/commit/ac61c624d8a7f860af735ad288491b5c50c656bb)) + +## [0.4.32](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.31...plugin-rsc@0.4.32) (2025-09-26) +### Bug Fixes + +* **deps:** update all non-major dependencies ([#851](https://github.com/vitejs/vite-plugin-react/issues/851)) ([3c2ebf8](https://github.com/vitejs/vite-plugin-react/commit/3c2ebf89de7f5e40ed0ef932993f7d0b7695719b)) +* **rsc:** reject inline "use server" inside "use client" module ([#884](https://github.com/vitejs/vite-plugin-react/issues/884)) ([5bc3f79](https://github.com/vitejs/vite-plugin-react/commit/5bc3f79fb4356ebf574b6ba28e4c7a315f4336de)) + +### Miscellaneous Chores + +* **deps:** update all non-major dependencies ([#879](https://github.com/vitejs/vite-plugin-react/issues/879)) ([608f266](https://github.com/vitejs/vite-plugin-react/commit/608f266c8d53f41a6b1541de35b218fe2640ec05)) +* **rsc:** fix typo ([#885](https://github.com/vitejs/vite-plugin-react/issues/885)) ([b81470c](https://github.com/vitejs/vite-plugin-react/commit/b81470c3076e079be517b7bf92325760ba89fd3d)) + +## [0.4.31](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.30...plugin-rsc@0.4.31) (2025-09-17) +### Bug Fixes + +* **rsc:** fix plugin name in `client-only` error message ([#862](https://github.com/vitejs/vite-plugin-react/issues/862)) ([0f2fbc7](https://github.com/vitejs/vite-plugin-react/commit/0f2fbc7c1fe2b6228864c5b424cea2309323fb67)) +* **rsc:** remove server style when css import is removed ([#849](https://github.com/vitejs/vite-plugin-react/issues/849)) ([4ae3f18](https://github.com/vitejs/vite-plugin-react/commit/4ae3f184eaa3e7fe559ccaf67c35e17a6e7fefa0)) +* **rsc:** show import chain for server-only and client-only import error ([#867](https://github.com/vitejs/vite-plugin-react/issues/867)) ([ba16c34](https://github.com/vitejs/vite-plugin-react/commit/ba16c34f6b70e68f29bff110c6906829ec3b2e8d)) + +### Documentation + +* **rsc:** mention `validateImports` option for build time `server-only` and `client-only` validation ([#858](https://github.com/vitejs/vite-plugin-react/issues/858)) ([a96a6b2](https://github.com/vitejs/vite-plugin-react/commit/a96a6b2ef0afc1cc914885d4514865711d978fbf)) +* **rsc:** separate "Tips" section ([#864](https://github.com/vitejs/vite-plugin-react/issues/864)) ([32cfa5f](https://github.com/vitejs/vite-plugin-react/commit/32cfa5fe1e9c255e59a29c14d1b8585772f7b61e)) + +### Miscellaneous Chores + +* **rsc:** add missing rsc-html-stream dep (fix [#857](https://github.com/vitejs/vite-plugin-react/issues/857)) ([#868](https://github.com/vitejs/vite-plugin-react/issues/868)) ([c30cf1a](https://github.com/vitejs/vite-plugin-react/commit/c30cf1a7db312a2643de426c7ac13479ce90289a)) + +### Tests + +* **rsc:** tweak assertions for rolldown-vite ([#869](https://github.com/vitejs/vite-plugin-react/issues/869)) ([a2a287a](https://github.com/vitejs/vite-plugin-react/commit/a2a287aef6be302a771b8f7c512f190578412685)) + +## [0.4.30](https://github.com/vitejs/vite-plugin-react/compare/plugin-rsc@0.4.29...plugin-rsc@0.4.30) (2025-09-15) +### Features + +* **rsc:** support `export default { fetch }` as server handler entry ([#839](https://github.com/vitejs/vite-plugin-react/issues/839)) ([cb5ce55](https://github.com/vitejs/vite-plugin-react/commit/cb5ce555e234166022dd899c71c88ad3eb7e5192)) + +### Bug Fixes + +* **rsc:** `copyPublicDir: false` for server build ([#831](https://github.com/vitejs/vite-plugin-react/issues/831)) ([12b05bb](https://github.com/vitejs/vite-plugin-react/commit/12b05bb3ec0155459b205199432b35e05ef3594a)) +* **rsc:** fix cjs transform to preserve `module.exports` on `require` side and allow `exports` assignment + expose `cjsModuleRunnerPlugin` ([#833](https://github.com/vitejs/vite-plugin-react/issues/833)) ([f63bb83](https://github.com/vitejs/vite-plugin-react/commit/f63bb83c7070d07ae5f488cdc9ac643bac61ba59)) +* **rsc:** keep server stylesheet link for hmr and avoid injecting css via client js ([#841](https://github.com/vitejs/vite-plugin-react/issues/841)) ([2b7b90f](https://github.com/vitejs/vite-plugin-react/commit/2b7b90f9ee94ca70beda90f288df2a5b6b260900)) + +### Documentation + +* **rsc:** remove unimportant APIs ([#830](https://github.com/vitejs/vite-plugin-react/issues/830)) ([9cabda1](https://github.com/vitejs/vite-plugin-react/commit/9cabda1574f95a123ba5f90ed94ed9bc9f8f04fc)) +* **rsc:** replace degit with create-vite ([#846](https://github.com/vitejs/vite-plugin-react/issues/846)) ([7c3edba](https://github.com/vitejs/vite-plugin-react/commit/7c3edba29b4996a77862c7dc7cb47bf51418dcd0)) + +### Miscellaneous Chores + +* **rsc:** remove double `import.meta.hot.accept` ([#840](https://github.com/vitejs/vite-plugin-react/issues/840)) ([a4bc2e0](https://github.com/vitejs/vite-plugin-react/commit/a4bc2e0c6cf7426dcb7b8b2945ca46377a7db688)) + +### Code Refactoring + +* **rsc:** self-accept css module direct request module on client environment ([#842](https://github.com/vitejs/vite-plugin-react/issues/842)) ([e37788b](https://github.com/vitejs/vite-plugin-react/commit/e37788bbde37daa9f6954891e90832566e65a667)) +* **rsc:** use `addWatchFile` to invalidate server css virtual ([#847](https://github.com/vitejs/vite-plugin-react/issues/847)) ([78a3f56](https://github.com/vitejs/vite-plugin-react/commit/78a3f56002d98f609998fd2cdad8e0299080cb8b)) + +### Tests + +* **rsc:** fix renderBuiltUrl runtime for css ([#838](https://github.com/vitejs/vite-plugin-react/issues/838)) ([19d14c2](https://github.com/vitejs/vite-plugin-react/commit/19d14c220bc66b1d985f5e018876dc5d5ff7b5ce)) +* **rsc:** test adding css import works without reload ([#845](https://github.com/vitejs/vite-plugin-react/issues/845)) ([eab0a16](https://github.com/vitejs/vite-plugin-react/commit/eab0a16986d6cd6cd70621c5b1bf18b6d4425ca8)) +* **rsc:** tweak timeout ([#854](https://github.com/vitejs/vite-plugin-react/issues/854)) ([456449d](https://github.com/vitejs/vite-plugin-react/commit/456449d5c757f3fea51976b6c92ffd69ec767640)) + +## [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..dbddc01dc --- /dev/null +++ b/packages/plugin-rsc/README.md @@ -0,0 +1,573 @@ +# @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 +npm create vite@latest -- --template rsc +``` + +## 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, + + // the plugin provides build-time validation of 'server-only' and 'client-only' imports. + // this is enabled by default. See the "server-only and client-only import" section below for details. + validateImports: true, + + // 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', + + // 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/') }, + + // 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... + +## Tips + +### 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. + +### `server-only` and `client-only` import + + + + + +You can use the `server-only` import to prevent accidentally importing server-only code into client bundles, which can expose sensitive server code in public static assets. +For example, the plugin will show an error `'server-only' cannot be imported in client build` for the following code: + +- server-utils.js + +```tsx +import 'server-only' + +export async function getData() { + const res = await fetch('https://internal-service.com/data', { + headers: { + authorization: process.env.API_KEY, + }, + }) + return res.json() +} +``` + +- client.js + +```tsx +'use client' +import { getData } from './server-utils.js' // ❌ 'server-only' cannot be imported in client build +... +``` + +Similarly, the `client-only` import ensures browser-specific code isn't accidentally imported into server environments. +For example, the plugin will show an error `'client-only' cannot be imported in server build` for the following code: + +- client-utils.js + +```tsx +import 'client-only' + +export function getStorage(key) { + // This uses browser-only APIs + return window.localStorage.getItem(key) +} +``` + +- server.js + +```tsx +import { getStorage } from './client-utils.js' // ❌ 'client-only' cannot be imported in server build + +export function ServerComponent() { + const data = getStorage("settings") + ... +} +``` + +Note that while there are official npm packages [`server-only`](https://www.npmjs.com/package/server-only) and [`client-only`](https://www.npmjs.com/package/client-only) created by React team, they don't need to be installed. The plugin internally overrides these imports and surfaces their runtime errors as build-time errors. + +This build-time validation is enabled by default and can be disabled by setting `validateImports: false` in the plugin options. + +## 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..1306ee314 --- /dev/null +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -0,0 +1,1514 @@ +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) { + // verify duplicate client-reference style link are removed + await expect( + page.locator( + 'link[rel="stylesheet"][data-precedence="vite-rsc/client-reference"]', + ), + ).toHaveCount(0) + await expect( + page + .locator( + 'link[rel="stylesheet"][data-precedence="vite-rsc/importer-resources"]', + ) + .nth(0), + ).toBeAttached() + await expect( + page + .locator( + 'link[rel="stylesheet"][data-precedence="test-style-manual-link"]', + ) + .nth(0), + ).toBeAttached() + } + + 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 remove css links on server hmr. for now, it requires a manually reload. + test('adding/removing css server @js', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.locator('.test-style-server')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + ) + + const editor = f.createEditor('src/routes/style-server/server.tsx') + + // removing and adding new css works via hmr + { + await using _ = await expectNoReload(page) + + // remove css import + editor.edit((s) => + s.replaceAll(`import './server.css'`, `/* import './server.css' */`), + ) + await expect(page.locator('.test-style-server')).toHaveCSS( + 'color', + 'rgb(0, 0, 0)', + ) + + // add new css + editor.edit((s) => + s.replaceAll(`/* import './server.css' */`, `import './server2.css'`), + ) + await expect(page.locator('.test-style-server')).toHaveCSS( + 'color', + 'rgb(0, 255, 165)', + ) + } + + // TODO: React doesn't re-inert same css link. so manual reload is required. + editor.reset() + await page.waitForTimeout(100) + await expect(async () => { + await page.reload() + await expect(page.locator('.test-style-server')).toHaveCSS( + 'color', + 'rgb(255, 165, 0)', + { timeout: 10 }, + ) + }).toPass() + }) + + 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/error.test.ts b/packages/plugin-rsc/e2e/error.test.ts new file mode 100644 index 000000000..ea2b63b11 --- /dev/null +++ b/packages/plugin-rsc/e2e/error.test.ts @@ -0,0 +1,57 @@ +import { test, expect } from '@playwright/test' +import { setupInlineFixture } from './fixture' +import { x } from 'tinyexec' + +test.describe('invalid directives', () => { + test.describe('"use server" in "use client"', () => { + const root = 'examples/e2e/temp/use-server-in-use-client' + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/client.tsx': /* tsx */ ` + "use client"; + + export function TestClient() { + return
[test-client]
+ } + + function testFn() { + "use server"; + console.log("testFn"); + } + `, + 'src/root.tsx': /* tsx */ ` + import { TestClient } from './client.tsx' + + export function Root() { + return ( + + + + + +
[test-server]
+ + + + ) + } + `, + }, + }) + }) + + test('build', async () => { + const result = await x('pnpm', ['build'], { + throwOnError: false, + nodeOptions: { cwd: root }, + }) + expect(result.stderr).toContain( + `'use server' directive is not allowed inside 'use client'`, + ) + expect(result.exitCode).not.toBe(0) + }) + }) +}) 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..f3b5a1596 --- /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: 20000 }, + ) + .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..fdd798d04 --- /dev/null +++ b/packages/plugin-rsc/e2e/isolated.test.ts @@ -0,0 +1,88 @@ +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' +import { waitForHydration } from './helper' + +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) + }) +}) + +test.describe('examples/ssg', () => { + const tmpRoot = path.join( + process.env['RUNNER_TEMP'] || os.tmpdir(), + 'test-vite-rsc-ssg', + ) + test.beforeAll(async () => { + await setupIsolatedFixture({ + src: 'examples/ssg', + dest: tmpRoot, + }) + }) + + test.describe('dev', () => { + const f = useFixture({ root: tmpRoot, mode: 'dev' }) + + test('basic', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + }) + }) + + test.describe('build', () => { + const f = useFixture({ root: tmpRoot, mode: 'build' }) + + test('basic', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + }) + }) +}) 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..0807e2aaf --- /dev/null +++ b/packages/plugin-rsc/e2e/react-router.test.ts @@ -0,0 +1,182 @@ +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('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..e9e70e6d6 --- /dev/null +++ b/packages/plugin-rsc/e2e/render-built-url.test.ts @@ -0,0 +1,170 @@ +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' +import type { RenderBuiltAssetUrl } from 'vite' + +test.describe(() => { + const root = 'examples/e2e/temp/renderBuiltUrl-runtime' + + test.beforeAll(async () => { + const renderBuiltUrl: RenderBuiltAssetUrl = (filename, meta) => { + if (meta.hostType === 'css') { + return { relative: true } + } + 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..e87bcb536 --- /dev/null +++ b/packages/plugin-rsc/e2e/validate-imports.test.ts @@ -0,0 +1,163 @@ +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 }, + }) + // assertion is adjusted for rolldown-vite + expect(result.stderr).toContain(`rsc:validate-imports`) + 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(`rsc:validate-imports`) + 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..ba62c5388 --- /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.14", + "@types/react": "^19.1.16", + "@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.14", + "vite": "^7.1.9", + "wrangler": "^4.42.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..f3fff1842 --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx @@ -0,0 +1,108 @@ +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', + }, + }) +} 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..a7132153c --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-server/server.tsx @@ -0,0 +1,33 @@ +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
+
+ ) +} + +// add no-op `import.meta.hot` to trigger `prune` event. +// this is needed until we land https://github.com/vitejs/vite/pull/20768 +import.meta.hot diff --git a/packages/plugin-rsc/examples/basic/src/routes/style-server/server2.css b/packages/plugin-rsc/examples/basic/src/routes/style-server/server2.css new file mode 100644 index 000000000..46ded0c8c --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/routes/style-server/server2.css @@ -0,0 +1,3 @@ +.test-style-server { + color: rgb(0, 255, 165); +} 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..e69b144ae --- /dev/null +++ b/packages/plugin-rsc/examples/basic/src/server.tsx @@ -0,0 +1,47 @@ +import { handleRequest } from './framework/entry.rsc.tsx' +import './styles.css' + +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 +} + +export default { + fetch: handler, +} + +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..7a4be8457 --- /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-10-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..68ad7dd54 --- /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.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "vite": "^7.1.9" + } +} 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..02e0322c1 --- /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.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "vite": "^7.1.9" + } +} 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..75b019e9f --- /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('[debug] root - Layout') + return ( + + + + + React Router Vite + + +
+ +
+ + {children} + + + ) +} + +export default function Component() { + console.log('[debug] root - Component') + 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..715166e52 --- /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(`[debug] sayHello - ${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..fdaf9db31 --- /dev/null +++ b/packages/plugin-rsc/examples/react-router/app/routes/home.tsx @@ -0,0 +1,42 @@ +import { sayHello } from './home.actions.ts' +import { PendingButton } from './home.client.tsx' +import './home.css' +import { TestActionStateServer } from './test-action-state/server.tsx' + +const Component = () => { + return ( +
+
+

Home

+

This is the home page.

+ [test-style-home] +

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..99be3fdd4 --- /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-10-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..2d5ea6e72 --- /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-10-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..0af663ca1 --- /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.9.3" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.13.10", + "@tailwindcss/typography": "^0.5.19", + "@tailwindcss/vite": "^4.1.14", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "tailwindcss": "^4.1.14", + "vite": "^7.1.9", + "wrangler": "^4.42.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..df81ead48 --- /dev/null +++ b/packages/plugin-rsc/examples/ssg/package.json @@ -0,0 +1,25 @@ +{ + "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.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^7.1.9" + } +} 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..48d1f0784 --- /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.13.10", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^7.1.9" + } +} 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..dd7f1d791 --- /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-10-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..b0d648420 --- /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.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^7.1.9" + } +} 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..62e8de584 --- /dev/null +++ b/packages/plugin-rsc/package.json @@ -0,0 +1,72 @@ +{ + "name": "@vitejs/plugin-rsc", + "version": "0.4.33", + "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.10.0", + "es-module-lexer": "^1.7.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.19", + "periscopic": "^4.0.2", + "turbo-stream": "^3.1.0", + "vitefu": "^1.1.1" + }, + "devDependencies": { + "@hiogawa/utils": "^1.7.0", + "@playwright/test": "^1.55.1", + "@tsconfig/strictest": "^2.0.6", + "@types/estree": "^1.0.8", + "@types/node": "^22.18.8", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "workspace:*", + "@vitejs/test-dep-cjs-and-esm": "./test-dep/cjs-and-esm", + "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.15.6" + }, + "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..520a08ef9 --- /dev/null +++ b/packages/plugin-rsc/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: 'e2e', + use: { + screenshot: 'only-on-failure', + trace: 'on-all-retries', + }, + 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..df214f2be --- /dev/null +++ b/packages/plugin-rsc/src/plugin.ts @@ -0,0 +1,2286 @@ +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, + findDirectives, +} from './transforms' +import { generateEncryptionKey, toBase64 } from './utils/encryption-utils' +import { createRpcServer } from './utils/rpc' +import { + cleanUrl, + directRequestRE, + evalValue, + normalizeViteImportAnalysisUrl, + prepareError, +} from './plugins/vite-utils' +import { cjsModuleRunnerPlugin } from './plugins/cjs' +import { + createVirtualPlugin, + getEntrySource, + hashString, + normalizeRelativePath, + getFetchHandlerExport, + 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) + const fetchHandler = getFetchHandlerExport(mod) + // 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(fetchHandler)(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 fetchHandler = getFetchHandlerExport(mod) + const handler = createRequestListener(fetchHandler) + + // 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') { + return + } + } + + 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/client-references")) { + 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()) +}); +` + // remove stylesheet links when css import is removed on rsc envrionment + code += `import.meta.hot.on("rsc:prune", ${(e: vite.PrunePayload) => { + const nodes = document.querySelectorAll( + "link[rel='stylesheet']", + ) + nodes.forEach((node) => { + if (e.paths.includes(node.dataset.rscCssHref!)) { + node.remove() + } + }) + }});` + 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 + } + + if (code.includes('use server')) { + const directives = findDirectives(ast, 'use server') + if (directives.length > 0) { + this.error( + `'use server' directive is not allowed inside 'use client'`, + directives[0]?.start, + ) + } + } + + 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) && + !(source === 'client-only' || source === 'server-only') + ) { + 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' }), + } + } + }, + }, + { + // force self accepting "?direct" css (injected via SSR ``) to avoid full reload. + // this should only apply to css modules + // https://github.com/vitejs/vite/blob/84079a84ad94de4c1ef4f1bdb2ab448ff2c01196/packages/vite/src/node/plugins/css.ts#L1096 + name: 'rsc:rsc-css-self-accept', + apply: 'serve', + transform: { + order: 'post', + handler(_code, id, _options) { + if ( + this.environment.name === 'client' && + this.environment.mode === 'dev' && + isCSSRequest(id) && + directRequestRE.test(id) + ) { + const mod = this.environment.moduleGraph.getModuleById(id) + if (mod && !mod.isSelfAccepting) { + mod.isSelfAccepting = true + } + } + }, + }, + }, + { + 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', + configureServer(server) { + // delegate 'prune' event from rsc environment to browser + const hot = server.environments.rsc!.hot + const original = hot.send + hot.send = function (this, ...args: any[]) { + const e = args[0] as vite.PrunePayload + if (e && typeof e === 'object' && e.type === 'prune') { + server.environments.client.hot.send({ + type: 'custom', + event: 'rsc:prune', + data: e, + }) + } + return original.apply(this, args as any) + } + }, + 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) + for (const file of [importer, ...result.visitedFiles]) { + this.addWatchFile(file) + } + const cssHrefs = result.hrefs.map((href) => href.slice(1)) + const deps = assetsURLOfDeps({ css: cssHrefs, js: [] }, 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, + )} + ` + } + } + }, + }, + createVirtualPlugin( + 'vite-rsc/remove-duplicate-server-css', + async function () { + // Remove duplicate css during dev due to server rendered and client inline