diff --git a/.eslintignore b/.eslintignore index dbd9643de0..f36aec4840 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,6 +6,17 @@ examples/**/dist/ worker-configuration.d.ts /playground/ /playground-local/ +integration/helpers/**/dist/ +integration/helpers/**/build/ +# Temporary until we can get prettier upgraded to support `import ... with` syntax +integration/helpers/rsc-parcel/src/server.tsx +playwright-report/ +test-results/ +build.utils.d.ts +.wrangler/ +.tmp/ +.react-router/ +.react-router-parcel/ packages/**/dist/ packages/react-router-dom/server.d.ts packages/react-router-dom/server.js diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a047864b86..4ef4cbaa8e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -8,46 +8,31 @@ body: value: | Thank you for helping to improve React Router! - - **All** bugs must have a **minimal** reproduction - - Minimal means that it is not just pointing to a deployed site or a branch in your existing application - - The preferred method is StackBlitz via [https://reactrouter.com/new](https://reactrouter.com/new) - - If Stackblitz is not an option, a GitHub repo based on a fresh `create-react-router` app is acceptable - - Only in extraordinary circumstances will code snippets or maximal reproductions be accepted - - Issue Review - - Issues not meeting the above criteria will be closed and pointed to this document - - Non-issues (feature requests, usage questions) will also be closed with a link to this document - - The SC will triage issues regularly - - Fixing Issues - - The SC will mark good community issues with an `Accepting PRs` label - - These issues will generally be ones that are likely to have a small surface area fix - - However, anyone can work on any issue but there's no guarantee the PR will be accepted if the surface area is too large for expedient review by a core team member + Please note that **all** bugs must have a **minimal** and **runnable** reproduction, meaning that it is not just pointing to a deployed site or a branch in your existing application, or showing a small code snippet. For more information, please refer to the [Bug/Issue Process Guidelines](https://github.com/remix-run/react-router/blob/main/GOVERNANCE.md#bugissue-process). - ## Option 1: Submit a PR with a failing test + ## If you are using **Framework Mode**, you have 2 preferred options + + ### Option 1 - Create a failing integration test ๐Ÿ† The most helpful reproduction is to use our _bug report integration test_ template: 1. [Fork `remix-run/react-router`](https://github.com/remix-run/react-router/fork) 2. Open [`integration/bug-report-test.ts`](https://github.com/remix-run/react-router/blob/dev/integration/bug-report-test.ts) in your editor - 3. Follow the instructions and submit a pull request with a failing bug report test! - - ## Option 2: Continue filling out this form + 3. Follow the instructions to create a failing bug report test + 4. Link to your forked branch with the failing test in this issue - If you'd rather open a GitHub issue, here are other ways to share a reproduction (ordered from most helpful to least): + ### Option 2 - Create a **minimal** reproduction - ๐Ÿฅ‡ Link to a [StackBlitz](https://reactrouter.com/new) environment - - ๐Ÿฅˆ Link to a GitHub repository - - ๐Ÿฅ‰ Description of project including template, config files, `package.json` scripts, etc. + - ๐Ÿฅˆ Link to a GitHub repository containing a minimal reproduction app + + ## If you are using **Data** or **Declarative Mode** + + Create a **minimal** reproduction + + - ๐Ÿฅ‡ Link to a CodeSandbox repro: [TS](https://codesandbox.io/templates/react-vite-ts) | [JS](https://codesandbox.io/templates/react-vite) + - ๐Ÿฅˆ Link to a GitHub repository containing a minimal reproduction app - - type: dropdown - id: mode - attributes: - label: I'm using React Router as a... - description: See https://reactrouter.com/home for explanation - options: - - library - - framework - validations: - required: true - type: textarea id: reproduction attributes: diff --git a/.github/workflows/close-feature-pr.yml b/.github/workflows/close-feature-pr.yml new file mode 100644 index 0000000000..945a420f10 --- /dev/null +++ b/.github/workflows/close-feature-pr.yml @@ -0,0 +1,26 @@ +# Close a singular pull request that implements a feature that has not +# gone through the Proposal process +# Triggered by adding the `feature-request` label to an issue + +name: ๐Ÿšช Check Feature PR + +on: + pull_request: + types: [labeled] + +jobs: + close-feature-pr: + name: ๐Ÿšช Check Feature PR + if: github.repository == 'remix-run/react-router' && github.event.label.name == 'feature-request' + runs-on: ubuntu-latest + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v4 + + - name: ๐Ÿšช Close PR + env: + GH_TOKEN: ${{ github.token }} + run: | + gh pr comment ${{ github.event.pull_request.number }} -F ./scripts/close-feature-pr.md + gh pr edit ${{ github.event.pull_request.number }} --remove-label ${{ github.event.label.name }} + gh pr close ${{ github.event.pull_request.number }} diff --git a/.github/workflows/close-no-repro-issue.yml b/.github/workflows/close-no-repro-issue.yml new file mode 100644 index 0000000000..e1e9e34fa9 --- /dev/null +++ b/.github/workflows/close-no-repro-issue.yml @@ -0,0 +1,25 @@ +# Close a singular issue without a reproduction +# Triggered by adding the `no-reproduction` label to an issue + +name: ๐Ÿšช Close issue without a reproduction + +on: + issues: + types: [labeled] + +jobs: + close-no-repro-issue: + name: ๐Ÿšช Close issue + if: github.repository == 'remix-run/react-router' && github.event.label.name == 'no-reproduction' + runs-on: ubuntu-latest + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v4 + + - name: ๐Ÿšช Close issue + env: + GH_TOKEN: ${{ github.token }} + run: | + gh issue comment ${{ github.event.issue.number }} -F ./scripts/close-no-repro-issue.md + gh issue edit ${{ github.event.issue.number }} --remove-label ${{ github.event.label.name }} + gh issue close ${{ github.event.issue.number }} -r "not planned"; diff --git a/.github/workflows/close-no-repro-issues.yml b/.github/workflows/close-no-repro-issues.yml new file mode 100644 index 0000000000..d84bd27dd3 --- /dev/null +++ b/.github/workflows/close-no-repro-issues.yml @@ -0,0 +1,49 @@ +# This is a bulk-close script that was used initially to find and close issues +# without a repro, but moving forward we'll likely use the singular version +# (close-no-repro-issue.yml) on new issues which is driven by a label added to +# the issue + +name: ๐Ÿšช Close issues without a reproduction + +on: + workflow_dispatch: + inputs: + dryRun: + type: boolean + description: "Dry Run? (no issues will be closed)" + default: false + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + close-no-repro-issues: + name: ๐Ÿšช Close issues + if: github.repository == 'remix-run/react-router' + runs-on: ubuntu-latest + env: + CI: "true" + GH_TOKEN: ${{ github.token }} + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v4 + + - name: ๐Ÿ“ฆ Setup pnpm + uses: pnpm/action-setup@v4.1.0 + + - name: โŽ” Setup node + uses: actions/setup-node@v4 + with: + # required for --experimental-strip-types + node-version: 22 + cache: "pnpm" + + - name: ๐Ÿ“ฅ Install deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿšช Close Issues (Dry Run) + if: ${{ inputs.dryRun }} + run: node --experimental-strip-types ./scripts/close-no-repro-issues.ts --dryRun + + - name: ๐Ÿšช Close Issues + if: ${{ ! inputs.dryRun }} + run: node --experimental-strip-types ./scripts/close-no-repro-issues.ts diff --git a/.github/workflows/deduplicate-lock-file.yml b/.github/workflows/deduplicate-lock-file.yml index b73aa8e467..f10197ea6d 100644 --- a/.github/workflows/deduplicate-lock-file.yml +++ b/.github/workflows/deduplicate-lock-file.yml @@ -19,6 +19,8 @@ jobs: steps: - name: โฌ‡๏ธ Checkout repo uses: actions/checkout@v4 + with: + token: ${{ secrets.FORMAT_PAT }} - name: ๐Ÿ“ฆ Setup pnpm uses: pnpm/action-setup@v4 @@ -44,4 +46,4 @@ jobs: fi git commit -m "chore: deduplicate \`pnpm-lock.yaml\`" git push - echo "๐Ÿ’ฟ https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" + echo "๐Ÿ’ฟ pushed dedupe changes https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000..ad63e44b8d --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,68 @@ +name: ๐Ÿ“š Docs + +on: + workflow_dispatch: + inputs: + branch: + description: "Branch to generate docs for" + required: true + api: + description: "API Names to generate docs for" + required: false + default: "" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + docs: + if: github.repository == 'remix-run/react-router' + runs-on: ubuntu-latest + + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v4 + with: + token: ${{ secrets.FORMAT_PAT }} + ref: ${{ github.event.inputs.branch }} + + - name: ๐Ÿ“ฆ Setup pnpm + uses: pnpm/action-setup@v4 + + - name: โŽ” Setup node + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: pnpm + + - name: ๐Ÿ“ฅ Install deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ— Build + run: pnpm build + + - name: ๐Ÿ“š Generate Typedoc Docs + run: pnpm run docs + + - name: ๐Ÿ“š Generate Markdown Docs (for all APIs) + if: github.event.inputs.api == '' + run: node --experimental-strip-types scripts/docs.ts --path packages/react-router/lib/hooks.tsx --write + + - name: ๐Ÿ“š Generate Markdown Docs (for specific APIs) + if: github.event.inputs.api != '' + run: node --experimental-strip-types scripts/docs.ts --path packages/react-router/lib/hooks.tsx --write --api ${{ github.event.inputs.api }} + + - name: ๐Ÿ’ช Commit + run: | + git config --local user.email "hello@remix.run" + git config --local user.name "Remix Run Bot" + + git add . + if [ -z "$(git status --porcelain)" ]; then + echo "๐Ÿ’ฟ no docs changed" + exit 0 + fi + git commit -m "chore: generate markdown docs from jsdocs" + git push + echo "๐Ÿ’ฟ pushed docs changes https://github.com/$GITHUB_REPOSITORY/commit/$(git rev-parse HEAD)" diff --git a/.github/workflows/integration-full.yml b/.github/workflows/integration-full.yml index fd2f79bfa9..cb1d064ee2 100644 --- a/.github/workflows/integration-full.yml +++ b/.github/workflows/integration-full.yml @@ -40,7 +40,7 @@ jobs: uses: ./.github/workflows/shared-integration.yml with: os: "windows-latest" - node_version: "[20, 22]" + node_version: "[22]" browser: '["msedge"]' integration-macos: diff --git a/.github/workflows/integration-pr-windows-macos.yml b/.github/workflows/integration-pr-windows-macos.yml index a36aef144d..12bbf2b77c 100644 --- a/.github/workflows/integration-pr-windows-macos.yml +++ b/.github/workflows/integration-pr-windows-macos.yml @@ -32,7 +32,7 @@ jobs: os: "windows-latest" node_version: "[22]" browser: '["msedge"]' - timeout: 60 + timeout: 120 integration-webkit: name: "๐Ÿ‘€ Integration Test" diff --git a/.github/workflows/release-stage-2-alpha.yml b/.github/workflows/release-stage-2-alpha.yml new file mode 100644 index 0000000000..ea930ff35f --- /dev/null +++ b/.github/workflows/release-stage-2-alpha.yml @@ -0,0 +1,86 @@ +name: ๐Ÿงช Check Alpha Release + +on: + pull_request: + types: [labeled] + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +env: + CI: true + +jobs: + alpha-release: + name: ๐Ÿงช Check Alpha Release + if: github.repository == 'remix-run/react-router' && github.event.label.name == 'alpha-release' + runs-on: ubuntu-latest + steps: + - name: ๐Ÿ“„ Log Info + run: | + echo "Label: ${{ github.event.label.name }}" + echo "Branch: ${{ github.event.pull_request.head.ref }}" + echo "SHA: ${{ github.event.pull_request.head.sha }}" + + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Log Git Info + run: | + git log -n 1 + git status + + - name: ๐Ÿ“ฆ Setup pnpm + uses: pnpm/action-setup@v4 + + - name: โŽ” Setup node + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - name: ๐Ÿ“ฅ Install deps + run: pnpm install --frozen-lockfile + + - name: โคด๏ธ Update version + id: version + run: | + git config --local user.email "hello@remix.run" + git config --local user.name "Remix Run Bot" + SHORT_SHA=$(git rev-parse --short HEAD) + NEXT_VERSION=0.0.0-experimental-${SHORT_SHA} + git checkout -b experimental/${NEXT_VERSION} + pnpm run version ${NEXT_VERSION} + echo "version=${NEXT_VERSION}" >> "$GITHUB_OUTPUT" + + - name: ๐Ÿ— Build + run: pnpm build + + - name: ๐Ÿ” Setup npm auth + run: | + echo "registry=https://registry.npmjs.org" >> ~/.npmrc + echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc + + - name: ๐Ÿš€ Publish + run: pnpm run publish + + - name: ๐Ÿ’ฌ Comment + env: + GH_TOKEN: ${{ github.token }} + run: | + LATEST_RELEASE_SHA=$(gh release list --limit 1 --json tagName --jq ".[0].tagName") + BASE_SHA=$(echo ${{ github.event.pull_request.base.sha }} | cut -c1-7) + COMMAND="git log --pretty=oneline ${LATEST_RELEASE_SHA}..${BASE_SHA}" + echo -e \ + "[Alpha release](https://github.com/remix-run/react-router/blob/main/GOVERNANCE.md#stage-2--alpha) \ + created: \`${{ steps.version.outputs.version }}\`\n\n \ + โš ๏ธ **Note:** This release was created from the \`HEAD\` of this branch so it \ + may contain commits that have landed in \`dev\` but have not been released yet \ + depending on when this branch was created. You can run the following command \ + to see the commits that may not have been released yet:\n\n \ + \`\`\`bash\n \ + ${COMMAND}\n \ + \`\`\`" \ + | gh pr comment ${{ github.event.pull_request.number }} --body-file - + gh pr edit ${{ github.event.pull_request.number }} --remove-label ${{ github.event.label.name }} diff --git a/.gitignore b/.gitignore index b13e7650a9..53e9a5baf1 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ node_modules/ .wireit .eslintcache +.parcel-cache .tmp tsup.config.bundled_*.mjs build.utils.d.ts diff --git a/.nvmrc b/.nvmrc index 2edeafb09d..8fdd954df9 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 \ No newline at end of file +22 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index dfbb78e0a2..1410011e91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,82 +13,89 @@ We manage release notes in this file instead of the paginated Github Releases Pa Table of Contents - [React Router Releases](#react-router-releases) - - [v7.6.3](#v763) + - [v7.7.0](#v770) + - [What's Changed](#whats-changed) + - [Unstable RSC APIs](#unstable-rsc-apis) + - [Minor Changes](#minor-changes) - [Patch Changes](#patch-changes) - - [v7.6.2](#v762) + - [Unstable Changes](#unstable-changes) + - [Changes by Package](#changes-by-package) + - [v7.6.3](#v763) - [Patch Changes](#patch-changes-1) - - [v7.6.1](#v761) + - [v7.6.2](#v762) - [Patch Changes](#patch-changes-2) - - [Unstable Changes](#unstable-changes) + - [v7.6.1](#v761) + - [Patch Changes](#patch-changes-3) + - [Unstable Changes](#unstable-changes-1) - [v7.6.0](#v760) - - [What's Changed](#whats-changed) + - [What's Changed](#whats-changed-1) - [`routeDiscovery` Config Option](#routediscovery-config-option) - [Automatic Types for Future Flags](#automatic-types-for-future-flags) - - [Minor Changes](#minor-changes) - - [Patch Changes](#patch-changes-3) - - [Unstable Changes](#unstable-changes-1) - - [Changes by Package](#changes-by-package) - - [v7.5.3](#v753) + - [Minor Changes](#minor-changes-1) - [Patch Changes](#patch-changes-4) + - [Unstable Changes](#unstable-changes-2) + - [Changes by Package](#changes-by-package-1) + - [v7.5.3](#v753) + - [Patch Changes](#patch-changes-5) - [v7.5.2](#v752) - [Security Notice](#security-notice) - - [Patch Changes](#patch-changes-5) - - [v7.5.1](#v751) - [Patch Changes](#patch-changes-6) - - [Unstable Changes](#unstable-changes-2) - - [v7.5.0](#v750) - - [What's Changed](#whats-changed-1) - - [`route.lazy` Object API](#routelazy-object-api) - - [Minor Changes](#minor-changes-1) + - [v7.5.1](#v751) - [Patch Changes](#patch-changes-7) - [Unstable Changes](#unstable-changes-3) - - [Changes by Package](#changes-by-package-1) - - [v7.4.1](#v741) - - [Security Notice](#security-notice-1) + - [v7.5.0](#v750) + - [What's Changed](#whats-changed-2) + - [`route.lazy` Object API](#routelazy-object-api) + - [Minor Changes](#minor-changes-2) - [Patch Changes](#patch-changes-8) - [Unstable Changes](#unstable-changes-4) - - [v7.4.0](#v740) - - [Minor Changes](#minor-changes-2) + - [Changes by Package](#changes-by-package-2) + - [v7.4.1](#v741) + - [Security Notice](#security-notice-1) - [Patch Changes](#patch-changes-9) - [Unstable Changes](#unstable-changes-5) - - [Changes by Package](#changes-by-package-2) - - [v7.3.0](#v730) + - [v7.4.0](#v740) - [Minor Changes](#minor-changes-3) - [Patch Changes](#patch-changes-10) - [Unstable Changes](#unstable-changes-6) + - [Changes by Package](#changes-by-package-3) + - [v7.3.0](#v730) + - [Minor Changes](#minor-changes-4) + - [Patch Changes](#patch-changes-11) + - [Unstable Changes](#unstable-changes-7) - [Client-side `context` (unstable)](#client-side-context-unstable) - [Middleware (unstable)](#middleware-unstable) - [Middleware `context` parameter](#middleware-context-parameter) - [`unstable_SerializesTo`](#unstable_serializesto) - - [Changes by Package](#changes-by-package-3) + - [Changes by Package](#changes-by-package-4) - [v7.2.0](#v720) - - [What's Changed](#whats-changed-2) + - [What's Changed](#whats-changed-3) - [Type-safe `href` utility](#type-safe-href-utility) - [Prerendering with a SPA Fallback](#prerendering-with-a-spa-fallback) - [Allow a root `loader` in SPA Mode](#allow-a-root-loader-in-spa-mode) - - [Minor Changes](#minor-changes-4) - - [Patch Changes](#patch-changes-11) - - [Unstable Changes](#unstable-changes-7) + - [Minor Changes](#minor-changes-5) + - [Patch Changes](#patch-changes-12) + - [Unstable Changes](#unstable-changes-8) - [Split Route Modules (unstable)](#split-route-modules-unstable) - - [Changes by Package](#changes-by-package-4) + - [Changes by Package](#changes-by-package-5) - [v7.1.5](#v715) - - [Patch Changes](#patch-changes-12) - - [v7.1.4](#v714) - [Patch Changes](#patch-changes-13) - - [v7.1.3](#v713) + - [v7.1.4](#v714) - [Patch Changes](#patch-changes-14) - - [v7.1.2](#v712) + - [v7.1.3](#v713) - [Patch Changes](#patch-changes-15) - - [v7.1.1](#v711) + - [v7.1.2](#v712) - [Patch Changes](#patch-changes-16) - - [v7.1.0](#v710) - - [Minor Changes](#minor-changes-5) + - [v7.1.1](#v711) - [Patch Changes](#patch-changes-17) - - [Changes by Package](#changes-by-package-5) - - [v7.0.2](#v702) + - [v7.1.0](#v710) + - [Minor Changes](#minor-changes-6) - [Patch Changes](#patch-changes-18) - - [v7.0.1](#v701) + - [Changes by Package](#changes-by-package-6) + - [v7.0.2](#v702) - [Patch Changes](#patch-changes-19) + - [v7.0.1](#v701) + - [Patch Changes](#patch-changes-20) - [v7.0.0](#v700) - [Breaking Changes](#breaking-changes) - [Package Restructuring](#package-restructuring) @@ -104,202 +111,202 @@ We manage release notes in this file instead of the paginated Github Releases Pa - [Prerendering](#prerendering) - [Major Changes (`react-router`)](#major-changes-react-router) - [Major Changes (`@react-router/*`)](#major-changes-react-router-1) - - [Minor Changes](#minor-changes-6) - - [Patch Changes](#patch-changes-20) - - [Changes by Package](#changes-by-package-6) + - [Minor Changes](#minor-changes-7) + - [Patch Changes](#patch-changes-21) + - [Changes by Package](#changes-by-package-7) - [React Router v6 Releases](#react-router-v6-releases) - [v6.30.1](#v6301) - - [Patch Changes](#patch-changes-21) - - [v6.30.0](#v6300) - - [Minor Changes](#minor-changes-7) - [Patch Changes](#patch-changes-22) - - [v6.29.0](#v6290) + - [v6.30.0](#v6300) - [Minor Changes](#minor-changes-8) - [Patch Changes](#patch-changes-23) - - [v6.28.2](#v6282) + - [v6.29.0](#v6290) + - [Minor Changes](#minor-changes-9) - [Patch Changes](#patch-changes-24) - - [v6.28.1](#v6281) + - [v6.28.2](#v6282) - [Patch Changes](#patch-changes-25) - - [v6.28.0](#v6280) - - [What's Changed](#whats-changed-3) - - [Minor Changes](#minor-changes-9) + - [v6.28.1](#v6281) - [Patch Changes](#patch-changes-26) - - [v6.27.0](#v6270) + - [v6.28.0](#v6280) - [What's Changed](#whats-changed-4) - - [Stabilized APIs](#stabilized-apis) - [Minor Changes](#minor-changes-10) - [Patch Changes](#patch-changes-27) - - [v6.26.2](#v6262) + - [v6.27.0](#v6270) + - [What's Changed](#whats-changed-5) + - [Stabilized APIs](#stabilized-apis) + - [Minor Changes](#minor-changes-11) - [Patch Changes](#patch-changes-28) - - [v6.26.1](#v6261) + - [v6.26.2](#v6262) - [Patch Changes](#patch-changes-29) - - [v6.26.0](#v6260) - - [Minor Changes](#minor-changes-11) + - [v6.26.1](#v6261) - [Patch Changes](#patch-changes-30) - - [v6.25.1](#v6251) + - [v6.26.0](#v6260) + - [Minor Changes](#minor-changes-12) - [Patch Changes](#patch-changes-31) + - [v6.25.1](#v6251) + - [Patch Changes](#patch-changes-32) - [v6.25.0](#v6250) - - [What's Changed](#whats-changed-5) + - [What's Changed](#whats-changed-6) - [Stabilized `v7_skipActionErrorRevalidation`](#stabilized-v7_skipactionerrorrevalidation) - - [Minor Changes](#minor-changes-12) - - [Patch Changes](#patch-changes-32) - - [v6.24.1](#v6241) + - [Minor Changes](#minor-changes-13) - [Patch Changes](#patch-changes-33) + - [v6.24.1](#v6241) + - [Patch Changes](#patch-changes-34) - [v6.24.0](#v6240) - - [What's Changed](#whats-changed-6) + - [What's Changed](#whats-changed-7) - [Lazy Route Discovery (a.k.a. "Fog of War")](#lazy-route-discovery-aka-fog-of-war) - - [Minor Changes](#minor-changes-13) - - [Patch Changes](#patch-changes-34) - - [v6.23.1](#v6231) + - [Minor Changes](#minor-changes-14) - [Patch Changes](#patch-changes-35) + - [v6.23.1](#v6231) + - [Patch Changes](#patch-changes-36) - [v6.23.0](#v6230) - - [What's Changed](#whats-changed-7) + - [What's Changed](#whats-changed-8) - [Data Strategy (unstable)](#data-strategy-unstable) - [Skip Action Error Revalidation (unstable)](#skip-action-error-revalidation-unstable) - - [Minor Changes](#minor-changes-14) + - [Minor Changes](#minor-changes-15) - [v6.22.3](#v6223) - - [Patch Changes](#patch-changes-36) - - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-37) - - [v6.22.1](#v6221) + - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-38) + - [v6.22.1](#v6221) + - [Patch Changes](#patch-changes-39) - [v6.22.0](#v6220) - - [What's Changed](#whats-changed-8) + - [What's Changed](#whats-changed-9) - [Core Web Vitals Technology Report Flag](#core-web-vitals-technology-report-flag) - - [Minor Changes](#minor-changes-15) - - [Patch Changes](#patch-changes-39) - - [v6.21.3](#v6213) + - [Minor Changes](#minor-changes-16) - [Patch Changes](#patch-changes-40) - - [v6.21.2](#v6212) + - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-41) - - [v6.21.1](#v6211) + - [v6.21.2](#v6212) - [Patch Changes](#patch-changes-42) + - [v6.21.1](#v6211) + - [Patch Changes](#patch-changes-43) - [v6.21.0](#v6210) - - [What's Changed](#whats-changed-9) + - [What's Changed](#whats-changed-10) - [`future.v7_relativeSplatPath`](#futurev7_relativesplatpath) - [Partial Hydration](#partial-hydration) - - [Minor Changes](#minor-changes-16) - - [Patch Changes](#patch-changes-43) - - [v6.20.1](#v6201) - - [Patch Changes](#patch-changes-44) - - [v6.20.0](#v6200) - [Minor Changes](#minor-changes-17) + - [Patch Changes](#patch-changes-44) + - [v6.20.1](#v6201) - [Patch Changes](#patch-changes-45) - - [v6.19.0](#v6190) - - [What's Changed](#whats-changed-10) - - [`unstable_flushSync` API](#unstable_flushsync-api) + - [v6.20.0](#v6200) - [Minor Changes](#minor-changes-18) - [Patch Changes](#patch-changes-46) - - [v6.18.0](#v6180) + - [v6.19.0](#v6190) - [What's Changed](#whats-changed-11) - - [New Fetcher APIs](#new-fetcher-apis) - - [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist) + - [`unstable_flushSync` API](#unstable_flushsync-api) - [Minor Changes](#minor-changes-19) - [Patch Changes](#patch-changes-47) - - [v6.17.0](#v6170) + - [v6.18.0](#v6180) - [What's Changed](#whats-changed-12) - - [View Transitions ๐Ÿš€](#view-transitions-) + - [New Fetcher APIs](#new-fetcher-apis) + - [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist) - [Minor Changes](#minor-changes-20) - [Patch Changes](#patch-changes-48) - - [v6.16.0](#v6160) + - [v6.17.0](#v6170) + - [What's Changed](#whats-changed-13) + - [View Transitions ๐Ÿš€](#view-transitions-) - [Minor Changes](#minor-changes-21) - [Patch Changes](#patch-changes-49) - - [v6.15.0](#v6150) + - [v6.16.0](#v6160) - [Minor Changes](#minor-changes-22) - [Patch Changes](#patch-changes-50) - - [v6.14.2](#v6142) + - [v6.15.0](#v6150) + - [Minor Changes](#minor-changes-23) - [Patch Changes](#patch-changes-51) - - [v6.14.1](#v6141) + - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-52) - - [v6.14.0](#v6140) - - [What's Changed](#whats-changed-13) - - [JSON/Text Submissions](#jsontext-submissions) - - [Minor Changes](#minor-changes-23) + - [v6.14.1](#v6141) - [Patch Changes](#patch-changes-53) - - [v6.13.0](#v6130) + - [v6.14.0](#v6140) - [What's Changed](#whats-changed-14) - - [`future.v7_startTransition`](#futurev7_starttransition) + - [JSON/Text Submissions](#jsontext-submissions) - [Minor Changes](#minor-changes-24) - [Patch Changes](#patch-changes-54) - - [v6.12.1](#v6121) - - [Patch Changes](#patch-changes-55) - - [v6.12.0](#v6120) + - [v6.13.0](#v6130) - [What's Changed](#whats-changed-15) - - [`React.startTransition` support](#reactstarttransition-support) + - [`future.v7_startTransition`](#futurev7_starttransition) - [Minor Changes](#minor-changes-25) + - [Patch Changes](#patch-changes-55) + - [v6.12.1](#v6121) - [Patch Changes](#patch-changes-56) - - [v6.11.2](#v6112) + - [v6.12.0](#v6120) + - [What's Changed](#whats-changed-16) + - [`React.startTransition` support](#reactstarttransition-support) + - [Minor Changes](#minor-changes-26) - [Patch Changes](#patch-changes-57) - - [v6.11.1](#v6111) + - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-58) - - [v6.11.0](#v6110) - - [Minor Changes](#minor-changes-26) + - [v6.11.1](#v6111) - [Patch Changes](#patch-changes-59) - - [v6.10.0](#v6100) - - [What's Changed](#whats-changed-16) + - [v6.11.0](#v6110) - [Minor Changes](#minor-changes-27) - - [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod) - [Patch Changes](#patch-changes-60) - - [v6.9.0](#v690) + - [v6.10.0](#v6100) - [What's Changed](#whats-changed-17) - - [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties) - - [Introducing Lazy Route Modules](#introducing-lazy-route-modules) - [Minor Changes](#minor-changes-28) + - [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod) - [Patch Changes](#patch-changes-61) - - [v6.8.2](#v682) + - [v6.9.0](#v690) + - [What's Changed](#whats-changed-18) + - [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties) + - [Introducing Lazy Route Modules](#introducing-lazy-route-modules) + - [Minor Changes](#minor-changes-29) - [Patch Changes](#patch-changes-62) - - [v6.8.1](#v681) + - [v6.8.2](#v682) - [Patch Changes](#patch-changes-63) - - [v6.8.0](#v680) - - [Minor Changes](#minor-changes-29) + - [v6.8.1](#v681) - [Patch Changes](#patch-changes-64) - - [v6.7.0](#v670) + - [v6.8.0](#v680) - [Minor Changes](#minor-changes-30) - [Patch Changes](#patch-changes-65) - - [v6.6.2](#v662) + - [v6.7.0](#v670) + - [Minor Changes](#minor-changes-31) - [Patch Changes](#patch-changes-66) - - [v6.6.1](#v661) + - [v6.6.2](#v662) - [Patch Changes](#patch-changes-67) - - [v6.6.0](#v660) - - [What's Changed](#whats-changed-18) - - [Minor Changes](#minor-changes-31) + - [v6.6.1](#v661) - [Patch Changes](#patch-changes-68) - - [v6.5.0](#v650) + - [v6.6.0](#v660) - [What's Changed](#whats-changed-19) - [Minor Changes](#minor-changes-32) - [Patch Changes](#patch-changes-69) - - [v6.4.5](#v645) + - [v6.5.0](#v650) + - [What's Changed](#whats-changed-20) + - [Minor Changes](#minor-changes-33) - [Patch Changes](#patch-changes-70) - - [v6.4.4](#v644) + - [v6.4.5](#v645) - [Patch Changes](#patch-changes-71) - - [v6.4.3](#v643) + - [v6.4.4](#v644) - [Patch Changes](#patch-changes-72) - - [v6.4.2](#v642) + - [v6.4.3](#v643) - [Patch Changes](#patch-changes-73) - - [v6.4.1](#v641) + - [v6.4.2](#v642) - [Patch Changes](#patch-changes-74) + - [v6.4.1](#v641) + - [Patch Changes](#patch-changes-75) - [v6.4.0](#v640) - - [What's Changed](#whats-changed-20) + - [What's Changed](#whats-changed-21) - [Remix Data APIs](#remix-data-apis) - - [Patch Changes](#patch-changes-75) + - [Patch Changes](#patch-changes-76) - [v6.3.0](#v630) - - [Minor Changes](#minor-changes-33) + - [Minor Changes](#minor-changes-34) - [v6.2.2](#v622) - - [Patch Changes](#patch-changes-76) - - [v6.2.1](#v621) - [Patch Changes](#patch-changes-77) - - [v6.2.0](#v620) - - [Minor Changes](#minor-changes-34) + - [v6.2.1](#v621) - [Patch Changes](#patch-changes-78) - - [v6.1.1](#v611) - - [Patch Changes](#patch-changes-79) - - [v6.1.0](#v610) + - [v6.2.0](#v620) - [Minor Changes](#minor-changes-35) + - [Patch Changes](#patch-changes-79) + - [v6.1.1](#v611) - [Patch Changes](#patch-changes-80) - - [v6.0.2](#v602) + - [v6.1.0](#v610) + - [Minor Changes](#minor-changes-36) - [Patch Changes](#patch-changes-81) - - [v6.0.1](#v601) + - [v6.0.2](#v602) - [Patch Changes](#patch-changes-82) + - [v6.0.1](#v601) + - [Patch Changes](#patch-changes-83) - [v6.0.0](#v600) @@ -341,6 +348,93 @@ Date: YYYY-MM-DD **Full Changelog**: [`v7.X.Y...v7.X.Y`](https://github.com/remix-run/react-router/compare/react-router@7.X.Y...react-router@7.X.Y) --> +## v7.7.0 + +Date: 2025-07-16 + +### What's Changed + +#### Unstable RSC APIs + +We're excited to introduce experimental support for RSC in Data Mode via the following new APIs: + +- [`unstable_RSCHydratedRouter`](https://reactrouter.com/api/rsc/RSCHydratedRouter) +- [`unstable_RSCStaticRouter`](https://reactrouter.com/api/rsc/RSCStaticRouter) +- [`unstable_createCallServer`](https://reactrouter.com/api/rsc/createCallServer) +- [`unstable_getRSCStream`](https://reactrouter.com/api/rsc/getRSCStream) +- [`unstable_matchRSCServerRequest`](https://reactrouter.com/api/rsc/matchRSCServerRequest) +- [`unstable_routeRSCServerRequest`](https://reactrouter.com/api/rsc/routeRSCServerRequest) + +For more information, check out the [blog post](https://remix.run/blog/react-router-and-react-server-components) and the [RSC Docs](https://reactrouter.com/how-to/react-server-components). + +### Minor Changes + +- `create-react-router` - Add Deno as a supported and detectable package manager. Note that this detection will only work with Deno versions 2.0.5 and above. If you are using an older version version of Deno then you must specify the --package-manager CLI flag set to `deno`. ([#12327](https://github.com/remix-run/react-router/pull/12327)) +- `@react-router/remix-config-routes-adapter` - Export `DefineRouteFunction` type alongside `DefineRoutesFunction` ([#13945](https://github.com/remix-run/react-router/pull/13945)) + +### Patch Changes + +- `react-router` - Handle `InvalidCharacterError` when validating cookie signature ([#13847](https://github.com/remix-run/react-router/pull/13847)) +- `react-router` - Pass a copy of `searchParams` to the `setSearchParams` callback function to avoid mutations of the internal `searchParams` instance ([#12784](https://github.com/remix-run/react-router/pull/12784)) + - This causes bugs if you mutate the current stateful `searchParams` when a navigation is blocked because the internal instance gets out of sync with `useLocation().search` +- `react-router` - Support invalid `Date` in `turbo-stream` v2 fork ([#13684](https://github.com/remix-run/react-router/pull/13684)) +- `react-router` - In Framework Mode, clear critical CSS in development after initial render ([#13872](https://github.com/remix-run/react-router/pull/13872), [#13995](https://github.com/remix-run/react-router/pull/13995)) +- `react-router` - Strip search parameters from `patchRoutesOnNavigation` `path` param for fetcher calls ([#13911](https://github.com/remix-run/react-router/pull/13911)) +- `react-router` - Skip scroll restoration on `useRevalidator()` calls because they're not new locations ([#13671](https://github.com/remix-run/react-router/pull/13671)) +- `react-router` - Support unencoded UTF-8 routes in prerender config with `ssr` set to `false` ([#13699](https://github.com/remix-run/react-router/pull/13699)) +- `react-router` - Do not throw if the url hash is not a valid URI component ([#13247](https://github.com/remix-run/react-router/pull/13247)) +- `react-router` - Remove `Content-Length` header from Single Fetch responses ([#13902](https://github.com/remix-run/react-router/pull/13902)) +- `react-router` - Fix a regression in `createRoutesStub` introduced with the middleware feature ([#13946](https://github.com/remix-run/react-router/pull/13946)) + + - As part of that work we altered the signature to align with the new middleware APIs without making it backwards compatible with the prior `AppLoadContext` API + - This permitted `createRoutesStub` to work if you were opting into middleware and the updated `context` typings, but broke `createRoutesStub` for users not yet opting into middleware + - We've reverted this change and re-implemented it in such a way that both sets of users can leverage it + - โš ๏ธ This may be a breaking bug for if you have adopted the unstable Middleware feature and are using `createRoutesStub` with the updated API. + + ```tsx + // If you have not opted into middleware, the old API should work again + let context: AppLoadContext = { + /*...*/ + }; + let Stub = createRoutesStub(routes, context); + + // If you have opted into middleware, you should now pass an instantiated + // `unstable_routerContextProvider` instead of a `getContext` factory function. + let context = new unstable_RouterContextProvider(); + context.set(SomeContext, someValue); + let Stub = createRoutesStub(routes, context); + ``` + +- `@react-router/dev` - Update `vite-node` to `^3.2.2` to support Vite 7 ([#13781](https://github.com/remix-run/react-router/pull/13781)) +- `@react-router/dev` - Properly handle `https` protocol in dev mode ([#13746](https://github.com/remix-run/react-router/pull/13746)) +- `@react-router/dev` - Fix missing styles when Vite's `build.cssCodeSplit` option is disabled ([#13943](https://github.com/remix-run/react-router/pull/13943)) +- `@react-router/dev` - Allow `.mts` and `.mjs` extensions for route config file ([#13931](https://github.com/remix-run/react-router/pull/13931)) +- `@react-router/dev` - Fix prerender file locations when `cwd` differs from project root ([#13824](https://github.com/remix-run/react-router/pull/13824)) +- `@react-router/dev` - Improve chunk error logging when a chunk cannot be found during the build ([#13799](https://github.com/remix-run/react-router/pull/13799)) +- `@react-router/dev` - Fix incorrectly configured `externalConditions` which had enabled `module` condition for externals and broke builds with certain packages (like Emotion) ([#13871](https://github.com/remix-run/react-router/pull/13871)) + +### Unstable Changes + +โš ๏ธ _[Unstable features](https://reactrouter.com/community/api-development-strategy#unstable-flags) are not recommended for production use_ + +- Add unstable RSC support ([#13700](https://github.com/remix-run/react-router/pull/13700)) + - For more information, see the [RSC documentation](https://reactrouter.com/start/rsc/installation) + +### Changes by Package + +- [`create-react-router`](https://github.com/remix-run/react-router/blob/react-router%407.7.0/packages/create-react-router/CHANGELOG.md#770) +- [`react-router`](https://github.com/remix-run/react-router/blob/react-router%407.7.0/packages/react-router/CHANGELOG.md#770) +- [`@react-router/architect`](https://github.com/remix-run/react-router/blob/react-router%407.7.0/packages/react-router-architect/CHANGELOG.md#770) +- [`@react-router/cloudflare`](https://github.com/remix-run/react-router/blob/react-router%407.7.0/packages/react-router-cloudflare/CHANGELOG.md#770) +- [`@react-router/dev`](https://github.com/remix-run/react-router/blob/react-router%407.7.0/packages/react-router-dev/CHANGELOG.md#770) +- [`@react-router/express`](https://github.com/remix-run/react-router/blob/react-router%407.7.0/packages/react-router-express/CHANGELOG.md#770) +- [`@react-router/fs-routes`](https://github.com/remix-run/react-router/blob/react-router%407.7.0/packages/react-router-fs-routes/CHANGELOG.md#770) +- [`@react-router/node`](https://github.com/remix-run/react-router/blob/react-router%407.7.0/packages/react-router-node/CHANGELOG.md#770) +- [`@react-router/remix-config-routes-adapter`](https://github.com/remix-run/react-router/blob/react-router%407.7.0/packages/react-router-remix-config-routes-adapter/CHANGELOG.md#770) +- [`@react-router/serve`](https://github.com/remix-run/react-router/blob/react-router%407.7.0/packages/react-router-serve/CHANGELOG.md#770) + +**Full Changelog**: [`v7.6.3...v7.7.0`](https://github.com/remix-run/react-router/compare/react-router@7.6.3...react-router@7.7.0) + ## v7.6.3 Date: 2025-06-27 diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 519e54c0c6..a64a9f99c3 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -28,7 +28,7 @@ The following design goals should be considered when considering RFCs for accept - **Less is More**. React Router has gained a _lot_ of functionality in the past years, but with that comes a bunch of new API surface. It's time to hone in on the core functionality and aim to reduce API surface _without sacrificing capabilities_. This may come in multiple forms, such as condensing a few existing APIs into a singular API, or deprecating current APIs in favor of a new React API. - **Routing and Data Focused.** Focus on core router-integrated/router-centric APIs and avoid adding first-class APIs that can be implemented in user-land -- **Simple Migration Paths.** Major version upgrade's don't have to stink. Breaking changes should be implemented behind future flags. Deprecations should be properly marked ahead of time in code and in documentation. Console warnings should be added prior to major releases to nudge developers towards the changes they can begin to make to prep for the upgrade. +- **Simple Migration Paths.** Major version upgrades don't have to stink. Breaking changes should be implemented behind future flags. Deprecations should be properly marked ahead of time in code and in documentation. Console warnings should be added prior to major releases to nudge developers towards the changes they can begin to make to prep for the upgrade. - **Lowest Common Mode.** Features are added at the lowest mode possible (`declarative -> data -> framework`) and then leveraged by the higher-level modes. This ensures that the largest number of React Router applications can leverage them. - **Regular Release Cadence**. Aim for major SemVer releases on a ~yearly basis so application developers can prepare in advance. @@ -38,11 +38,11 @@ The Steering Committee will be in charge of accepting RFC's for consideration, a The SC will initially consist of the Remix team developers: -- Matt Brophy -- Pedro Cattori -- Mark Dalgleish -- Jacob Ebey -- Brooks Lybrand +- Matt Brophy ([`@brophdawg11`](https://github.com/brophdawg11)) +- Pedro Cattori ([`@pcattori`](https://github.com/pcattori)) +- Mark Dalgleish ([`@markdalgleish`](https://github.com/markdalgleish)) +- Jacob Ebey ([`@jacob-ebey`](https://github.com/jacob-ebey)) +- Brooks Lybrand ([`@brookslybrand`](https://github.com/brookslybrand)) In the future, we may add a limited number of heavily involved community members to the SC as well. @@ -50,12 +50,15 @@ To reduce friction, the SC will primarily operate asynchronously via GitHub, but ## Bug/Issue Process -Due to the # of React Router applications out there, we have to be a bit strict on the process for filing issues to avoid an overload in GitHub. +Due to the large number of React Router applications out there, we have to be a bit strict on the process for filing issues to avoid an overload in GitHub. -- **All** bugs must have a **minimal** reproduction [^3] - - Minimal means that it is not just pointing to a deployed site or a branch in your existing application - - The preferred method is StackBlitz via [https://reactrouter.com/new](https://reactrouter.com/new) - - If Stackblitz is not an option, a GitHub repo based on a fresh `create-react-router` app is acceptable +- **All** bugs must have a **minimal** and **runnable** reproduction [^3] + - _Minimal_ means that it is not just pointing to a deployed site or a branch in your existing application + - _Runnable_ means that it is a working application where we can see the issue, not just a few snippets of code that need to be manually reassembled into a running application + - The preferred methods for reproductions are: + - **Framework Mode**: [StackBlitz](https://reactrouter.com/new) or a GitHub fork with a failing integration test based on [`bug-report-test.ts`](integration/bug-report-test.ts) + - **Data/Declarative Modes**: [CodeSandbox (TS)](https://codesandbox.io/templates/react-vite-ts) or [CodeSandbox (JS)](https://codesandbox.io/templates/react-vite) + - If StackBlitz/CodeSandbox is not an option, a GitHub repo based on a fresh `npx create-react-router` app is acceptable - Only in extraordinary circumstances will code snippets or maximal reproductions be accepted - Issue Review - Issues not meeting the above criteria will be closed and pointed to this document @@ -70,16 +73,18 @@ Due to the # of React Router applications out there, we have to be a bit strict ## New Feature Process -The process for new features being added to React Router will follow a series of stages loosely based on the [TC39 Process](https://tc39.es/process-document/). It is important to note that entrance into any given stage does not imply that an RFC will proceed any further. The stages will act as a funnel with fewer RFCs making it into later stages such that only the strongest RFCs make it into a React Router release in a stable fashion. This table gives a high-level overview of the Stages, but please see the individual stage sections below for more detailed information on the stages and the process for moving an FC through them. +The process for new features being added to React Router will follow a series of stages loosely based on the [TC39 Process](https://tc39.es/process-document/). It is important to note that entrance into any given stage does not imply that an RFC will proceed any further. The stages will act as a funnel with fewer RFCs making it into later stages such that only the strongest RFCs make it into a React Router release in a stable fashion. This table gives a high-level overview of the stages, but please see the individual stage sections below for more detailed information on the stages and the process for moving an FC through them. -| Stage | Name | Entrance Criteria | Purpose | -| ----- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 | Proposal | Proposal discussion opened on GitHub | We start with a GitHub Proposal to provide the lowest barrier to RFC submission. Anyone can submit an RFC and community members can review, comment, up-vote without any initial involvement of the SC. | -| 1 | Consideration | Proposal acceptance from 2 or more SC members | The consideration phase is the first "funnel" for incoming RFCs where the SC can officially express interest in the more popular RFCs. We only require 2 SC members to express interest to move an RFC into the **Consideration** phase to allow for low-friction experimentation of features in the **Alpha** stage. | -| 2 | Alpha | Pull-Request (PR) opened to implement the feature in an "unstable" state. This should be accompanied by a mechanism for community members to test the feature via [pkg.pr.new](https://pkg.pr.new/), [patch-package](https://www.npmjs.com/package/patch-package), or [pnpm patch](https://pnpm.io/cli/patch) | The **Alpha** stage is the next funnel for RFCs. Once interest has been expressed by the SC in the **Consideration** phase we open the RFC up for a sample PR implementation and a mechanism for community members to alpha test the feature without requiring that anything be shipped in a React Router SemVer release. This stage allows evaluation of the RFC in running applications and consideration of what a practical implementation of the RFC looks like in the code. | -| 3 | Beta | PR approval from at least 50% of the SC members indicating their acceptance of the PR for an unstable API | A RFC enters the **Beta** stage once enough members of the SC feel comfortable not only with the code for the beta feature, but have also seen positive feedback from alpha testers that the feature is working as expected. Once an **Alpha** stage PR has enough SC approvals, it will be merged and be included in the next React Router release. | -| 4 | Stabilization | At least 1 month in the Beta stage and PR opened to stabilize the APIs. This PR should also include documentation for the new feature. | The **Stabilization** phase exists to ensure that unstable features are available for enough time for applications to update their React Router version and opt-into beta testing. We don't want to rush features through beta testing so that we have maximal feedback prior to stabilizing a feature. | -| 5 | Stable | PR approval from at least 50% of the SC members indicating their acceptance of the PR for a stable API | A RFC is completed and enters the **Stable** stage once enough members of the SC feel comfortable not only with the code for the stable feature, but have also seen positive feedback from beta testers that the feature is working as expected. Once an **Beta** stage PR has enough SC approvals and has spent the required amount of time in the **Beta** stage, it can be merged and included in the next React Router release. | +Once a feature reaches Stage 2, it will be added to the [Roadmap](https://github.com/orgs/remix-run/projects/5) where it can be tracked as it moves through the stages. + +| Stage | Name | Entrance Criteria | Purpose | +| ----- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 | Proposal | Proposal discussion opened on GitHub | We start with a GitHub Proposal to provide the lowest barrier to RFC submission. Anyone can submit an RFC and community members can review, comment, up-vote without any initial involvement of the SC. | +| 1 | Consideration | Proposal acceptance from 2 or more SC members | The consideration phase is the first "funnel" for incoming RFCs where the SC can officially express interest in the more popular RFCs. We only require 2 SC members to express interest to move an RFC into the **Consideration** phase to allow for low-friction experimentation of features in the **Alpha** stage. | +| 2 | Alpha | Pull request (PR) opened to implement the feature in an "unstable" state | The **Alpha** stage is the next funnel for RFCs. Once interest has been expressed by the SC in the **Consideration** phase we open the RFC up for a sample PR implementation and a mechanism for community members to alpha test the feature without requiring that anything be shipped in a React Router SemVer release. This stage allows evaluation of the RFC in running applications and consideration of what a practical implementation of the RFC looks like in the code. | +| 3 | Beta | PR approval from at least 50% of the SC members indicating their acceptance of the PR for an unstable API | A RFC enters the **Beta** stage once enough members of the SC feel comfortable not only with the code for the beta feature, but have also seen positive feedback from alpha testers that the feature is working as expected. Once an **Alpha** stage PR has enough SC approvals, it will be merged and be included in the next React Router release. | +| 4 | Stabilization | At least 1 month in the Beta stage and PR opened to stabilize the APIs. This PR should also include documentation for the new feature. | The **Stabilization** phase exists to ensure that unstable features are available for enough time for applications to update their React Router version and opt-into beta testing. We don't want to rush features through beta testing so that we have maximal feedback prior to stabilizing a feature. | +| 5 | Stable | PR approval from at least 50% of the SC members indicating their acceptance of the PR for a stable API | A RFC is completed and enters the **Stable** stage once enough members of the SC feel comfortable not only with the code for the stable feature, but have also seen positive feedback from beta testers that the feature is working as expected. Once an **Beta** stage PR has enough SC approvals and has spent the required amount of time in the **Beta** stage, it can be merged and included in the next React Router release. | To get a feature accepted and implemented in React Router, it will go through the following stages: @@ -106,9 +111,11 @@ To get a feature accepted and implemented in React Router, it will go through th ### Stage 2 โ€” Alpha - A proposal enters **Stage 2 โ€” Alpha** once a PR has been opened implementing the feature in an `unstable_` state -- At this stage, we are looking for early community testing _before_ merging any work to the React Router repo โ€” so these PRs should provide a mechanism for community members to opt-into to alpha testing - - Ideally, we'll be able to wire up [pkg.pr.new](https://github.com/stackblitz-labs/pkg.pr.new) on the repo - - Otherwise, a `.patch` file (or instructions to create one) can be included in the PR for use with [patch-package](https://www.npmjs.com/package/patch-package) or [pnpm patch](https://pnpm.io/cli/patch) +- At this stage, we should open an Issue for the Proposal and add it to the [Roadmap](https://github.com/orgs/remix-run/projects/5) +- At this stage, we are looking for early community testing _before_ merging any work to the React Router repo โ€” so these PRs should provide a mechanism for community members to opt into to alpha testing + - Maintainers can trigger an alpha release from the PR branch by adding the `alpha-release` label, which will kick off an experimental release and comment it back on the PR + - Because the alpha release may contain other work committed to `dev` but not yet released in a stable version, it may not be ideal for testing in all cases + - In these cases, PR authors may also add the contents for a `.patch` file in a comment that folks can use via [patch-package](https://www.npmjs.com/package/patch-package) or [pnpm patch](https://pnpm.io/cli/patch) - Feedback from alpha testers is considered essential for further progress - The PR should also contain a changeset documenting the new API for the release notes - SC members will review and approve the PR via GitHub reviews @@ -137,5 +144,3 @@ To get a feature accepted and implemented in React Router, it will go through th - A proposal enters **Stage 5 โ€” Stable** once it receives **Stage 4 โ€” Stabilization** PR approvals from at least 50% of the SC members and is merged to `dev` - This will include the stable feature in `nightly` releases and the next normal SemVer release - -[mermaid]: https://mermaid.live/edit#pako:eNqVkm9r2zAQxr_KcTDYwAn-E8eOGYXGScdeFEYzGLQuQ4nPjsCWjCx3beN89yn2rJKX06tDd8_vnpPuhAeZEyZYVPLP4ciUhp-bTIA5t0_fc2LPMJvd9L8U1wQ_lGxky6oe1k8Z7jQrCdyve3Xzecp8yfB5VK8H3XQPrGmUfDFBoWQNPuxSuKd6T6rtIbUwD2aQStHynBTTXAqLS0fcA_C6qagmobkogbXQiVazfUW_e9hYjm84t1VzZFa_mfTXRpiGilirIXQ_gSyubG0tLjC4NekP2naiKarly8WJtQGNooK_9nBn5QsjN9GeV_z9eqq7_3f1zWLDf9iKDA8drEnVjOfmL08Xeob6aB4qw8SEORWsq3SGmTib0q7JmaZtzrVUmBSsaslB1mm5exMHTLTqaCracFYqVtuqhglMTviKySqYh4toFUfxIo6DyPUdfMPEW0ZzNwjNvb9wYz8Ko7OD71IagjuPo1UQL1dLb7F0PS8IB9zjkBx70mDpflzJYTMdVLIrj7Z_qS4jjtWKhFmUVHZCYxKe_wLxVeMU diff --git a/contributors.yml b/contributors.yml index a07fbc81fc..55a5cc202a 100644 --- a/contributors.yml +++ b/contributors.yml @@ -18,6 +18,7 @@ - akamfoad - alany411 - alberto +- AlemTuzlak - Aleuck - alexandernanberg - alexanderson1993 @@ -26,6 +27,7 @@ - amitdahan - AmRo045 - amsal +- Andarist - andreasottosson-polestar - andreiduca - antonmontrezor @@ -50,6 +52,7 @@ - BDomzalski - bhbs - bilalk711 +- bmsuseluda - bobziroll - bravo-kernel - Brendonovich @@ -89,10 +92,12 @@ - david-crespo - davidbielik - dcblair +- dchenk - decadentsavant - developit - dgrijuela - DigitalNaut +- DimaAmega - dmitrytarassov - dokeet - doytch @@ -111,6 +116,7 @@ - FilipJirsak - focusotter - foxscotch +- frodi-karlsson - frontsideair - fucancode - fyzhu @@ -133,9 +139,11 @@ - harshmangalam - HenriqueLimas - hernanif1 +- hi-ogawa - HK-SHAO - holynewbie - hongji00 +- hoosierhuy - hsbtr - hyesungoh - iamnishanth @@ -190,6 +198,7 @@ - ken0x0a - kentcdodds - kettanaito +- kigawas - kilavvy - kiliman - kkirsche @@ -249,6 +258,7 @@ - mjackson - mlewando - mm-jpoole +- mobregozo - modex98 - morleytatro - ms10596 @@ -267,6 +277,7 @@ - nowells - Nurai1 - Obi-Dann +- okalil - OlegDev1 - omahs - omar-moquete @@ -290,7 +301,9 @@ - pruszel - pwdcd - pyitphyoaung +- redabacha - refusado +- remorses - renyu-io - reyronald - rifaidev @@ -303,12 +316,14 @@ - rtzll - rubeonline - ruidi-huang +- rururux - ryanflorence - ryanhiebert - saengmotmi - samimsu - sanjai451 - sanketshah19 +- sapphi-red - saul-atomrigs - sbolel - scarf005 @@ -354,6 +369,7 @@ - ThornWu - tiborbarsi - timdorr +- timfisher - TkDodo - tkindy - tlinhart @@ -367,6 +383,7 @@ - tosinamuda - triangularcube - trungpv1601 +- tryonelove - TrySound - ttys026 - Tumas2 @@ -377,6 +394,7 @@ - ValiantCat - vdusart - vesan +- vezaynk - VictorElHajj - vijaypushkin - vikingviolinist @@ -400,7 +418,9 @@ - yionr - yracnet - ytori +- yuhwan-park - yuleicul +- yuri-poliantsev - zeevick10 - zeromask1337 - zheng-chuang diff --git a/docs/api/components/Await.md b/docs/api/components/Await.md index c74977022d..1d45df6513 100644 --- a/docs/api/components/Await.md +++ b/docs/api/components/Await.md @@ -15,7 +15,7 @@ Used to render promise values with automatic error handling. ```tsx import { Await, useLoaderData } from "react-router"; -export function loader() { +export async function loader() { // not awaited const reviews = getReviews(); // awaited (blocks the transition) diff --git a/docs/api/components/ServerRouter.md b/docs/api/components/ServerRouter.md index 813988fff1..bb33c85e0d 100644 --- a/docs/api/components/ServerRouter.md +++ b/docs/api/components/ServerRouter.md @@ -10,7 +10,7 @@ title: ServerRouter [Reference Documentation โ†—](https://api.reactrouter.com/v7/functions/react_router.ServerRouter.html) -Rendered at the top of the app in a custom entry.server.tsx. +Rendered at the top of the app in a custom [entry.server.tsx][entry-server]. ## Props @@ -31,3 +31,5 @@ _No documentation_ [modes: framework] _No documentation_ + +[entry-server]: ../framework-conventions/entry.server.tsx diff --git a/docs/api/data-routers/RouterProvider.md b/docs/api/data-routers/RouterProvider.md index 6d1f9c4714..34b01ed74b 100644 --- a/docs/api/data-routers/RouterProvider.md +++ b/docs/api/data-routers/RouterProvider.md @@ -10,16 +10,16 @@ title: RouterProvider [Reference Documentation โ†—](https://api.reactrouter.com/v7/functions/react_router.RouterProvider.html) -Initializes a data router, subscribes to its changes, and renders the +Accepts a data router, subscribes to its changes, and renders the matching components. Should typically be at the top of an app's element tree. ```tsx -import { - RouterProvider, - createBrowserRouter, -} from "react-router"; import { createRoot } from "react-dom/client"; -let router = createBrowserRouter(); +import { createBrowserRouter } from "react-router"; +import { RouterProvider } from "react-router/dom"; + +let router = createBrowserRouter(routes); + createRoot(document.getElementById("root")).render( ); @@ -31,10 +31,15 @@ createRoot(document.getElementById("root")).render( [modes: data] -_No documentation_ +This is an implementation detail and shouldn't need to be used in your application. + +This prop provides a way to inject the `react-dom` `flushSync` implementation when running `RouterProvider` in a DOM environment for use during routing operations with `flushSync` enabled (i.e., [useNavigate](../hooks/useNavigate#signature)). + +- If you're running `RouterProvider` in a memory environment (such as unit tests) you can import it from `react-router` and omit this prop +- If you are running `RouterProvider` in a DOM environment, you should be importing it from `react-router/dom` which automatically passes the `react-dom` `flushSync` implementation for you ### router [modes: data] -_No documentation_ +The initialized data router for the application. diff --git a/docs/api/data-routers/index.md b/docs/api/data-routers/index.md index 4c08fcb58a..eddd554727 100644 --- a/docs/api/data-routers/index.md +++ b/docs/api/data-routers/index.md @@ -1,4 +1,4 @@ --- title: Data Routers +order: 4 --- - diff --git a/docs/api/declarative-routers/index.md b/docs/api/declarative-routers/index.md index ef2329fd66..f5edcdf78a 100644 --- a/docs/api/declarative-routers/index.md +++ b/docs/api/declarative-routers/index.md @@ -1,4 +1,4 @@ --- title: Declarative Routers +order: 5 --- - diff --git a/docs/api/framework-conventions/client-modules.md b/docs/api/framework-conventions/client-modules.md new file mode 100644 index 0000000000..5b0bed6f2c --- /dev/null +++ b/docs/api/framework-conventions/client-modules.md @@ -0,0 +1,114 @@ +--- +title: .client modules +--- + +# `.client` modules + +[MODES: framework] + +## Summary + +You may have a file or dependency that uses module side effects in the browser. You can use `*.client.ts` on file names or nest files within `.client` directories to force them out of server bundles. + +```ts filename=feature-check.client.ts +// this would break the server +export const supportsVibrationAPI = + "vibrate" in window.navigator; +``` + +Note that values exported from this module will all be `undefined` on the server, so the only places to use them are in [`useEffect`][use_effect] and user events like click handlers. + +```ts +import { supportsVibrationAPI } from "./feature-check.client.ts"; + +console.log(supportsVibrationAPI); +// server: undefined +// client: true | false +``` + +## Usage Patterns + +### Individual Files + +Mark individual files as client-only by adding `.client` to the filename: + +```txt +app/ +โ”œโ”€โ”€ utils.client.ts ๐Ÿ‘ˆ client-only file +โ”œโ”€โ”€ feature-detection.client.ts +โ””โ”€โ”€ root.tsx +``` + +### Client Directories + +Mark entire directories as client-only by using `.client` in the directory name: + +```txt +app/ +โ”œโ”€โ”€ .client/ ๐Ÿ‘ˆ entire directory is client-only +โ”‚ โ”œโ”€โ”€ analytics.ts +โ”‚ โ”œโ”€โ”€ feature-detection.ts +โ”‚ โ””โ”€โ”€ browser-utils.ts +โ”œโ”€โ”€ components/ +โ””โ”€โ”€ root.tsx +``` + +## Examples + +### Browser Feature Detection + +```ts filename=app/utils/browser.client.ts +export const canUseDOM = typeof window !== "undefined"; + +export const hasWebGL = !!window.WebGLRenderingContext; + +export const supportsVibrationAPI = + "vibrate" in window.navigator; +``` + +### Client-Only Libraries + +```ts filename=app/analytics.client.ts +// This would break on the server +import { track } from "some-browser-only-analytics-lib"; + +export function trackEvent(eventName: string, data: any) { + track(eventName, data); +} +``` + +### Using Client Modules + +```tsx filename=app/routes/dashboard.tsx +import { useEffect } from "react"; +import { + canUseDOM, + supportsLocalStorage, + supportsVibrationAPI, +} from "../utils/browser.client.ts"; +import { trackEvent } from "../analytics.client.ts"; + +export default function Dashboard() { + useEffect(() => { + // These values are undefined on the server + if (canUseDOM && supportsVibrationAPI) { + console.log("Device supports vibration"); + } + + // Safe localStorage usage + const savedTheme = + supportsLocalStorage.getItem("theme"); + if (savedTheme) { + document.body.className = savedTheme; + } + + trackEvent("dashboard_viewed", { + timestamp: Date.now(), + }); + }, []); + + return
Dashboard
; +} +``` + +[use_effect]: https://react.dev/reference/react/useEffect diff --git a/docs/api/framework-conventions/entry.client.tsx.md b/docs/api/framework-conventions/entry.client.tsx.md new file mode 100644 index 0000000000..57adcbd9c0 --- /dev/null +++ b/docs/api/framework-conventions/entry.client.tsx.md @@ -0,0 +1,43 @@ +--- +title: entry.client.tsx +order: 4 +--- + +# entry.client.tsx + +[MODES: framework] + +## Summary + + +This file is optional + + +This file is the entry point for the browser and is responsible for hydrating the markup generated by the server in your [server entry module][server-entry] + +This is the first piece of code that runs in the browser. You can initialize any other client-side code here, such as client side libraries, add client only providers, etc. + +```tsx filename=app/entry.client.tsx +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { HydratedRouter } from "react-router/dom"; + +startTransition(() => { + hydrateRoot( + document, + + + + ); +}); +``` + +## Generating `entry.client.tsx` + +By default, React Router will handle hydrating your app on the client for you. You can reveal the default entry client file with the following: + +```shellscript nonumber +npx react-router reveal +``` + +[server-entry]: ./entry.server.tsx diff --git a/docs/api/framework-conventions/entry.server.tsx.md b/docs/api/framework-conventions/entry.server.tsx.md new file mode 100644 index 0000000000..657f77b49b --- /dev/null +++ b/docs/api/framework-conventions/entry.server.tsx.md @@ -0,0 +1,162 @@ +--- +title: entry.server.tsx +order: 5 +--- + +# entry.server.tsx + +[MODES: framework] + +## Summary + + +This file is optional + + +This file is the server-side entry point that controls how your React Router application generates HTTP responses on the server. + +This module should render the markup for the current page using a [``][serverrouter] element with the `context` and `url` for the current request. This markup will (optionally) be re-hydrated once JavaScript loads in the browser using the [client entry module][client-entry]. + +## Generating `entry.server.tsx` + +By default, React Router will handle generating the HTTP Response for you. You can reveal the default entry server file with the following: + +```shellscript nonumber +npx react-router reveal +``` + +## Exports + +### `default` + +The `default` export of this module is a function that lets you create the response, including HTTP status, headers, and HTML, giving you full control over the way the markup is generated and sent to the client. + +```tsx filename=app/entry.server.tsx +import { PassThrough } from "node:stream"; +import type { EntryContext } from "react-router"; +import { createReadableStreamFromReadable } from "@react-router/node"; +import { ServerRouter } from "react-router"; +import { renderToPipeableStream } from "react-dom/server"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext +) { + return new Promise((resolve, reject) => { + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + responseHeaders.set("Content-Type", "text/html"); + + const body = new PassThrough(); + const stream = + createReadableStreamFromReadable(body); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + } + ); + }); +} +``` + +### `streamTimeout` + +If you are [streaming] responses, you can export an optional `streamTimeout` value (in milliseconds) that will control the amount of time the server will wait for streamed promises to settle before rejecting outstanding promises and closing the stream. + +It's recommended to decouple this value from the timeout in which you abort the React renderer. You should always set the React rendering timeout to a higher value so it has time to stream down the underlying rejections from your `streamTimeout`. + +```tsx lines=[1-2,13-15] +// Reject all pending promises from handler functions after 10 seconds +export const streamTimeout = 10000; + +export default function handleRequest(...) { + return new Promise((resolve, reject) => { + // ... + + const { pipe, abort } = renderToPipeableStream( + , + { /* ... */ } + ); + + // Abort the streaming render pass after 11 seconds to allow the rejected + // boundaries to be flushed + setTimeout(abort, streamTimeout + 1000); + }); +} +``` + +### `handleDataRequest` + +You can export an optional `handleDataRequest` function that will allow you to modify the response of a data request. These are the requests that do not render HTML, but rather return the `loader` and `action` data to the browser once client-side hydration has occurred. + +```tsx +export function handleDataRequest( + response: Response, + { + request, + params, + context, + }: LoaderFunctionArgs | ActionFunctionArgs +) { + response.headers.set("X-Custom-Header", "value"); + return response; +} +``` + +### `handleError` + +By default, React Router will log encountered server-side errors to the console. If you'd like more control over the logging, or would like to also report these errors to an external service, then you can export an optional `handleError` function which will give you control (and will disable the built-in error logging). + +```tsx +export function handleError( + error: unknown, + { + request, + params, + context, + }: LoaderFunctionArgs | ActionFunctionArgs +) { + if (!request.signal.aborted) { + sendErrorToErrorReportingService(error); + console.error(formatErrorForJsonLogging(error)); + } +} +``` + +_Note that you generally want to avoid logging when the request was aborted, since React Router's cancellation and race-condition handling can cause a lot of requests to be aborted._ + +**Streaming Rendering Errors** + +When you are streaming your HTML responses via [`renderToPipeableStream`][rendertopipeablestream] or [`renderToReadableStream`][rendertoreadablestream], your own `handleError` implementation will only handle errors encountered during the initial shell render. If you encounter a rendering error during subsequent streamed rendering you will need to handle these errors manually since the React Router server has already sent the Response by that point. + +For `renderToPipeableStream`, you can handle these errors in the `onError` callback function. You will need to toggle a boolean in `onShellReady` so you know if the error was a shell rendering error (and can be ignored) or an async + +For an example, please refer to the default [`entry.server.tsx`][node-streaming-entry-server] for Node. + +**Thrown Responses** + +Note that this does not handle thrown `Response` instances from your `loader`/`action` functions. The intention of this handler is to find bugs in your code which result in unexpected thrown errors. If you are detecting a scenario and throwing a 401/404/etc. `Response` in your `loader`/`action` then it's an expected flow that is handled by your code. If you also wish to log, or send those to an external service, that should be done at the time you throw the response. + +[client-entry]: ./entry.client.tsx +[serverrouter]: ../components/ServerRouter +[streaming]: ../how-to/suspense +[rendertopipeablestream]: https://react.dev/reference/react-dom/server/renderToPipeableStream +[rendertoreadablestream]: https://react.dev/reference/react-dom/server/renderToReadableStream +[node-streaming-entry-server]: https://github.com/remix-run/react-router/blob/dev/packages/react-router-dev/config/defaults/entry.server.node.tsx diff --git a/docs/api/framework-conventions/index.md b/docs/api/framework-conventions/index.md new file mode 100644 index 0000000000..33a5ff320f --- /dev/null +++ b/docs/api/framework-conventions/index.md @@ -0,0 +1,4 @@ +--- +title: Framework Conventions +order: 3 +--- diff --git a/docs/api/framework-conventions/react-router.config.ts.md b/docs/api/framework-conventions/react-router.config.ts.md new file mode 100644 index 0000000000..fcb4038240 --- /dev/null +++ b/docs/api/framework-conventions/react-router.config.ts.md @@ -0,0 +1,210 @@ +--- +title: react-router.config.ts +order: 3 +--- + +# react-router.config.ts + +[MODES: framework] + +## Summary + + +This file is optional + + +[Reference Documentation โ†—](https://api.reactrouter.com/v7/types/_react_router_dev.config.Config.html) + +React Router framework configuration file that lets you customize aspects of your React Router application like server-side rendering, directory locations, and build settings. + +```tsx filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; + +export default { + appDirectory: "app", + buildDirectory: "build", + ssr: true, + prerender: ["/", "/about"], +} satisfies Config; +``` + +## Options + +### `appDirectory` + +The path to the `app` directory, relative to the root directory. Defaults to `"app"`. + +```tsx filename=react-router.config.ts +export default { + appDirectory: "src", +} satisfies Config; +``` + +### `basename` + +The React Router app basename. Defaults to `"/"`. + +```tsx filename=react-router.config.ts +export default { + basename: "/my-app", +} satisfies Config; +``` + +### `buildDirectory` + +The path to the build directory, relative to the project. Defaults to `"build"`. + +```tsx filename=react-router.config.ts +export default { + buildDirectory: "dist", +} satisfies Config; +``` + +### `buildEnd` + +A function that is called after the full React Router build is complete. + +```tsx filename=react-router.config.ts +export default { + buildEnd: async ({ buildManifest, serverBuildPath }) => { + // Custom build logic here + console.log("Build completed!"); + }, +} satisfies Config; +``` + +### `future` + +Enabled future flags for opting into upcoming features. + +See [Future Flags][future-flags] for more information. + +```tsx filename=react-router.config.ts +export default { + future: { + // Enable future flags here + }, +} satisfies Config; +``` + +### `prerender` + +An array of URLs to prerender to HTML files at build time. Can also be a function returning an array to dynamically generate URLs. + +See [Pre-Rendering][pre-rendering] for more information. + +```tsx filename=react-router.config.ts +export default { + // Static array + prerender: ["/", "/about", "/contact"], + + // Or dynamic function + prerender: async ({ getStaticPaths }) => { + const paths = await getStaticPaths(); + return ["/", ...paths]; + }, +} satisfies Config; +``` + +### `presets` + +An array of React Router plugin config presets to ease integration with other platforms and tools. + +See [Presets][presets] for more information. + +```tsx filename=react-router.config.ts +export default { + presets: [ + // Add presets here + ], +} satisfies Config; +``` + +### `routeDiscovery` + +Configure how routes are discovered and loaded by the client. Defaults to `mode: "lazy"` with `manifestPath: "/__manifest"`. + +**Options:** + +- `mode: "lazy"` - Routes are discovered as the user navigates (default) + - `manifestPath` - Custom path for manifest requests when using `lazy` mode +- `mode: "initial"` - All routes are included in the initial manifest + +```tsx filename=react-router.config.ts +export default { + // Enable lazy route discovery (default) + routeDiscovery: { + mode: "lazy", + manifestPath: "/__manifest", + }, + + // Use a custom manifest path + routeDiscovery: { + mode: "lazy", + manifestPath: "/custom-manifest", + }, + + // Disable lazy discovery and include all routes initially + routeDiscovery: { mode: "initial" }, +} satisfies Config; +``` + +See [Lazy Route Discovery][lazy-route-discovery] for more information. + +### `serverBuildFile` + +The file name of the server build output. This file should end in a `.js` extension and should be deployed to your server. Defaults to `"index.js"`. + +```tsx filename=react-router.config.ts +export default { + serverBuildFile: "server.js", +} satisfies Config; +``` + +### `serverBundles` + +A function for assigning routes to different server bundles. This function should return a server bundle ID which will be used as the bundle's directory name within the server build directory. + +See [Server Bundles][server-bundles] for more information. + +```tsx filename=react-router.config.ts +export default { + serverBundles: ({ branch }) => { + // Return bundle ID based on route branch + return branch.some((route) => route.id === "admin") + ? "admin" + : "main"; + }, +} satisfies Config; +``` + +### `serverModuleFormat` + +The output format of the server build. Defaults to `"esm"`. + +```tsx filename=react-router.config.ts +export default { + serverModuleFormat: "cjs", // or "esm" +} satisfies Config; +``` + +### `ssr` + +If `true`, React Router will server render your application. + +If `false`, React Router will pre-render your application and save it as an `index.html` file with your assets so your application can be deployed as a SPA without server-rendering. See ["SPA Mode"][spa-mode] for more information. + +Defaults to `true`. + +```tsx filename=react-router.config.ts +export default { + ssr: false, // disabled server-side rendering +} satisfies Config; +``` + +[future-flags]: ../../upgrading/future +[presets]: ../../how-to/presets +[server-bundles]: ../../how-to/server-bundles +[pre-rendering]: ../../how-to/pre-rendering +[spa-mode]: ../../how-to/spa +[lazy-route-discovery]: ../../explanation/lazy-route-discovery diff --git a/docs/api/framework-conventions/root.tsx.md b/docs/api/framework-conventions/root.tsx.md new file mode 100644 index 0000000000..b9822d7b9c --- /dev/null +++ b/docs/api/framework-conventions/root.tsx.md @@ -0,0 +1,199 @@ +--- +title: root.tsx +order: 1 +--- + +# root.tsx + +[MODES: framework] + +## Summary + + +This file is required + + +The "root" route (`app/root.tsx`) is the only _required_ route in your React Router application because it is the parent to all routes and is in charge of rendering the root `` document. + +```tsx filename=app/root.tsx +import { Outlet, Scripts } from "react-router"; + +import "./global-styles.css"; + +export default function App() { + return ( + + + + + + + + + + ); +} +``` + +## Components to Render + +Because the root route manages your document, it is the proper place to render a handful of "document-level" components React Router provides. These components are to be used once inside your root route and they include everything React Router figured out or built in order for your page to render properly. + +```tsx filename=app/root.tsx +import { + Outlet, + Scripts, + ScrollRestoration, +} from "react-router"; + +export default function App() { + return ( + + + + + + + {/* Child routes render here */} + + + {/* Manages scroll position for client-side transitions */} + {/* If you use a nonce-based content security policy for scripts, you must provide the `nonce` prop. Otherwise, omit the nonce prop as shown here. */} + + + {/* Script tags go here */} + {/* If you use a nonce-based content security policy for scripts, you must provide the `nonce` prop. Otherwise, omit the nonce prop as shown here. */} + + + + ); +} +``` + +If you are are not on React 19 or choosing not to use React's [``][react-link], [``][react-title], and [`<meta>`][react-meta] components, and instead relying on React Router's [`links`][react-router-links] and [`meta`][react-router-meta] exports, you need to add the following to your root route: + +```tsx filename=app/root.tsx +import { Links, Meta } from "react-router"; + +export default function App() { + return ( + <html lang="en"> + <head> + {/* All `meta` exports on all routes will render here */} + <Meta /> + + {/* All `link` exports on all routes will render here */} + <Links /> + </head> + <body> + <Outlet /> + <ScrollRestoration /> + <Scripts /> + </body> + </html> + ); +} +``` + +## Layout Export + +The root route supports all [route module exports][route-module]. + +The root route also supports an additional optional `Layout` export. The `Layout` component serves 2 purposes: + +1. Avoid duplicating your document's "app shell" across your root component, `HydrateFallback`, and `ErrorBoundary` +2. Prevent React from re-mounting your app shell elements when switching between the root component/`HydrateFallback`/`ErrorBoundary` which can cause a FOUC if React removes and re-adds `<link rel="stylesheet">` tags from your `<Links>` component. + +`Layout` takes a single `children` prop, which is the `default` export (e.g. `App`), `HydrateFallback`, or `ErrorBoundary`. + +```tsx filename=app/root.tsx +export function Layout({ children }) { + return ( + <html lang="en"> + <head> + <meta charSet="utf-8" /> + <meta + name="viewport" + content="width=device-width, initial-scale=1" + /> + <Meta /> + <Links /> + </head> + <body> + {/* children will be the root Component, ErrorBoundary, or HydrateFallback */} + {children} + <Scripts /> + <ScrollRestoration /> + </body> + </html> + ); +} + +export default function App() { + return <Outlet />; +} + +export function ErrorBoundary() {} +``` + +**A note on `useLoaderData`in the `Layout` Component** + +`useLoaderData` is not permitted to be used in `ErrorBoundary` components because it is intended for the happy-path route rendering, and its typings have a built-in assumption that the `loader` ran successfully and returned something. That assumption doesn't hold in an `ErrorBoundary` because it could have been the `loader` that threw and triggered the boundary! In order to access loader data in `ErrorBoundary`'s, you can use `useRouteLoaderData` which accounts for the loader data potentially being `undefined`. + +Because your `Layout` component is used in both success and error flows, this same restriction holds. If you need to fork logic in your `Layout` depending on if it was a successful request or not, you can use `useRouteLoaderData("root")` and `useRouteError()`. + +<docs-warn>Because your `<Layout>` component is used for rendering the `ErrorBoundary`, you should be _very defensive_ to ensure that you can render your `ErrorBoundary` without encountering any render errors. If your `Layout` throws another error trying to render the boundary, then it can't be used and your UI will fall back to the very minimal built-in default `ErrorBoundary`.</docs-warn> + +```tsx filename=app/root.tsx lines=[6-7,19-29,32-34] +export function Layout({ + children, +}: { + children: React.ReactNode; +}) { + const data = useRouteLoaderData("root"); + const error = useRouteError(); + + return ( + <html lang="en"> + <head> + <meta charSet="utf-8" /> + <meta + name="viewport" + content="width=device-width, initial-scale=1" + /> + <Meta /> + <Links /> + <style + dangerouslySetInnerHTML={{ + __html: ` + :root { + --themeVar: ${ + data?.themeVar || defaultThemeVar + } + } + `, + }} + /> + </head> + <body> + {data ? ( + <Analytics token={data.analyticsToken} /> + ) : null} + {children} + <ScrollRestoration /> + <Scripts /> + </body> + </html> + ); +} +``` + +[route-module]: ../start/framework/route-module +[react-link]: https://react.dev/reference/react-dom/components/link +[react-meta]: https://react.dev/reference/react-dom/components/meta +[react-title]: https://react.dev/reference/react-dom/components/title +[react-router-links]: ../../start/framework/route-module#links +[react-router-meta]: ../../start/framework/route-module#meta diff --git a/docs/api/framework-conventions/routes.ts.md b/docs/api/framework-conventions/routes.ts.md new file mode 100644 index 0000000000..7d4845c42d --- /dev/null +++ b/docs/api/framework-conventions/routes.ts.md @@ -0,0 +1,67 @@ +--- +title: routes.ts +order: 2 +--- + +# routes.ts + +[MODES: framework] + +## Summary + +<docs-info> +This file is required +</docs-info> + +[Reference Documentation โ†—](https://api.reactrouter.com/v7/interfaces/_react_router_dev.routes.RouteConfigEntry.html) + +Configuration file that maps URL patterns to route modules in your application. + +See the [routing guide][routing] for more information. + +## Examples + +### Basic + +Configure your routes as an array of objects. + +```tsx filename=app/routes.ts +import { + type RouteConfig, + route, +} from "@react-router/dev/routes"; + +export default [ + route("some/path", "./some/file.tsx"), + // pattern ^ ^ module file +] satisfies RouteConfig; +``` + +You can use the following helpers to create route config entries: + +- [`route`][route] โ€” Helper function for creating a route config entry +- [`index`][index] โ€” Helper function for creating a route config entry for an index route +- [`layout`][layout] โ€” Helper function for creating a route config entry for a layout route +- [`prefix`][prefix] โ€” Helper function for adding a path prefix to a set of routes without needing to introduce a parent route file +- [`relative`][relative] โ€” Creates a set of route config helpers that resolve file paths relative to the given directory. Designed to support splitting route config into multiple files within different directories + +### File-based Routing + +If you prefer to define your routes via file naming conventions rather than configuration, the `@react-router/fs-routes` package provides a [file system routing convention][file-route-conventions]: + +```ts filename=app/routes.ts +import { type RouteConfig } from "@react-router/dev/routes"; +import { flatRoutes } from "@react-router/fs-routes"; + +export default flatRoutes() satisfies RouteConfig; +``` + +### Route Helpers + +[routing]: ../../start/framework/routing +[route]: https://api.reactrouter.com/v7/functions/_react_router_dev.routes.route.html +[index]: https://api.reactrouter.com/v7/functions/_react_router_dev.routes.index.html +[layout]: https://api.reactrouter.com/v7/functions/_react_router_dev.routes.layout.html +[prefix]: https://api.reactrouter.com/v7/functions/_react_router_dev.routes.prefix.html +[relative]: https://api.reactrouter.com/v7/functions/_react_router_dev.routes.relative.html +[file-route-conventions]: ../../how-to/file-route-conventions diff --git a/docs/api/framework-conventions/server-modules.md b/docs/api/framework-conventions/server-modules.md new file mode 100644 index 0000000000..0bb842bf29 --- /dev/null +++ b/docs/api/framework-conventions/server-modules.md @@ -0,0 +1,145 @@ +--- +title: .server modules +--- + +# `.server` modules + +[MODES: framework] + +## Summary + +Server-only modules that are excluded from client bundles and only run on the server. + +```ts filename=auth.server.ts +// This would expose secrets on the client +export const JWT_SECRET = process.env.JWT_SECRET; + +export function validateToken(token: string) { + // Server-only authentication logic +} +``` + +`.server` modules are a good way to explicitly mark entire modules as server-only. The build will fail if any code in a `.server` file or `.server` directory accidentally ends up in the client module graph. + +## Usage Patterns + +### Individual Files + +Mark individual files as server-only by adding `.server` to the filename: + +```txt +app/ +โ”œโ”€โ”€ auth.server.ts ๐Ÿ‘ˆ server-only file +โ”œโ”€โ”€ database.server.ts +โ”œโ”€โ”€ email.server.ts +โ””โ”€โ”€ root.tsx +``` + +### Server Directories + +Mark entire directories as server-only by using `.server` in the directory name: + +```txt +app/ +โ”œโ”€โ”€ .server/ ๐Ÿ‘ˆ entire directory is server-only +โ”‚ โ”œโ”€โ”€ auth.ts +โ”‚ โ”œโ”€โ”€ database.ts +โ”‚ โ””โ”€โ”€ email.ts +โ”œโ”€โ”€ components/ +โ””โ”€โ”€ root.tsx +``` + +## Examples + +### Database Connection + +```ts filename=app/utils/db.server.ts +import { PrismaClient } from "@prisma/client"; + +// This would expose database credentials on the client +const db = new PrismaClient({ + datasources: { + db: { + url: process.env.DATABASE_URL, + }, + }, +}); + +export { db }; +``` + +### Authentication Utilities + +```ts filename=app/utils/auth.server.ts +import jwt from "jsonwebtoken"; +import bcrypt from "bcryptjs"; + +const JWT_SECRET = process.env.JWT_SECRET!; + +export function hashPassword(password: string) { + return bcrypt.hash(password, 10); +} + +export function verifyPassword( + password: string, + hash: string +) { + return bcrypt.compare(password, hash); +} + +export function createToken(userId: string) { + return jwt.sign({ userId }, JWT_SECRET, { + expiresIn: "7d", + }); +} + +export function verifyToken(token: string) { + return jwt.verify(token, JWT_SECRET) as { + userId: string; + }; +} +``` + +### Using Server Modules + +```tsx filename=app/routes/login.tsx +import type { ActionFunctionArgs } from "react-router"; +import { redirect } from "react-router"; +import { + hashPassword, + createToken, +} from "../utils/auth.server"; +import { db } from "../utils/db.server"; + +export async function action({ + request, +}: ActionFunctionArgs) { + const formData = await request.formData(); + const email = formData.get("email") as string; + const password = formData.get("password") as string; + + // Server-only operations + const hashedPassword = await hashPassword(password); + const user = await db.user.create({ + data: { email, password: hashedPassword }, + }); + + const token = createToken(user.id); + + return redirect("/dashboard", { + headers: { + "Set-Cookie": `token=${token}; HttpOnly; Secure; SameSite=Strict`, + }, + }); +} + +export default function Login() { + return ( + <form method="post"> + <input name="email" type="email" required /> + <input name="password" type="password" required /> + <button type="submit">Login</button> + </form> + ); +} +``` diff --git a/docs/api/hooks/useLocation.md b/docs/api/hooks/useLocation.md index 5072be97b8..ffc2e260a1 100644 --- a/docs/api/hooks/useLocation.md +++ b/docs/api/hooks/useLocation.md @@ -10,7 +10,7 @@ title: useLocation [Reference Documentation โ†—](https://api.reactrouter.com/v7/functions/react_router.useLocation.html) -Returns the current [Location]([../Other/Location](https://api.reactrouter.com/v7/interfaces/react_router.Location.html)). This can be useful if you'd like to perform some side effect whenever it changes. +Returns the current [Location](https://api.reactrouter.com/v7/interfaces/react_router.Location.html). This can be useful if you'd like to perform some side effect whenever it changes. ```tsx import * as React from 'react' diff --git a/docs/api/hooks/unstable_usePrompt.md b/docs/api/hooks/usePrompt.md similarity index 97% rename from docs/api/hooks/unstable_usePrompt.md rename to docs/api/hooks/usePrompt.md index 6238a0205e..48bc6ebc0a 100644 --- a/docs/api/hooks/unstable_usePrompt.md +++ b/docs/api/hooks/usePrompt.md @@ -1,5 +1,6 @@ --- -title: unstable_usePrompt +title: usePrompt +unstable: true --- # unstable_usePrompt diff --git a/docs/api/rsc/RSCHydratedRouter.md b/docs/api/rsc/RSCHydratedRouter.md new file mode 100644 index 0000000000..9b7d6de832 --- /dev/null +++ b/docs/api/rsc/RSCHydratedRouter.md @@ -0,0 +1,53 @@ +--- +title: RSCHydratedRouter +unstable: true +--- + +# RSCHydratedRouter + +[MODES: data] + +## Summary + +Hydrates a server rendered `RSCPayload` in the browser. + +```tsx filename=entry.browser.tsx lines=[7-12] +createFromReadableStream(getRSCStream()).then( + (payload: RSCServerPayload) => { + startTransition(async () => { + hydrateRoot( + document, + <StrictMode> + <RSCHydratedRouter + createFromReadableStream={ + createFromReadableStream + } + payload={payload} + /> + </StrictMode>, + { + formState: await getFormState(payload), + } + ); + }); + } +); +``` + +## Props + +### createFromReadableStream + +Your `react-server-dom-xyz/client`'s `createFromReadableStream` function, used to decode payloads from the server. + +### payload + +The decoded `RSCPayload` to hydrate. + +### routeDiscovery + +`eager` or `lazy` - Determines if links are eagerly discovered, or delayed until clicked. + +### unstable_getContext + +A function that returns an `unstable_InitialContext` object (`Map<RouterContext, unknown>`), for use in client loaders, actions and middleware. diff --git a/docs/api/rsc/RSCStaticRouter.md b/docs/api/rsc/RSCStaticRouter.md new file mode 100644 index 0000000000..67d8876f66 --- /dev/null +++ b/docs/api/rsc/RSCStaticRouter.md @@ -0,0 +1,37 @@ +--- +title: RSCStaticRouter +unstable: true +--- + +# RSCStaticRouter + +[MODES: data] + +## Summary + +Pre-renders an `RSCPayload` to HTML. Usually used in `routeRSCServerRequest`'s `renderHTML` callback. + +```tsx filename=entry.ssr.tsx lines=[9] +routeRSCServerRequest({ + request, + fetchServer, + createFromReadableStream, + async renderHTML(getPayload) { + const payload = await getPayload(); + + return await renderHTMLToReadableStream( + <RSCStaticRouter getPayload={getPayload} />, + { + bootstrapScriptContent, + formState: await getFormState(payload), + } + ); + }, +}); +``` + +## Props + +### getPayload + +A function that starts decoding of the `RSCPayload`. Usually passed through from `routeRSCServerRequest`'s `renderHTML`. diff --git a/docs/api/rsc/createCallServer.md b/docs/api/rsc/createCallServer.md new file mode 100644 index 0000000000..5cbe511448 --- /dev/null +++ b/docs/api/rsc/createCallServer.md @@ -0,0 +1,32 @@ +--- +title: createCallServer +unstable: true +--- + +# createCallServer + +[MODES: data] + +## Summary + +Create a React `callServer` implementation for React Router. + +```tsx filename=entry.browser.tsx +setServerCallback( + createCallServer({ + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + }) +); +``` + +## Options + +### createFromReadableStream + +Your `react-server-dom-xyz/client`'s `createFromReadableStream`. Used to decode payloads from the server. + +### encodeReply + +Your `react-server-dom-xyz/client`'s `encodeReply`. Used when sending payloads to the server. diff --git a/docs/api/rsc/getRSCStream.md b/docs/api/rsc/getRSCStream.md new file mode 100644 index 0000000000..8840e7b0a2 --- /dev/null +++ b/docs/api/rsc/getRSCStream.md @@ -0,0 +1,30 @@ +--- +title: getRSCStream +unstable: true +--- + +# getRSCStream + +[MODES: data] + +## Summary + +Get the prerendered RSC stream for hydration. Usually passed directly to your `react-server-dom-xyz/client`'s `createFromReadableStream`. + +```tsx filename=entry.browser.tsx lines=[1] +createFromReadableStream(getRSCStream()).then( + (payload: RSCServerPayload) => { + startTransition(async () => { + hydrateRoot( + document, + <StrictMode> + <RSCHydratedRouter /* props */ /> + </StrictMode>, + { + /* ... */ + } + ); + }); + } +); +``` diff --git a/docs/api/rsc/index.md b/docs/api/rsc/index.md new file mode 100644 index 0000000000..e8159c0f44 --- /dev/null +++ b/docs/api/rsc/index.md @@ -0,0 +1,3 @@ +--- +title: RSC (Unstable) +--- diff --git a/docs/api/rsc/matchRSCServerRequest.md b/docs/api/rsc/matchRSCServerRequest.md new file mode 100644 index 0000000000..247a0242b8 --- /dev/null +++ b/docs/api/rsc/matchRSCServerRequest.md @@ -0,0 +1,71 @@ +--- +title: matchRSCServerRequest +unstable: true +--- + +# matchRSCServerRequest + +[MODES: data] + +## Summary + +Matches the given routes to a Request and returns a RSC Response encoding an `RSCPayload` for consumption by a RSC enabled client router. + +```tsx filename=entry.rsc.ts +matchRSCServerRequest({ + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + request, + routes: routes(), + generateResponse(match) { + return new Response( + renderToReadableStream(match.payload), + { + status: match.statusCode, + headers: match.headers, + } + ); + }, +}); +``` + +## Options + +### basename + +The basename to use when matching the request. + +### decodeAction + +Your `react-server-dom-xyz/server`'s `decodeAction` function, responsible for loading a server action. + +### decodeReply + +Your `react-server-dom-xyz/server`'s `decodeReply` function, used to decode the server function's arguments and bind them to the implementation for invocation by the router. + +### decodeFormState + +A function responsible for decoding form state for progressively enhanceable forms with `useActionState` using your `react-server-dom-xyz/server`'s `decodeFormState`. + +### generateResponse + +A function responsible for using your `renderToReadableStream` to generate a Response encoding the `RSCPayload`. + +### loadServerAction + +Your `react-server-dom-xyz/server`'s `loadServerAction` function, used to load a server action by ID. + +### request + +The request to match against. + +### requestContext + +An instance of `unstable_RouterContextProvider` that should be created per request, to be passed to loaders, actions and middleware. + +### routes + +Your route definitions. diff --git a/docs/api/rsc/routeRSCServerRequest.md b/docs/api/rsc/routeRSCServerRequest.md new file mode 100644 index 0000000000..84d5030c9b --- /dev/null +++ b/docs/api/rsc/routeRSCServerRequest.md @@ -0,0 +1,49 @@ +--- +title: routeRSCServerRequest +unstable: true +--- + +# routeRSCServerRequest + +[MODES: data] + +## Summary + +Routes the incoming request to the RSC server and appropriately proxies the server response for data / resource requests, or renders to HTML for a document request. + +```tsx filename=entry.ssr.tsx +routeRSCServerRequest({ + request, + fetchServer, + createFromReadableStream, + async renderHTML(getPayload) { + const payload = await getPayload(); + + return await renderHTMLToReadableStream( + <RSCStaticRouter getPayload={getPayload} />, + { + bootstrapScriptContent, + formState: await getFormState(payload), + } + ); + }, +}); +``` + +## Options + +### createFromReadableStream + +Your `react-server-dom-xyz/client`'s `createFromReadableStream` function, used to decode payloads from the server. + +### fetchServer + +A function that forwards a `Request` to the RSC handler and returns a `Promise<Response>` containing a serialized `RSCPayload`. + +### renderHTML + +A function that renders the `RSCPayload` to HTML, usually using a `<RSCStaticRouter>`. + +### request + +The request to route. diff --git a/docs/api/utils/createContext.md b/docs/api/utils/createContext.md new file mode 100644 index 0000000000..ae4eaf56f2 --- /dev/null +++ b/docs/api/utils/createContext.md @@ -0,0 +1,80 @@ +--- +title: createContext +unstable: true +--- + +# unstable_createContext + +[MODES: framework, data] + +<br/> +<br/> + +<docs-warning>This API is experimental and subject to breaking changes. Enable it with the `future.unstable_middleware` flag.</docs-warning> + +## Summary + +[Reference Documentation โ†—](https://api.reactrouter.com/v7/functions/react_router.unstable_createContext.html) + +Creates a type-safe context object that can be used to store and retrieve values in middleware, loaders, and actions. Similar to React's `createContext`, but designed for React Router's request/response lifecycle. + +## Signature + +```tsx +unstable_createContext<T>(defaultValue?: T): RouterContext<T> +``` + +## Params + +### defaultValue + +An optional default value for the context. This value will be returned if no value has been set for this context. + +## Returns + +A `RouterContext<T>` object that can be used with `context.get()` and `context.set()` in middleware, loaders, and actions. + +## Examples + +### Basic Usage + +```tsx filename=app/context.ts +import { unstable_createContext } from "react-router"; + +// Create a context for user data +export const userContext = + unstable_createContext<User | null>(null); +``` + +```tsx filename=app/middleware/auth.ts +import { userContext } from "~/context"; +import { getUserFromSession } from "~/auth.server"; + +export const authMiddleware = async ({ + request, + context, +}) => { + const user = await getUserFromSession(request); + context.set(userContext, user); +}; +``` + +```tsx filename=app/routes/profile.tsx +import { userContext } from "~/context"; + +export async function loader({ + context, +}: Route.LoaderArgs) { + const user = context.get(userContext); + + if (!user) { + throw new Response("Unauthorized", { status: 401 }); + } + + return { user }; +} +``` + +## See Also + +- [Middleware Guide](../../how-to/middleware) diff --git a/docs/community/contributing.md b/docs/community/contributing.md index 10d95c4fef..c5b8a7e90a 100644 --- a/docs/community/contributing.md +++ b/docs/community/contributing.md @@ -8,6 +8,10 @@ Thanks for contributing, you rock! When it comes to open source, there are many different kinds of contributions that can be made, all of which are valuable. Here are a few guidelines that should help you as you prepare your contribution. +## Open Governance Model + +Before going any further, please read the Open Governance [blog post](https://remix.run/blog/rr-governance) and [document](https://github.com/remix-run/react-router/blob/main/GOVERNANCE.md) for information on how we handle bugs/issues/feature proposals in React Router. + ## Setup Before you can contribute to the codebase, you will need to fork the repo. This will look a bit different depending on what type of contribution you are making: @@ -33,21 +37,23 @@ The following steps will get you set up to contribute changes to this repo: ## Think You Found a Bug? -Please conform to the issue template and provide a clear path to reproduction with a code example. Best is a pull request with a [failing test](https://github.com/remix-run/react-router/blob/dev/integration/bug-report-test.ts). Next best is a link to [StackBlitz](https://reactrouter.com/new) or repository that illustrates the bug. +Please conform to the issue template and provide a **minimal** and **runnable** reproduction. Best is a pull request with a [failing test](https://github.com/remix-run/react-router/blob/dev/integration/bug-report-test.ts). Next best is a link to [StackBlitz](https://reactrouter.com/new), CodeSsndbox, or GitHub repository that illustrates the bug. -## Adding an Example? +## Issue Not Getting Attention? -Examples can be added directly to the main branch. Create a branch off of your local clone of main. Once you've finished, create a pull request and outline your example. +If you need a bug fixed and nobody is fixing it, your best bet is to provide a fix for it and make a [pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request). Open source code belongs to all of us, and it's all of our responsibility to push it forward. ## Proposing New or Changed API? -Please provide thoughtful comments and some sample code that show what you'd like to do with React Router in your app. It helps the conversation if you can show us how you're limited by the current API first before jumping to a conclusion about what needs to be changed and/or added. +โš ๏ธ _Please do not start with a PR for a new feature._ + +New features need to go through the process outlined in the [Open Governance Model](https://github.com/remix-run/react-router/blob/main/GOVERNANCE.md#new-feature-process) and can be started by opening a [Proposal Discussion](https://github.com/remix-run/react-router/discussions/new?category=proposals) on GitHub. Please provide thoughtful comments and some sample code that show what you'd like to do with React Router in your app. It helps the conversation if you can show us how you're limited by the current API first before jumping to a conclusion about what needs to be changed and/or added. We have learned by experience that small APIs are usually better, so we may be a little reluctant to add something new unless there's an obvious limitation with the current API. That being said, we are always anxious to hear about cases that we just haven't considered before, so please don't be shy! :) -## Issue Not Getting Attention? +## Adding an Example? -If you need a bug fixed and nobody is fixing it, your best bet is to provide a fix for it and make a [pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request). Open source code belongs to all of us, and it's all of our responsibility to push it forward. +Examples can be added directly to the `main` branch. Create a branch off of your local clone of `main`. Once you've finished, create a pull request and outline your example. ## Making a Pull Request? @@ -57,7 +63,7 @@ Pull requests need only the approval of two or more collaborators to be merged; ### Tests -All commits that fix bugs or add features need a test. +All commits that fix bugs or add features need one or more tests. <docs-error>Do not merge code without tests!</docs-error> @@ -83,14 +89,14 @@ Calling `pnpm build` from the root directory will run the build, which should ta ### Testing -Before running the tests, you need to run a build. After you build, running `pnpm test` from the root directory will run **every** package's tests. If you want to run tests for a specific package, use `pnpm test --projects packages/<package-name>`: +Before running the tests, you need to run a build. After you build, running `pnpm test` from the root directory will run **every** package's tests. If you want to run tests for a specific package, use `pnpm test packages/<package-name>/`: ```bash # Test all packages pnpm test -# Test only react-router-dom -pnpm test --projects packages/react-router-dom +# Test only @react-router/dev +pnpm test packages/react-router-dev/ ``` ## Repository Branching @@ -100,84 +106,11 @@ This repo maintains separate branches for different purposes. They will look som ``` - main > the most recent release and current docs - dev > code under active development between stable releases -- v5 > the most recent code for a specific major release +- v6 > the most recent code for a specific major release ``` There may be other branches for various features and experimentation, but all of the magic happens from these branches. -## New Releases +## Releases -When it's time to cut a new release, we follow a process based on our branching strategy depending on the type of release. - -### `react-router@next` Releases - -We create experimental releases from the current state of the `dev` branch. They can be installed by using the `@next` tag: - -```bash -pnpm add react-router-dom@next -# or -npm install react-router-dom@next -``` - -These releases will be automated as PRs are merged into the `dev` branch. - -### Latest Major Releases - -```bash -# Start from the dev branch. -git checkout dev - -# Merge the main branch into dev to ensure that any hotfixes and -# docs updates are available in the release. -git merge main - -# Create a new release branch from dev. -git checkout -b release/v6.1.0 - -# Create a new tag and update version references throughout the -# codebase. -pnpm run version [nextVersion] - -# Push the release branch along with the new release tag. -git push origin release/v6.1.0 --follow-tags - -# Wait for GitHub actions to run all tests. If the tests pass, the -# release is ready to go! Merge the release branch into main and dev. -git checkout main -git merge release/v6.1.0 -git checkout dev -git merge release/v6.1.0 - -# The release branch can now be deleted. -git branch -D release/v6.1.0 -git push origin --delete release/v6.1.0 - -# Now go to GitHub and create the release from the new tag. Let -# GitHub Actions take care of the rest! -``` - -### Hot-fix Releases - -Sometimes we have a crucial bug that needs to be patched right away. If the bug affects the latest release, we can create a new version directly from `main` (or the relevant major release branch where the bug exists): - -```bash -# From the main branch, make sure to run the build and all tests -# before creating a new release. -pnpm install && pnpm build && pnpm test - -# Assuming the tests pass, create the release tag and update -# version references throughout the codebase. -pnpm run version [nextVersion] - -# Push changes along with the new release tag. -git push origin main --follow-tags - -# In GitHub, create the release from the new tag and it will be -# published via GitHub actions - -# When the hot-fix is done, merge the changes into dev and clean -# up conflicts as needed. -git checkout dev -git merge main -git push origin dev -``` +Please refer to [DEVELOPMENT.md](https://github.com/remix-run/react-router/blob/main/DEVELOPMENT.md) for an outline of the release process. diff --git a/docs/explanation/backend-for-frontend.md b/docs/explanation/backend-for-frontend.md new file mode 100644 index 0000000000..252b66b51a --- /dev/null +++ b/docs/explanation/backend-for-frontend.md @@ -0,0 +1,50 @@ +--- +title: Backend For Frontend +--- + +# Backend For Frontend + +[MODES: framework] + +<br/> +<br/> + +While React Router can serve as your fullstack application, it also fits perfectly into the "Backend for Frontend" architecture. + +The BFF strategy employs a web server with a job scoped to serving the frontend web app and connecting it to the services it needs: your database, mailer, job queues, existing backend APIs (REST, GraphQL), etc. Instead of your UI integrating directly from the browser to these services, it connects to the BFF, and the BFF connects to your services. + +Mature apps already have a lot of backend application code in Ruby, Elixir, PHP, etc., and there's no reason to justify migrating it all to a server-side JavaScript runtime just to get the benefits of React Router. Instead, you can use your React Router app as a backend for your frontend. + +You can use `fetch` right from your loaders and actions to your backend. + +```tsx lines=[7,13,17] +import escapeHtml from "escape-html"; + +export async function loader() { + const apiUrl = "https://api.example.com/some-data.json"; + const res = await fetch(apiUrl, { + headers: { + Authorization: `Bearer ${process.env.API_TOKEN}`, + }, + }); + + const data = await res.json(); + + const prunedData = data.map((record) => { + return { + id: record.id, + title: record.title, + formattedBody: escapeHtml(record.content), + }; + }); + return { prunedData }; +} +``` + +There are several benefits of this approach vs. fetching directly from the browser. The highlighted lines above show how you can: + +1. Simplify third-party integrations and keep tokens and secrets out of client bundles +2. Prune the data down to send less kB over the network, speeding up your app significantly +3. Move a lot of code from browser bundles to the server, like `escapeHtml`, which speeds up your app. Additionally, moving code to the server usually makes your code easier to maintain since server-side code doesn't have to worry about UI states for async operations + +Again, React Router can be used as your only server by talking directly to the database and other services with server-side JavaScript APIs, but it also works perfectly as a backend for your frontend. Go ahead and keep your existing API server for application logic and let React Router connect the UI to it. diff --git a/docs/explanation/code-splitting.md b/docs/explanation/code-splitting.md index e764299b0b..b78b5600d3 100644 --- a/docs/explanation/code-splitting.md +++ b/docs/explanation/code-splitting.md @@ -4,6 +4,11 @@ title: Automatic Code Splitting # Automatic Code Splitting +[MODES: framework] + +<br/> +<br/> + When using React Router's framework features, your application is automatically code split to improve the performance of initial load times when users visit your application. ## Code Splitting by Route diff --git a/docs/explanation/concurrency.md b/docs/explanation/concurrency.md index b939ecca9e..cfa8695d53 100644 --- a/docs/explanation/concurrency.md +++ b/docs/explanation/concurrency.md @@ -6,7 +6,8 @@ title: Network Concurrency Management [MODES: framework, data] -## Overview +<br/> +<br/> When building web applications, managing network requests can be a daunting task. The challenges of ensuring up-to-date data and handling simultaneous requests often lead to complex logic in the application to deal with interruptions and race conditions. React Router simplifies this process by automating network management while mirroring and expanding upon the intuitive behavior of web browsers. diff --git a/docs/explanation/form-vs-fetcher.md b/docs/explanation/form-vs-fetcher.md new file mode 100644 index 0000000000..5a011dc476 --- /dev/null +++ b/docs/explanation/form-vs-fetcher.md @@ -0,0 +1,293 @@ +--- +title: Form vs. fetcher +--- + +# Form vs. fetcher + +[MODES: framework, data] + +## Overview + +Developing in React Router offers a rich set of tools that can sometimes overlap in functionality, creating a sense of ambiguity for newcomers. The key to effective development in React Router is understanding the nuances and appropriate use cases for each tool. This document seeks to provide clarity on when and why to use specific APIs. + +## APIs in Focus + +- [`<Form>`][form-component] +- [`useFetcher`][use-fetcher] +- [`useNavigation`][use-navigation] + +Understanding the distinctions and intersections of these APIs is vital for efficient and effective React Router development. + +## URL Considerations + +The primary criterion when choosing among these tools is whether you want the URL to change or not: + +- **URL Change Desired**: When navigating or transitioning between pages, or after certain actions like creating or deleting records. This ensures that the user's browser history accurately reflects their journey through your application. + + - **Expected Behavior**: In many cases, when users hit the back button, they should be taken to the previous page. Other times the history entry may be replaced but the URL change is important nonetheless. + +- **No URL Change Desired**: For actions that don't significantly change the context or primary content of the current view. This might include updating individual fields or minor data manipulations that don't warrant a new URL or page reload. This also applies to loading data with fetchers for things like popovers, combo boxes, etc. + +### When the URL Should Change + +These actions typically reflect significant changes to the user's context or state: + +- **Creating a New Record**: After creating a new record, it's common to redirect users to a page dedicated to that new record, where they can view or further modify it. + +- **Deleting a Record**: If a user is on a page dedicated to a specific record and decides to delete it, the logical next step is to redirect them to a general page, such as a list of all records. + +For these cases, developers should consider using a combination of [`<Form>`][form-component] and [`useNavigation`][use-navigation]. These tools can be coordinated to handle form submission, invoke specific actions, retrieve action-related data through component props, and manage navigation respectively. + +### When the URL Shouldn't Change + +These actions are generally more subtle and don't require a context switch for the user: + +- **Updating a Single Field**: Maybe a user wants to change the name of an item in a list or update a specific property of a record. This action is minor and doesn't necessitate a new page or URL. + +- **Deleting a Record from a List**: In a list view, if a user deletes an item, they likely expect to remain on the list view, with that item no longer in the list. + +- **Creating a Record in a List View**: When adding a new item to a list, it often makes sense for the user to remain in that context, seeing their new item added to the list without a full page transition. + +- **Loading Data for a Popover or Combobox**: When loading data for a popover or combobox, the user's context remains unchanged. The data is loaded in the background and displayed in a small, self-contained UI element. + +For such actions, [`useFetcher`][use-fetcher] is the go-to API. It's versatile, combining functionalities of these APIs, and is perfectly suited for tasks where the URL should remain unchanged. + +## API Comparison + +As you can see, the two sets of APIs have a lot of similarities: + +| Navigation/URL API | Fetcher API | +| ----------------------------- | -------------------- | +| `<Form>` | `<fetcher.Form>` | +| `actionData` (component prop) | `fetcher.data` | +| `navigation.state` | `fetcher.state` | +| `navigation.formAction` | `fetcher.formAction` | +| `navigation.formData` | `fetcher.formData` | + +## Examples + +### Creating a New Record + +```tsx filename=app/pages/new-recipe.tsx lines=[16,23-24,29] +import { + Form, + redirect, + useNavigation, +} from "react-router"; +import type { Route } from "./+types/new-recipe"; + +export async function action({ + request, +}: Route.ActionArgs) { + const formData = await request.formData(); + const errors = await validateRecipeFormData(formData); + if (errors) { + return { errors }; + } + const recipe = await db.recipes.create(formData); + return redirect(`/recipes/${recipe.id}`); +} + +export function NewRecipe({ + actionData, +}: Route.ComponentProps) { + const { errors } = actionData || {}; + const navigation = useNavigation(); + const isSubmitting = + navigation.formAction === "/recipes/new"; + + return ( + <Form method="post"> + <label> + Title: <input name="title" /> + {errors?.title ? <span>{errors.title}</span> : null} + </label> + <label> + Ingredients: <textarea name="ingredients" /> + {errors?.ingredients ? ( + <span>{errors.ingredients}</span> + ) : null} + </label> + <label> + Directions: <textarea name="directions" /> + {errors?.directions ? ( + <span>{errors.directions}</span> + ) : null} + </label> + <button type="submit"> + {isSubmitting ? "Saving..." : "Create Recipe"} + </button> + </Form> + ); +} +``` + +The example leverages [`<Form>`][form-component], component props, and [`useNavigation`][use-navigation] to facilitate an intuitive record creation process. + +Using `<Form>` ensures direct and logical navigation. After creating a record, the user is naturally guided to the new recipe's unique URL, reinforcing the outcome of their action. + +The component props bridge server and client, providing immediate feedback on submission issues. This quick response enables users to rectify any errors without hindrance. + +Lastly, `useNavigation` dynamically reflects the form's submission state. This subtle UI change, like toggling the button's label, assures users that their actions are being processed. + +Combined, these APIs offer a balanced blend of structured navigation and feedback. + +### Updating a Record + +Now consider we're looking at a list of recipes that have delete buttons on each item. When a user clicks the delete button, we want to delete the recipe from the database and remove it from the list without navigating away from the list. + +First, consider the basic route setup to get a list of recipes on the page: + +```tsx filename=app/pages/recipes.tsx +import type { Route } from "./+types/recipes"; + +export async function loader({ + request, +}: Route.LoaderArgs) { + return { + recipes: await db.recipes.findAll({ limit: 30 }), + }; +} + +export default function Recipes({ + loaderData, +}: Route.ComponentProps) { + const { recipes } = loaderData; + return ( + <ul> + {recipes.map((recipe) => ( + <RecipeListItem key={recipe.id} recipe={recipe} /> + ))} + </ul> + ); +} +``` + +Now we'll look at the action that deletes a recipe and the component that renders each recipe in the list. + +```tsx filename=app/pages/recipes.tsx lines=[10,21,27] +import { useFetcher } from "react-router"; +import type { Recipe } from "./recipe.server"; +import type { Route } from "./+types/recipes"; + +export async function action({ + request, +}: Route.ActionArgs) { + const formData = await request.formData(); + const id = formData.get("id"); + await db.recipes.delete(id); + return { ok: true }; +} + +export default function Recipes() { + return ( + // ... + // doesn't matter, somewhere it's using <RecipeListItem /> + ) +} + +function RecipeListItem({ recipe }: { recipe: Recipe }) { + const fetcher = useFetcher(); + const isDeleting = fetcher.state !== "idle"; + + return ( + <li> + <h2>{recipe.title}</h2> + <fetcher.Form method="post"> + <input type="hidden" name="id" value={recipe.id} /> + <button disabled={isDeleting} type="submit"> + {isDeleting ? "Deleting..." : "Delete"} + </button> + </fetcher.Form> + </li> + ); +} +``` + +Using [`useFetcher`][use-fetcher] in this scenario works perfectly. Instead of navigating away or refreshing the entire page, we want in-place updates. When a user deletes a recipe, the `action` is called and the fetcher manages the corresponding state transitions. + +The key advantage here is the maintenance of context. The user stays on the list when the deletion completes. The fetcher's state management capabilities are leveraged to give real-time feedback: it toggles between `"Deleting..."` and `"Delete"`, providing a clear indication of the ongoing process. + +Furthermore, with each `fetcher` having the autonomy to manage its own state, operations on individual list items become independent, ensuring that actions on one item don't affect the others (though revalidation of the page data is a shared concern covered in [Network Concurrency Management][network-concurrency-management]). + +In essence, `useFetcher` offers a seamless mechanism for actions that don't necessitate a change in the URL or navigation, enhancing the user experience by providing real-time feedback and context preservation. + +### Mark Article as Read + +Imagine you want to mark that an article has been read by the current user, after they've been on the page for a while and scrolled to the bottom. You could make a hook that looks something like this: + +```tsx +import { useFetcher } from "react-router"; + +function useMarkAsRead({ articleId, userId }) { + const marker = useFetcher(); + + useSpentSomeTimeHereAndScrolledToTheBottom(() => { + marker.submit( + { userId }, + { + action: `/article/${articleId}/mark-as-read`, + method: "post", + } + ); + }); +} +``` + +### User Avatar Details Popup + +Anytime you show the user avatar, you could put a hover effect that fetches data from a loader and displays it in a popup. + +```tsx filename=app/pages/user-details.tsx +import { useState, useEffect } from "react"; +import { useFetcher } from "react-router"; +import type { Route } from "./+types/user-details"; + +export async function loader({ params }: Route.LoaderArgs) { + return await fakeDb.user.find({ + where: { id: params.id }, + }); +} + +type LoaderData = Route.ComponentProps["loaderData"]; + +function UserAvatar({ partialUser }) { + const userDetails = useFetcher<LoaderData>(); + const [showDetails, setShowDetails] = useState(false); + + useEffect(() => { + if ( + showDetails && + userDetails.state === "idle" && + !userDetails.data + ) { + userDetails.load(`/user-details/${partialUser.id}`); + } + }, [showDetails, userDetails, partialUser.id]); + + return ( + <div + onMouseEnter={() => setShowDetails(true)} + onMouseLeave={() => setShowDetails(false)} + > + <img src={partialUser.profileImageUrl} /> + {showDetails ? ( + userDetails.state === "idle" && userDetails.data ? ( + <UserPopup user={userDetails.data} /> + ) : ( + <UserPopupLoading /> + ) + ) : null} + </div> + ); +} +``` + +## Conclusion + +React Router offers a range of tools to cater to varied developmental needs. While some functionalities might seem to overlap, each tool has been crafted with specific scenarios in mind. By understanding the intricacies and ideal applications of `<Form>`, `useFetcher`, and `useNavigation`, along with how data flows through component props, developers can create more intuitive, responsive, and user-friendly web applications. + +[form-component]: ../api/components/Form +[use-fetcher]: ../api/hooks/useFetcher +[use-navigation]: ../api/hooks/useNavigation +[network-concurrency-management]: ./concurrency diff --git a/docs/explanation/hot-module-replacement.md b/docs/explanation/hot-module-replacement.md index b9455785d6..0467cb7719 100644 --- a/docs/explanation/hot-module-replacement.md +++ b/docs/explanation/hot-module-replacement.md @@ -4,6 +4,11 @@ title: Hot Module Replacement # Hot Module Replacement +[MODES: framework] + +<br/> +<br/> + Hot Module Replacement is a technique for updating modules in your app without needing to reload the page. It's a great developer experience, and React Router supports it when using Vite. diff --git a/docs/explanation/index-query-param.md b/docs/explanation/index-query-param.md new file mode 100644 index 0000000000..d42a63dd48 --- /dev/null +++ b/docs/explanation/index-query-param.md @@ -0,0 +1,86 @@ +--- +title: Index Query Param +--- + +# Index Query Param + +[MODES: framework, data] + +## Overview + +You may find a wild `?index` appearing in the URL of your app when submitting forms. + +Because of nested routes, multiple routes in your route hierarchy can match the URL. Unlike navigations where all matching route [`loader`][loader]s are called to build up the UI, when a [`form`][form_element] is submitted, _only one action is called_. + +Because index routes share the same URL as their parent, the `?index` param lets you disambiguate between the two. + +## Understanding Index Routes + +For example, consider the following route structure: + +```ts filename=app/routes.ts +import { + type RouteConfig, + route, + index, +} from "@react-router/dev/routes"; + +export default [ + route("projects", "./pages/projects.tsx", [ + index("./pages/projects/index.tsx"), + route(":id", "./pages/projects/project.tsx"), + ]), +] satisfies RouteConfig; +``` + +This creates two routes that match `/projects`: + +- The parent route (`./pages/projects.tsx`) +- The index route (`./pages/projects/index.tsx`) + +## Form Submission Targeting + +For example, consider the following forms: + +```tsx +<Form method="post" action="https://redirect.github.com/projects" /> +<Form method="post" action="https://redirect.github.com/projects?index" /> +``` + +The `?index` param will submit to the index route; the action without the index param will submit to the parent route. + +When a [`<Form>`][form_component] is rendered in an index route without an [`action`][action], the `?index` param will automatically be appended so that the form posts to the index route. The following form, when submitted, will post to `/projects?index` because it is rendered in the context of the `projects` index route: + +```tsx filename=app/pages/projects/index.tsx +function ProjectsIndex() { + return <Form method="post" />; +} +``` + +If you moved the code to the project layout (`./pages/projects.tsx` in this example), it would instead post to `/projects`. + +This applies to `<Form>` and all of its cousins: + +```tsx +function Component() { + const submit = useSubmit(); + submit({}, { action: "/projects" }); + submit({}, { action: "/projects?index" }); +} +``` + +```tsx +function Component() { + const fetcher = useFetcher(); + fetcher.submit({}, { action: "/projects" }); + fetcher.submit({}, { action: "/projects?index" }); + <fetcher.Form action="https://redirect.github.com/projects" />; + <fetcher.Form action="https://redirect.github.com/projects?index" />; + <fetcher.Form />; // defaults to the route in context +} +``` + +[loader]: ../api/data-routers/loader +[form_element]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form +[form_component]: ../api/components/Form +[action]: ../api/data-routers/action diff --git a/docs/explanation/lazy-route-discovery.md b/docs/explanation/lazy-route-discovery.md new file mode 100644 index 0000000000..d99f09fa67 --- /dev/null +++ b/docs/explanation/lazy-route-discovery.md @@ -0,0 +1,78 @@ +--- +title: Lazy Route Discovery +--- + +# Lazy Route Discovery + +[MODES: framework] + +<br/> +<br/> + +Lazy Route Discovery is a performance optimization that loads route information progressively as users navigate through your application, rather than loading the complete route manifest upfront. + +With Lazy Route Discovery enabled (the default), React Router sends only the routes needed for the initial server-side render in the manifest. As users navigate to new parts of your application, additional route information is fetched dynamically and added to the client-side manifest. + +The route manifest contains metadata about your routes (JavaScript/CSS imports, whether routes have `loaders`/`actions`, etc.) but not the actual route module implementations. This allows React Router to understand your application's structure without downloading unnecessary route information. + +## Route Discovery Process + +When a user navigates to a new route that isn't in the current manifest: + +1. **Route Discovery Request** - React Router makes a request to the internal `/__manifest` endpoint +2. **Manifest Patch** - The server responds with the required route information +3. **Route Loading** - React Router loads the necessary route modules and data +4. **Navigation** - The user navigates to the new route + +## Eager Discovery Optimization + +To prevent navigation waterfalls, React Router implements eager route discovery. All [`<Link>`](../api/components/Link) and [`<NavLink>`](../api/components/NavLink) components rendered on the current page are automatically discovered via a batched request to the server. + +This discovery request typically completes before users click any links, making subsequent navigation feel synchronous even with lazy route discovery enabled. + +```tsx +// Links are automatically discovered by default +<Link to="/dashboard">Dashboard</Link> + +// Opt out of discovery for specific links +<Link to="/admin" discover="none">Admin</Link> +``` + +## Performance Benefits + +Lazy Route Discovery provides several performance improvements: + +- **Faster Initial Load** - Smaller initial bundle size by excluding unused route metadata +- **Reduced Memory Usage** - Route information is loaded only when needed +- **Scalability** - Applications with hundreds of routes see more significant benefits + +## Configuration + +You can configure route discovery behavior in your `react-router.config.ts`: + +```tsx filename=react-router.config.ts +export default { + // Default: lazy discovery with /__manifest endpoint + routeDiscovery: { + mode: "lazy", + manifestPath: "/__manifest", + }, + + // Custom manifest path (useful for multiple apps on same domain) + routeDiscovery: { + mode: "lazy", + manifestPath: "/my-app-manifest", + }, + + // Disable lazy discovery (include all routes initially) + routeDiscovery: { mode: "initial" }, +} satisfies Config; +``` + +## Deployment Considerations + +When using lazy route discovery, ensure your deployment setup handles manifest requests properly: + +- **Route Handling** - Ensure `/__manifest` requests reach your React Router handler +- **CDN Caching** - If using CDN/edge caching, include `version` and `p` query parameters in your cache key for the manifest endpoint +- **Multiple Applications** - Use a custom `manifestPath` if running multiple React Router applications on the same domain diff --git a/docs/explanation/picking-a-router.md b/docs/explanation/picking-a-router.md deleted file mode 100644 index 761005138b..0000000000 --- a/docs/explanation/picking-a-router.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Picking a Router -hidden: true ---- - -## TODO: diff --git a/docs/explanation/progressive-enhancement.md b/docs/explanation/progressive-enhancement.md index 8a65f38b0c..b502d3ffe6 100644 --- a/docs/explanation/progressive-enhancement.md +++ b/docs/explanation/progressive-enhancement.md @@ -4,9 +4,14 @@ title: Progressive Enhancement # Progressive Enhancement -> Progressive enhancement is a strategy in web design that puts emphasis on web content first, allowing everyone to access the basic content and functionality of a web page, whilst users with additional browser features or faster Internet access receive the enhanced version instead. +[MODES: framework] + +<br/> +<br/> -<cite>- [Wikipedia][wikipedia]</cite> +> Progressive enhancement is a strategy in web design that puts emphasis on web content first, allowing everyone to access the basic content and functionality of a web page, whilst users with additional browser features or faster Internet access receive the enhanced version instead. +> +> <cite>- [Wikipedia][wikipedia]</cite> When using React Router with Server-Side Rendering (the default in framework mode), you can automatically leverage the benefits of progressive enhancement. diff --git a/docs/explanation/race-conditions.md b/docs/explanation/race-conditions.md index 9289da4520..dbeb2fc5d0 100644 --- a/docs/explanation/race-conditions.md +++ b/docs/explanation/race-conditions.md @@ -4,6 +4,11 @@ title: Race Conditions # Race Conditions +[MODES: framework, data] + +<br/> +<br/> + While impossible to eliminate every possible race condition in your application, React Router automatically handles the most common race conditions found in web user interfaces. ## Browser Behavior diff --git a/docs/explanation/sessions-and-cookies.md b/docs/explanation/sessions-and-cookies.md index a0e37c9820..7be7367341 100644 --- a/docs/explanation/sessions-and-cookies.md +++ b/docs/explanation/sessions-and-cookies.md @@ -4,6 +4,8 @@ title: Sessions and Cookies # Sessions and Cookies +[MODES: framework, data] + ## Sessions Sessions are an important part of websites that allow the server to identify requests coming from the same person, especially when it comes to server-side form validation or when JavaScript is not on the page. Sessions are a fundamental building block of many sites that let users "log in", including social, e-commerce, business, and educational websites. diff --git a/docs/explanation/special-files.md b/docs/explanation/special-files.md index 9447977159..ca8192f2e8 100644 --- a/docs/explanation/special-files.md +++ b/docs/explanation/special-files.md @@ -1,358 +1,16 @@ --- title: Special Files +hidden: true --- # Special Files -There are a few special files that React Router looks for in your project. Not all of these files are required +The content of this page has been moved to the following: -## react-router.config.ts - -**This file is optional** - -The config file is used to configure certain aspects of your app, such as whether you are using server-side rendering, where certain directories are located, and more. - -```tsx filename=react-router.config.ts -import type { Config } from "@react-router/dev/config"; - -export default { - // Config options... -} satisfies Config; -``` - -See the details on [react-router config API][react-router-config] for more information. - -## root.tsx - -**This file is required** - -The "root" route (`app/root.tsx`) is the only _required_ route in your React Router application because it is the parent to all routes in your `routes/` directory and is in charge of rendering the root `<html>` document. - -Because the root route manages your document, it is the proper place to render a handful of "document-level" components React Router provides. These components are to be used once inside your root route and they include everything React Router figured out or built in order for your page to render properly. - -```tsx filename=app/root.tsx -import type { LinksFunction } from "react-router"; -import { - Links, - Meta, - Outlet, - Scripts, - ScrollRestoration, -} from "react-router"; - -import "./global-styles.css"; - -export default function App() { - return ( - <html lang="en"> - <head> - <meta charSet="utf-8" /> - <meta - name="viewport" - content="width=device-width, initial-scale=1" - /> - - {/* All `meta` exports on all routes will render here */} - <Meta /> - - {/* All `link` exports on all routes will render here */} - <Links /> - </head> - <body> - {/* Child routes render here */} - <Outlet /> - - {/* Manages scroll position for client-side transitions */} - {/* If you use a nonce-based content security policy for scripts, you must provide the `nonce` prop. Otherwise, omit the nonce prop as shown here. */} - <ScrollRestoration /> - - {/* Script tags go here */} - {/* If you use a nonce-based content security policy for scripts, you must provide the `nonce` prop. Otherwise, omit the nonce prop as shown here. */} - <Scripts /> - </body> - </html> - ); -} -``` - -### Layout export - -The root route supports all [route module exports][route-module]. - -The root route also supports an additional optional `Layout` export. The `Layout` component serves 2 purposes: - -1. Avoid duplicating your document's "app shell" across your root component, `HydrateFallback`, and `ErrorBoundary` -2. Prevent React from re-mounting your app shell elements when switching between the root component/`HydrateFallback`/`ErrorBoundary` which can cause a FOUC if React removes and re-adds `<link rel="stylesheet">` tags from your `<Links>` component. - -```tsx filename=app/root.tsx lines=[10-31] -export function Layout({ children }) { - return ( - <html lang="en"> - <head> - <meta charSet="utf-8" /> - <meta - name="viewport" - content="width=device-width, initial-scale=1" - /> - <Meta /> - <Links /> - </head> - <body> - {/* children will be the root Component, ErrorBoundary, or HydrateFallback */} - {children} - <Scripts /> - <ScrollRestoration /> - </body> - </html> - ); -} - -export default function App() { - return <Outlet />; -} - -export function ErrorBoundary() {} -``` - -**A note on `useLoaderData`in the `Layout` Component** - -`useLoaderData` is not permitted to be used in `ErrorBoundary` components because it is intended for the happy-path route rendering, and its typings have a built-in assumption that the `loader` ran successfully and returned something. That assumption doesn't hold in an `ErrorBoundary` because it could have been the `loader` that threw and triggered the boundary! In order to access loader data in `ErrorBoundary`'s, you can use `useRouteLoaderData` which accounts for the loader data potentially being `undefined`. - -Because your `Layout` component is used in both success and error flows, this same restriction holds. If you need to fork logic in your `Layout` depending on if it was a successful request or not, you can use `useRouteLoaderData("root")` and `useRouteError()`. - -<docs-warn>Because your `<Layout>` component is used for rendering the `ErrorBoundary`, you should be _very defensive_ to ensure that you can render your `ErrorBoundary` without encountering any render errors. If your `Layout` throws another error trying to render the boundary, then it can't be used and your UI will fall back to the very minimal built-in default `ErrorBoundary`.</docs-warn> - -```tsx filename=app/root.tsx lines=[6-7,19-29,32-34] -export function Layout({ - children, -}: { - children: React.ReactNode; -}) { - const data = useRouteLoaderData("root"); - const error = useRouteError(); - - return ( - <html lang="en"> - <head> - <meta charSet="utf-8" /> - <meta - name="viewport" - content="width=device-width, initial-scale=1" - /> - <Meta /> - <Links /> - <style - dangerouslySetInnerHTML={{ - __html: ` - :root { - --themeVar: ${ - data?.themeVar || defaultThemeVar - } - } - `, - }} - /> - </head> - <body> - {data ? ( - <Analytics token={data.analyticsToken} /> - ) : null} - {children} - <ScrollRestoration /> - <Scripts /> - </body> - </html> - ); -} -``` - -## routes.ts - -**This file is required** - -The `routes.ts` file is used to configure which url patterns are matched to which route modules. - -```tsx filename=app/routes.ts -import { - type RouteConfig, - route, -} from "@react-router/dev/routes"; - -export default [ - route("some/path", "./some/file.tsx"), - // pattern ^ ^ module file -] satisfies RouteConfig; -``` - -See the [routing guide][routing] for more information. - -## entry.client.tsx - -**This file is optional** - -By default, React Router will handle hydrating your app on the client for you. You can reveal the default entry client file with the following: - -```shellscript nonumber -react-router reveal -``` - -This file is the entry point for the browser and is responsible for hydrating the markup generated by the server in your [server entry module][server-entry], however you can also initialize any other client-side code here. - -```tsx filename=app/entry.client.tsx -import { startTransition, StrictMode } from "react"; -import { hydrateRoot } from "react-dom/client"; -import { HydratedRouter } from "react-router/dom"; - -startTransition(() => { - hydrateRoot( - document, - <StrictMode> - <HydratedRouter /> - </StrictMode> - ); -}); -``` - -This is the first piece of code that runs in the browser. You can initialize client side libraries, add client only providers, etc. - -## entry.server.tsx - -**This file is optional** - -By default, React Router will handle generating the HTTP Response for you. You can reveal the default entry server file with the following: - -```shellscript nonumber -react-router reveal -``` - -The `default` export of this module is a function that lets you create the response, including HTTP status, headers, and HTML, giving you full control over the way the markup is generated and sent to the client. - -This module should render the markup for the current page using a `<ServerRouter>` element with the `context` and `url` for the current request. This markup will (optionally) be re-hydrated once JavaScript loads in the browser using the [client entry module][client-entry]. - -### `streamTimeout` - -If you are [streaming] responses, you can export an optional `streamTimeout` value (in milliseconds) that will control the amount of time the server will wait for streamed promises to settle before rejecting outstanding promises them and closing the stream. - -It's recommended to decouple this value from the timeout in which you abort the React renderer. You should always set the React rendering timeout to a higher value so it has time to stream down the underlying rejections from your `streamTimeout`. - -```tsx lines=[1-2,13-15] -// Reject all pending promises from handler functions after 10 seconds -export const streamTimeout = 10000; - -export default function handleRequest(...) { - return new Promise((resolve, reject) => { - // ... - - const { pipe, abort } = renderToPipeableStream( - <ServerRouter context={routerContext} url={request.url} />, - { /* ... */ } - ); - - // Abort the streaming render pass after 11 seconds to allow the rejected - // boundaries to be flushed - setTimeout(abort, streamTimeout + 1000); - }); -} -``` - -### `handleDataRequest` - -You can export an optional `handleDataRequest` function that will allow you to modify the response of a data request. These are the requests that do not render HTML, but rather return the loader and action data to the browser once client-side hydration has occurred. - -```tsx -export function handleDataRequest( - response: Response, - { - request, - params, - context, - }: LoaderFunctionArgs | ActionFunctionArgs -) { - response.headers.set("X-Custom-Header", "value"); - return response; -} -``` - -### `handleError` - -By default, React Router will log encountered server-side errors to the console. If you'd like more control over the logging, or would like to also report these errors to an external service, then you can export an optional `handleError` function which will give you control (and will disable the built-in error logging). - -```tsx -export function handleError( - error: unknown, - { - request, - params, - context, - }: LoaderFunctionArgs | ActionFunctionArgs -) { - if (!request.signal.aborted) { - sendErrorToErrorReportingService(error); - console.error(formatErrorForJsonLogging(error)); - } -} -``` - -_Note that you generally want to avoid logging when the request was aborted, since React Router's cancellation and race-condition handling can cause a lot of requests to be aborted._ - -### Streaming Rendering Errors - -When you are streaming your HTML responses via [`renderToPipeableStream`][rendertopipeablestream] or [`renderToReadableStream`][rendertoreadablestream], your own `handleError` implementation will only handle errors encountered during the initial shell render. If you encounter a rendering error during subsequent streamed rendering you will need to handle these errors manually since the React Router server has already sent the Response by that point. - -For `renderToPipeableStream`, you can handle these errors in the `onError` callback function. You will need to toggle a boolean in `onShellReady` so you know if the error was a shell rendering error (and can be ignored) or an async - -For an example, please refer to the default [`entry.server.tsx`][node-streaming-entry-server] for Node. - -**Thrown Responses** - -Note that this does not handle thrown `Response` instances from your `loader`/`action` functions. The intention of this handler is to find bugs in your code which result in unexpected thrown errors. If you are detecting a scenario and throwing a 401/404/etc. `Response` in your `loader`/`action` then it's an expected flow that is handled by your code. If you also wish to log, or send those to an external service, that should be done at the time you throw the response. - -## `.server` modules - -While not strictly necessary, `.server` modules are a good way to explicitly mark entire modules as server-only. -The build will fail if any code in a `.server` file or `.server` directory accidentally ends up in the client module graph. - -```txt -app -โ”œโ”€โ”€ .server ๐Ÿ‘ˆ marks all files in this directory as server-only -โ”‚ โ”œโ”€โ”€ auth.ts -โ”‚ โ””โ”€โ”€ db.ts -โ”œโ”€โ”€ cms.server.ts ๐Ÿ‘ˆ marks this file as server-only -โ”œโ”€โ”€ root.tsx -โ””โ”€โ”€ routes.ts -``` - -`.server` modules must be within your app directory. - -Refer to the Route Module section in the sidebar for more information. - -## `.client` modules - -While uncommon, you may have a file or dependency that uses module side effects in the browser. You can use `*.client.ts` on file names or nest files within `.client` directories to force them out of server bundles. - -```ts filename=feature-check.client.ts -// this would break the server -export const supportsVibrationAPI = - "vibrate" in window.navigator; -``` - -Note that values exported from this module will all be `undefined` on the server, so the only places to use them are in [`useEffect`][use_effect] and user events like click handlers. - -```ts -import { supportsVibrationAPI } from "./feature-check.client.ts"; - -console.log(supportsVibrationAPI); -// server: undefined -// client: true | false -``` - -[react-router-config]: https://api.reactrouter.com/v7/types/_react_router_dev.config.Config.html -[route-module]: ../start/framework/route-module -[routing]: ../start/framework/routing -[server-entry]: #entryservertsx -[client-entry]: #entryclienttsx -[rendertopipeablestream]: https://react.dev/reference/react-dom/server/renderToPipeableStream -[rendertoreadablestream]: https://react.dev/reference/react-dom/server/renderToReadableStream -[node-streaming-entry-server]: https://github.com/remix-run/react-router/blob/dev/packages/react-router-dev/config/defaults/entry.server.node.tsx -[streaming]: ../how-to/suspense -[use_effect]: https://react.dev/reference/react/useEffect +- [`react-router.config.ts`](../api/framework-conventions/react-router.config.ts) - Optional configuration file for your app +- [`root.tsx`](../api/framework-conventions/root.tsx) - Required root route that renders the HTML document +- [`routes.ts`](../api/framework-conventions/routes.ts) - Required route configuration mapping URLs to components +- [`entry.client.tsx`](../api/framework-conventions/entry.client.tsx) - Optional client-side entry point for hydration +- [`entry.server.tsx`](../api/framework-conventions/entry.server.tsx) - Optional server-side entry point for rendering +- [`.server` modules](../api/framework-conventions/server-modules) - Server-only modules excluded from client bundles +- [`.client` modules](../api/framework-conventions/client-modules) - Client-only modules excluded from server bundles diff --git a/docs/explanation/state-management.md b/docs/explanation/state-management.md index 7df9ffd6ef..8b6d0f752c 100644 --- a/docs/explanation/state-management.md +++ b/docs/explanation/state-management.md @@ -4,6 +4,11 @@ title: State Management # State Management +[MODES: framework, data] + +<br/> +<br/> + State management in React typically involves maintaining a synchronized cache of server data on the client side. However, when using React Router as your framework, most of the traditional caching solutions become redundant because of how it inherently handles data synchronization. ## Understanding State Management in React diff --git a/docs/explanation/type-safety.md b/docs/explanation/type-safety.md index 844cc1bd0b..734a24a033 100644 --- a/docs/explanation/type-safety.md +++ b/docs/explanation/type-safety.md @@ -4,6 +4,11 @@ title: Type Safety # Type Safety +[MODES: framework] + +<br/> +<br/> + If you haven't done so already, check out our guide for [setting up type safety][route-module-type-safety] in a new project. React Router generates types for each route in your app to provide type safety for the route module exports. diff --git a/docs/how-to/accessibility.md b/docs/how-to/accessibility.md index 351db6a390..287dd95ceb 100644 --- a/docs/how-to/accessibility.md +++ b/docs/how-to/accessibility.md @@ -10,12 +10,22 @@ React Router makes certain accessibility practices the default where possible an ## Links +[MODES: framework, data, declarative] + +<br/> +<br/> + The [`<Link>` component][link] renders a standard anchor tag, meaning that you get its accessibility behaviors from the browser for free! React Router also provides the [`<NavLink/>`][navlink] which behaves the same as `<Link>`, but it also provides context for assistive technology when the link points to the current page. This is useful for building navigation menus or breadcrumbs. ## Routing +[MODES: framework] + +<br/> +<br/> + If you are rendering [`<Scripts>`][scripts] in your app, there are some important things to consider to make client-side routing more accessible for your users. With a traditional multi-page website we don't have to think about route changes too much. Your app renders an anchor tag, and the browser handles the rest. If your users disable JavaScript, your React Router app should already work this way by default! diff --git a/docs/how-to/client-data.md b/docs/how-to/client-data.md index feef583030..18f211b2cf 100644 --- a/docs/how-to/client-data.md +++ b/docs/how-to/client-data.md @@ -4,6 +4,11 @@ title: Client Data # Client Data +[MODES: framework] + +<br/> +<br/> + You can fetch and mutate data directly in the browser using `clientLoader` and `clientAction` functions. These functions are the primary mechanism for data handling when using [SPA mode][spa]. This guide demonstrates common use cases for leveraging client data in Server-Side Rendering (SSR). diff --git a/docs/how-to/error-boundary.md b/docs/how-to/error-boundary.md index 3bd4daa6e8..c8e7f79daa 100644 --- a/docs/how-to/error-boundary.md +++ b/docs/how-to/error-boundary.md @@ -4,6 +4,11 @@ title: Error Boundaries # Error Boundaries +[MODES: framework, data] + +<br/> +<br/> + To avoid rendering an empty page to users, route modules will automatically catch errors in your code and render the closest `ErrorBoundary`. Error boundaries are not intended for error reporting or rendering form validation errors. Please see [Form Validation](./form-validation) and [Error Reporting](./error-reporting) instead. diff --git a/docs/how-to/error-reporting.md b/docs/how-to/error-reporting.md index 685d0ea0f6..25fc046d25 100644 --- a/docs/how-to/error-reporting.md +++ b/docs/how-to/error-reporting.md @@ -4,6 +4,11 @@ title: Error Reporting # Error Reporting +[MODES: framework] + +<br/> +<br/> + React Router catches errors in your route modules and sends them to [error boundaries](./error-boundary) to prevent blank pages when errors occur. However, ErrorBoundary isn't sufficient for logging and reporting errors. To access these caught errors, use the handleError export of the server entry module. ## 1. Reveal the server entry diff --git a/docs/how-to/fetchers.md b/docs/how-to/fetchers.md index 729575de45..08e6986cbe 100644 --- a/docs/how-to/fetchers.md +++ b/docs/how-to/fetchers.md @@ -4,6 +4,11 @@ title: Using Fetchers # Using Fetchers +[MODES: framework, data] + +<br/> +<br/> + Fetchers are useful for creating complex, dynamic user interfaces that require multiple, concurrent data interactions without causing a navigation. Fetchers track their own, independent state and can be used to load data, mutate data, submit forms, and generally interact with loaders and actions. diff --git a/docs/how-to/file-route-conventions.md b/docs/how-to/file-route-conventions.md index 037ddecc77..43f937b5d8 100644 --- a/docs/how-to/file-route-conventions.md +++ b/docs/how-to/file-route-conventions.md @@ -4,6 +4,11 @@ title: File Route Conventions # File Route Conventions +[MODES: framework] + +<br/> +<br/> + The `@react-router/fs-routes` package enables file-convention based route config. ## Setting up diff --git a/docs/how-to/file-uploads.md b/docs/how-to/file-uploads.md index d77f064a06..9f1cbc2a29 100644 --- a/docs/how-to/file-uploads.md +++ b/docs/how-to/file-uploads.md @@ -4,6 +4,11 @@ title: File Uploads # File Uploads +[MODES: framework] + +<br/> +<br/> + Handle file uploads in your React Router applications. This guide uses some packages from the [Remix The Web][remix-the-web] project to make file uploads easier. _Thank you to David Adams for [writing an original guide](https://programmingarehard.com/2024/09/06/remix-file-uploads-updated.html/) on which this doc is based. You can refer to it for even more examples._ diff --git a/docs/how-to/form-validation.md b/docs/how-to/form-validation.md index 7376c13f19..b06d797303 100644 --- a/docs/how-to/form-validation.md +++ b/docs/how-to/form-validation.md @@ -4,6 +4,11 @@ title: Form Validation # Form Validation +[MODES: framework, data] + +<br/> +<br/> + This guide walks through a simple signup form implementation. You will likely want to pair these concepts with third-party validation libraries and error components, but this guide only focuses on the moving pieces for React Router. ## 1. Setting Up diff --git a/docs/how-to/headers.md b/docs/how-to/headers.md index 19e6588ec4..99b7bf4ea7 100644 --- a/docs/how-to/headers.md +++ b/docs/how-to/headers.md @@ -4,6 +4,11 @@ title: HTTP Headers # HTTP Headers +[MODES: framework] + +<br/> +<br/> + Headers are primarily defined with the route module `headers` export. You can also set headers in `entry.server.tsx`. ## From Route Modules diff --git a/docs/how-to/middleware.md b/docs/how-to/middleware.md new file mode 100644 index 0000000000..85e0569d17 --- /dev/null +++ b/docs/how-to/middleware.md @@ -0,0 +1,442 @@ +--- +title: Middleware +unstable: true +--- + +# Middleware + +<docs-warning>The middleware feature is currently experimental and subject to breaking changes. Use the `future.unstable_middleware` flag to enable it.</docs-warning> + +Middleware allows you to run code before and after your route handlers (loaders, actions, and components) execute. This enables common patterns like authentication, logging, error handling, and data preprocessing in a reusable way. + +Middleware runs in a nested chain, executing from parent routes to child routes on the way "down" to your route handlers, then from child routes back to parent routes on the way "up" after your handlers complete. + +For example, on a `GET /parent/child` request, the middleware would run in the following order: + +```text +- Root middleware start + - Parent middleware start + - Child middleware start + - Run loaders + - Child middleware end + - Parent middleware end +- Root middleware end +``` + +## Quick Start + +### 1. Enable the middleware flag + +First, enable middleware in your React Router config: + +```ts filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; + +export default { + future: { + unstable_middleware: true, + }, +} satisfies Config; +``` + +<docs-warning>By enabling the middleware feature, you change the type of the `context` parameter to your loaders and actions. Please pay attention to the section on [getLoadContext](#custom-server-with-getloadcontext) below if you are actively using `context` today.</docs-warning> + +### 2. Add type support + +Update your `app/types/global.d.ts` to enable middleware types: + +```ts filename=app/types/global.d.ts +declare module "@react-router/dev/routes" { + interface AppConfig { + future: { + unstable_middleware: true; + }; + } +} +``` + +### 3. Create a context + +Create type-safe context objects using `unstable_createContext`: + +```ts filename=app/context.ts +import { unstable_createContext } from "react-router"; +import type { User } from "~/types"; + +export const userContext = + unstable_createContext<User | null>(null); +``` + +### 4. Export middleware from your routes + +```tsx filename=app/routes/dashboard.tsx +import { redirect } from "react-router"; +import { userContext } from "~/context"; + +// Server-side Authentication Middleware +export const unstable_middleware: Route.unstable_MiddlewareFunction[] = + [ + async ({ request, context }) => { + const user = await getUserFromSession(request); + if (!user) { + throw redirect("/login"); + } + context.set(userContext, user); + }, + ]; + +// Client-side timing middleware +export const unstable_clientMiddleware: Route.unstable_ClientMiddlewareFunction[] = + [ + async ({ context }, next) => { + const start = performance.now(); + + await next(); + + const duration = performance.now() - start; + console.log(`Navigation took ${duration}ms`); + }, + ]; + +export async function loader({ + context, +}: Route.LoaderArgs) { + const user = context.get(userContext); + const profile = await getProfile(user); + return { profile }; +} + +export default function Dashboard({ + loaderData, +}: Route.ComponentProps) { + return ( + <div> + <h1>Welcome {loaderData.profile.fullName}!</h1> + <Profile profile={loaderData.profile} /> + </div> + ); +} +``` + +## Core Concepts + +### Server vs Client Middleware + +**Server middleware** (`unstable_middleware`) runs on the server for: + +- HTML Document requests +- `.data` requests for subsequent navigations and fetcher calls + +**Client middleware** (`unstable_clientMiddleware`) runs in the browser for: + +- Client-side navigations and fetcher calls + +### The `next` Function + +The `next` function runs the next middleware in the chain, or the route handlers if it's the leaf route middleware: + +```ts +const middleware = async ({ context }, next) => { + // Code here runs BEFORE handlers + console.log("Before"); + + const response = await next(); + + // Code here runs AFTER handlers + console.log("After"); + + return response; // Optional on client, required on server +}; +``` + +<docs-warning>You can only call `next()` once per middleware. Calling it multiple times will throw an error</docs-warning> + +### Skipping `next()` + +If you don't need to run code after your handlers, you can skip calling `next()`: + +```ts +const authMiddleware = async ({ request, context }) => { + const user = await getUser(request); + if (!user) { + throw redirect("/login"); + } + context.set(userContext, user); + // next() is called automatically +}; +``` + +### Context API + +The new context system provides type safety and prevents naming conflicts: + +```ts +// โœ… Type-safe +import { unstable_createContext } from "react-router"; +const userContext = unstable_createContext<User>(); + +// Later in middleware/loaders +context.set(userContext, user); // Must be User type +const user = context.get(userContext); // Returns User type + +// โŒ Old way (no type safety) +// context.user = user; // Could be anything +``` + +## Common Patterns + +### Authentication + +```tsx filename=app/middleware/auth.ts +import { redirect } from "react-router"; +import { userContext } from "~/context"; +import { getSession } from "~/sessions.server"; + +export const authMiddleware = async ({ + request, + context, +}) => { + const session = await getSession(request); + const userId = session.get("userId"); + + if (!userId) { + throw redirect("/login"); + } + + const user = await getUserById(userId); + context.set(userContext, user); +}; +``` + +```tsx filename=app/routes/protected.tsx +import { authMiddleware } from "~/middleware/auth"; + +export const unstable_middleware = [authMiddleware]; + +export function loader({ context }: Route.LoaderArgs) { + const user = context.get(userContext); // Guaranteed to exist + return { user }; +} +``` + +### Logging + +```tsx filename=app/middleware/logging.ts +import { requestIdContext } from "~/context"; + +export const loggingMiddleware = async ( + { request, context }, + next +) => { + const requestId = crypto.randomUUID(); + context.set(requestIdContext, requestId); + + console.log( + `[${requestId}] ${request.method} ${request.url}` + ); + + const start = performance.now(); + const response = await next(); + const duration = performance.now() - start; + + console.log( + `[${requestId}] Response ${response.status} (${duration}ms)` + ); + + return response; +}; +``` + +### Error Handling + +```tsx filename=app/middleware/error-handling.ts +export const errorMiddleware = async ( + { context }, + next +) => { + try { + return await next(); + } catch (error) { + // Log error + console.error("Route error:", error); + + // Re-throw to let React Router handle it + throw error; + } +}; +``` + +### 404 to CMS Redirect + +```tsx filename=app/middleware/cms-fallback.ts +export const cmsFallbackMiddleware = async ( + { request }, + next +) => { + const response = await next(); + + // Check if we got a 404 + if (response.status === 404) { + // Check CMS for a redirect + const cmsRedirect = await checkCMSRedirects( + request.url + ); + if (cmsRedirect) { + throw redirect(cmsRedirect, 302); + } + } + + return response; +}; +``` + +### Response Headers + +```tsx filename=app/middleware/headers.ts +export const headersMiddleware = async ( + { context }, + next +) => { + const response = await next(); + + // Add security headers + response.headers.set("X-Frame-Options", "DENY"); + response.headers.set("X-Content-Type-Options", "nosniff"); + + return response; +}; +``` + +## Client-Side Middleware + +Client middleware works similarly but doesn't return responses: + +```tsx filename=app/routes/dashboard.tsx +import { userContext } from "~/context"; + +export const unstable_clientMiddleware = [ + ({ context }) => { + // Set up client-side user data + const user = getLocalUser(); + context.set(userContext, user); + }, + + async ({ context }, next) => { + console.log("Starting client navigation"); + await next(); + console.log("Client navigation complete"); + }, +]; + +export async function clientLoader({ + context, +}: Route.ClientLoaderArgs) { + const user = context.get(userContext); + return { user }; +} +``` + +## Advanced Usage + +### Conditional Middleware + +```tsx +export const unstable_middleware = [ + async ({ request, context }, next) => { + // Only run auth for POST requests + if (request.method === "POST") { + await ensureAuthenticated(request, context); + } + return next(); + }, +]; +``` + +### Sharing Context Between Action and Loader + +```tsx +const sharedDataContext = unstable_createContext<any>(); + +export const unstable_middleware = [ + async ({ request, context }, next) => { + if (request.method === "POST") { + // Set data during action phase + context.set( + sharedDataContext, + await getExpensiveData() + ); + } + return next(); + }, +]; + +export async function action({ + context, +}: Route.ActionArgs) { + const data = context.get(sharedDataContext); + // Use the data... +} + +export async function loader({ + context, +}: Route.LoaderArgs) { + const data = context.get(sharedDataContext); + // Same data is available here +} +``` + +### Custom Server with getLoadContext + +If you're using a custom server, update your `getLoadContext` function: + +```ts filename=app/entry.server.tsx +import { unstable_createContext } from "react-router"; +import type { unstable_InitialContext } from "react-router"; + +const dbContext = unstable_createContext<Database>(); + +function getLoadContext(req, res): unstable_InitialContext { + const map = new Map(); + map.set(dbContext, database); + return map; +} +``` + +### Migration from AppLoadContext + +If you're currently using `AppLoadContext`, you can migrate most easily by creating a context for your existing object: + +```ts filename=app/context.ts +import { unstable_createContext } from "react-router"; + +declare module "@react-router/server-runtime" { + interface AppLoadContext { + db: Database; + user: User; + } +} + +const myLoadContext = + unstable_createContext<AppLoadContext>(); +``` + +Update your `getLoadContext` function to return a Map with the context initial value: + +```diff filename=app/entry.server.tsx +function getLoadContext() { + const loadContext = {...}; +- return loadContext; ++ return new Map([ ++ [myLoadContext, appLoadContextInstance]] ++ ); +} +``` + +Update your loaders/actions to read from the new context instance: + +```diff filename=app/routes/example.tsx +export function loader({ context }: Route.LoaderArgs) { +- const { db, user } = context; ++ const { db, user } = context.get(myLoadContext); +} +``` diff --git a/docs/how-to/navigation-blocking.md b/docs/how-to/navigation-blocking.md index 72545c0251..304bc053dd 100644 --- a/docs/how-to/navigation-blocking.md +++ b/docs/how-to/navigation-blocking.md @@ -6,7 +6,8 @@ title: Navigation Blocking [MODES: framework, data] -## Overview +<br/> +<br/> When users are in the middle of a workflow, like filling out an important form, you may want to prevent them from navigating away from the page. diff --git a/docs/how-to/pre-rendering.md b/docs/how-to/pre-rendering.md index 28bf95e51d..71e182c423 100644 --- a/docs/how-to/pre-rendering.md +++ b/docs/how-to/pre-rendering.md @@ -4,6 +4,11 @@ title: Pre-Rendering # Pre-Rendering +[MODES: framework] + +<br/> +<br/> + Pre-Rendering allows you to speed up page loads for static content by rendering pages at build time instead of at runtime. Pre-rendering is enabled via the `prerender` config in `react-router.config.ts` and can be used in two ways based on the `ssr` config value: - Alongside a runtime SSR server with `ssr:true` (the default value) diff --git a/docs/how-to/presets.md b/docs/how-to/presets.md new file mode 100644 index 0000000000..f5e4f497d7 --- /dev/null +++ b/docs/how-to/presets.md @@ -0,0 +1,103 @@ +--- +title: Presets +--- + +# Presets + +[MODES: framework] + +<br/> +<br/> + +The [React Router config][react-router-config] supports a `presets` option to ease integration with other tools and hosting providers. + +[Presets][preset-type] can only do two things: + +- Configure React Router config options on your behalf +- Validate the resolved config + +The config returned by each preset is merged in the order the presets were defined. Any config directly specified in your React Router config will be merged last. This means that your config will always take precedence over any presets. + +## Defining preset config + +As a basic example, let's create a preset that configures a [server bundles function][server-bundles]: + +```ts filename=my-cool-preset.ts +import type { Preset } from "@react-router/dev/config"; + +export function myCoolPreset(): Preset { + return { + name: "my-cool-preset", + reactRouterConfig: () => ({ + serverBundles: ({ branch }) => { + const isAuthenticatedRoute = branch.some((route) => + route.id.split("/").includes("_authenticated") + ); + + return isAuthenticatedRoute + ? "authenticated" + : "unauthenticated"; + }, + }), + }; +} +``` + +## Validating config + +Keep in mind that other presets and user config can still override the values returned from your preset. + +In our example preset, the `serverBundles` function could be overridden with a different, conflicting implementation. If we want to validate that the final resolved config contains the `serverBundles` function from our preset, we can use the `reactRouterConfigResolved` hook: + +```ts filename=my-cool-preset.ts lines=[22-27] +import type { + Preset, + ServerBundlesFunction, +} from "@react-router/dev/config"; + +const serverBundles: ServerBundlesFunction = ({ + branch, +}) => { + const isAuthenticatedRoute = branch.some((route) => + route.id.split("/").includes("_authenticated") + ); + + return isAuthenticatedRoute + ? "authenticated" + : "unauthenticated"; +}; + +export function myCoolPreset(): Preset { + return { + name: "my-cool-preset", + reactRouterConfig: () => ({ serverBundles }), + reactRouterConfigResolved: ({ reactRouterConfig }) => { + if ( + reactRouterConfig.serverBundles !== serverBundles + ) { + throw new Error("`serverBundles` was overridden!"); + } + }, + }; +} +``` + +The `reactRouterConfigResolved` hook should only be used when it would be an error to merge or override your preset's config. + +## Using a preset + +Presets are designed to be published to npm and used within your React Router config. + +```ts filename=react-router.config.ts lines=[6] +import type { Config } from "@react-router/dev/config"; +import { myCoolPreset } from "react-router-preset-cool"; + +export default { + // ... + presets: [myCoolPreset()], +} satisfies Config; +``` + +[react-router-config]: https://api.reactrouter.com/v7/types/_react_router_dev.config.Config.html +[preset-type]: https://api.reactrouter.com/v7/types/_react_router_dev.config.Preset.html +[server-bundles]: ./server-bundles diff --git a/docs/how-to/react-server-components.md b/docs/how-to/react-server-components.md index 975dd99b98..3e43a7c866 100644 --- a/docs/how-to/react-server-components.md +++ b/docs/how-to/react-server-components.md @@ -1,50 +1,790 @@ --- title: React Server Components -# need to ship it first! -hidden: true +unstable: true --- # React Server Components -<docs-info>This feature is still in development and not yet available.</docs-info> +[MODES: data] -In the future, async components can be rendered in loaders like any other data: +<br/> +<br/> -```tsx filename=app/product-page.tsx -// route("products/:pid", "./product-page.tsx"); -import type { Route } from "./+types/product"; -import Product from "./product"; -import Reviews from "./reviews"; +<docs-warning>React Server Components support is experimental and subject to breaking changes.</docs-warning> -export async function loader({ params }: Route.LoaderArgs) { - return { - product: <Product id={params.pid} />, - reviews: <Reviews productId={params.pid} />, - }; +React Server Components (RSC) refers generally to an architecture and set of APIs provided by React since version 19. + +From the docs: + +> Server Components are a new type of Component that renders ahead of time, before bundling, in an environment separate from your client app or SSR server. +> +> <cite>- [React "Server Components" docs][react-server-components-doc]</cite> + +React Router provides a set of APIs for integrating with RSC-compatible bundlers, allowing you to leverage [Server Components][react-server-components-doc] and [Server Functions][react-server-functions-doc] in your React Router applications. + +## Quick Start + +The quickest way to get started is with one of our templates. + +These templates come with React Router RSC APIs already configured with the respective bundler, offering you out of the box features such as: + +- Server Component Routes +- Server Side Rendering (SSR) +- Client Components (via [`"use client"`][use-client-docs] directive) +- Server Functions (via [`"use server"`][use-server-docs] directive) + +**Parcel Template** + +The [parcel template][parcel-rsc-template] uses the official React `react-server-dom-parcel` plugin. + +```shellscript +npx create-react-router-app@latest --template=unstable_rsc-parcel +``` + +**Vite Template** + +The [vite template][vite-rsc-template] uses the experimental Vite `@vitejs/plugin-rsc` plugin. + +```shellscript +npx create-react-router-app@latest --template=unstable_rsc-vite +``` + +**RSC + SPA (no SSR) Template** + +For those who like alphabet soup. + +```shellscript +npx create-react-router-app@latest --template=unstable_rsc-vite-spa +``` + +## Using RSC with React Router + +### Configuring Routes + +Routes are configured as an argument to [`matchRSCServerRequest`][match-rsc-server-request]. At a minimum, you need a path and component: + +```tsx +function Root() { + return <h1>Hello world</h1>; } -export default function ProductPage({ - loaderData, -}: Route.ComponentProps) { +matchRSCServerRequest({ + // ...other options + routes: [{ path: "/", Component: Root }], +}); +``` + +While you can define components inline, we recommend using the `lazy()` option and defining [Route Modules][route-module] for both startup performance and code organization + +<docs-info> + +The [Route Module API][route-module] up until now has been a [Framework Mode][framework-mode] only feature. However, the `lazy` field of the RSC route config expects the same exports as the Route Module exports, unifying the APIs even further. + +</docs-info> + +```tsx filename=app/routes.ts +import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + +export function routes() { + return [ + { + id: "root", + path: "", + lazy: () => import("./root/route"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./home/route"), + }, + { + id: "about", + path: "about", + lazy: () => import("./about/route"), + }, + ], + }, + ] satisfies RSCRouteConfig; +} +``` + +### Server Component Routes + +By default each route's `default` export renders a Server Component + +```tsx +export default function Home() { return ( - <div> - {loaderData.product} - <Suspense fallback={<div>loading...</div>}> - {loaderData.reviews} - </Suspense> - </div> + <main> + <article> + <h1>Welcome to React Router RSC</h1> + <p> + You won't find me running any JavaScript in the + browser! + </p> + </article> + </main> ); } ``` -```tsx filename=app/product.tsx -export async function Product({ id }: { id: string }) { - const product = await fakeDb.getProduct(id); +A nice feature of Server Components is that you can fetch data directly from your component by making it asynchronous. + +```tsx +export default async function Home() { + let user = await getUserData(); + return ( - <div> - <h1>{product.title}</h1> - <p>{product.description}</p> - </div> + <main> + <article> + <h1>Welcome to React Router RSC</h1> + <p> + You won't find me running any JavaScript in the + browser! + </p> + <p> + Hello, {user ? user.name : "anonymous person"}! + </p> + </article> + </main> ); } ``` + +<docs-info> + +Server Components can also be returned from your loaders and actions. In general, if you are using RSC to build your application, loaders are primarily useful for things like setting `status` codes or returning a `redirect`. + +Using Server Components in loaders can be helpful for incremental adoption of RSC. + +</docs-info> + +### Server Functions + +[Server Functions][react-server-functions-doc] are a React feature that allow you to call async functions executed on the server. They're defined with the [`"use server"`][use-server-docs] directive. + +```tsx +"use server"; + +export async function updateFavorite(formData: FormData) { + let movieId = formData.get("id"); + let intent = formData.get("intent"); + if (intent === "add") { + await addFavorite(Number(movieId)); + } else { + await removeFavorite(Number(movieId)); + } +} +``` + +```tsx +import { updateFavorite } from "./action.ts"; +export async function AddToFavoritesForm({ + movieId, +}: { + movieId: number; +}) { + let isFav = await isFavorite(movieId); + return ( + <form action={updateFavorite}> + <input type="hidden" name="id" value={movieId} /> + <input + type="hidden" + name="intent" + value={isFav ? "remove" : "add"} + /> + <AddToFavoritesButton isFav={isFav} /> + </form> + ); +} +``` + +Note that after server functions are called, React Router will automatically revalidate the route and update the UI with the new server content. You don't have to mess around with any cache invalidation. + +### Client Properties + +Routes are defined on the server at runtime, but we can still provide `clientLoader`, `clientAction`, and `shouldRevalidate` through the utilization of client references and `"use client"`. + +```tsx filename=src/routes/root/client.tsx +"use client"; + +export function clientAction() {} + +export function clientLoader() {} + +export function shouldRevalidate() {} +``` + +We can then re-export these from our lazy loaded route module: + +```tsx filename=src/routes/root/route.tsx +export { + clientAction, + clientLoader, + shouldRevalidate, +} from "./route.client"; + +export default function Root() { + // ... +} +``` + +This is also the way we would make an entire route a Client Component. + +```tsx filename=src/routes/root/route.tsx lines=[1,11] +import { default as ClientRoot } from "./route.client"; +export { + clientAction, + clientLoader, + shouldRevalidate, +} from "./route.client"; + +export default function Root() { + // Adding a Server Component at the root is required by bundlers + // if you're using css side-effects imports. + return <ClientRoot />; +} +``` + +## Configuring RSC with React Router + +React Router provides several APIs that allow you to easily integrate with RSC-compatible bundlers, useful if you are using React Router Data Mode to make your own [custom framework][custom-framework]. + +The following steps show how to setup a React Router application to use Server Components (RSC) to server-render (SSR) pages and hydrate them for single-page app (SPA) navigations. You don't have to use SSR (or even client-side hydration) if you don't want to. You can also leverage the HTML generation for Static Site Generation (SSG) or Incremental Static Regeneration (ISR) if you prefer. This guide is meant merely to explain how to wire up all the different APIs for a typically RSC-based application. + +### Entry points + +Besides our [route definitions](#configuring-routes), we will need to configure the following: + +1. A server to handle the incoming request, fetch the RSC payload, and convert it into HTML +2. A React server to generate RSC payloads +3. A browser handler to hydrate the generated HTML and set the `callServer` function to support post-hydration server actions + +The following naming conventions have been chosen for familiarity and simplicity. Feel free to name and configure your entry points as you see fit. + +See the relevant bundler documentation below for specific code examples for each of the following entry points. + +These examples all use [express][express] and [@mjackson/node-fetch-server][node-fetch-server] for the server and request handling. + +**Routes** + +See [Configuring Routes](#configuring-routes). + +**Server** + +<docs-info> + +You don't have to use SSR at all. You can choose to use RSC to "prerender" HTML for Static Site Generation (SSG) or something like Incremental Static Regeneration (ISR). + +</docs-info> + +`entry.ssr.tsx` is the entry point for the server. It is responsible for handling the request, calling the RSC server, and converting the RSC payload into HTML on document requests (server-side rendering). + +Relevant APIs: + +- [`routeRSCServerRequest`][route-rsc-server-request] +- [`RSCStaticRouter`][rsc-static-router] + +**RSC Server** + +<docs-info> + +Even though you have a "React Server" and a server responsible for request handling/SSR, you don't actually need to have 2 separate servers. You can simply have 2 separate module graphs within the same server. This is important because React behaves differently when generating RSC payloads vs. when generating HTML to be hydrated on the client. + +</docs-info> + +`entry.rsc.tsx` is the entry point for the React Server. It is responsible for matching the request to a route and generating RSC payloads. + +Relevant APIs: + +- [`matchRSCServerRequest`][match-rsc-server-request] + +**Browser** + +`entry.browser.tsx` is the entry point for the client. It is responsible for hydrating the generated HTML and setting the `callServer` function to support post-hydration server actions. + +Relevant APIs: + +- [`createCallServer`][create-call-server] +- [`getRSCStream`][get-rsc-stream] +- [`RSCHydratedRouter`][rsc-hydrated-router] + +### Parcel + +See the [Parcel RSC docs][parcel-rsc-doc] for more information. You can also refer to our [Parcel RSC Parcel template][parcel-rsc-template] to see a working version. + +In addition to `react`, `react-dom`, and `react-router`, you'll need the following dependencies: + +```shellscript +# install runtime dependencies +npm i @parcel/runtime-rsc react-server-dom-parcel + +# install dev dependencies +npm i -D parcel +``` + +#### `package.json` + +To configure Parcel, add the following to your `package.json`: + +```json filename=package.json +{ + "scripts": { + "build": "parcel build --no-autoinstall", + "dev": "cross-env NODE_ENV=development parcel --no-autoinstall --no-cache", + "start": "cross-env NODE_ENV=production node dist/server/entry.rsc.js" + }, + "targets": { + "react-server": { + "context": "react-server", + "source": "src/entry.rsc.tsx", + "scopeHoist": false, + "includeNodeModules": { + "@mjackson/node-fetch-server": false, + "compression": false, + "express": false + } + } + } +} +``` + +#### `routes/config.ts` + +You must add `"use server-entry"` to the top of the file where you define your routes. Additionally, you need to import the client entry point, since it will use the `"use client-entry"` directive (see below). + +```tsx filename=src/routes/config.ts +"use server-entry"; + +import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + +import "../entry.browser"; + +// This needs to be a function so Parcel can add a `bootstrapScript` property. +export function routes() { + return [ + { + id: "root", + path: "", + lazy: () => import("./root/route"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./home/route"), + }, + { + id: "about", + path: "about", + lazy: () => import("./about/route"), + }, + ], + }, + ] satisfies RSCRouteConfig; +} +``` + +#### `entry.ssr.tsx` + +The following is a simplified example of a Parcel SSR Server. + +```tsx filename=src/entry.ssr.tsx +import { renderToReadableStream as renderHTMLToReadableStream } from "react-dom/server.edge"; +import { + unstable_routeRSCServerRequest as routeRSCServerRequest, + unstable_RSCStaticRouter as RSCStaticRouter, +} from "react-router"; +import { createFromReadableStream } from "react-server-dom-parcel/client.edge"; + +export async function generateHTML( + request: Request, + fetchServer: (request: Request) => Promise<Response>, + bootstrapScriptContent: string | undefined +): Promise<Response> { + 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; + + return await renderHTMLToReadableStream( + <RSCStaticRouter getPayload={getPayload} />, + { + bootstrapScriptContent, + formState, + } + ); + }, + }); +} +``` + +#### `entry.rsc.tsx` + +The following is a simplified example of a Parcel RSC Server. + +```tsx filename=src/entry.rsc.tsx +import { createRequestListener } from "@mjackson/node-fetch-server"; +import express from "express"; +import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router"; +import { + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + renderToReadableStream, +} from "react-server-dom-parcel/server.edge"; + +// Import the generateHTML function from the react-client environment +import { generateHTML } from "./entry.ssr" with { env: "react-client" }; +import { routes } from "./routes/config"; + +function fetchServer(request: Request) { + return matchRSCServerRequest({ + // Provide the React Server touchpoints. + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + // The incoming request. + request, + // The app routes. + routes: routes(), + // Encode the match with the React Server implementation. + generateResponse(match) { + return new Response(renderToReadableStream(match.payload), { + status: match.statusCode, + headers: match.headers, + }); + }, + }); +} + +const app = express(); + +// Serve static assets with compression and long cache lifetime. +app.use( + "/client", + compression(), + express.static("dist/client", { + immutable: true, + maxAge: "1y", + }) +); +// Hook up our application. +app.use( + createRequestListener((request) => + generateHTML( + request, + fetchServer, + (routes as unknown as { bootstrapScript?: string }).bootstrapScript + ) + ) +); + +app.listen(3000, () => { + console.log("Server listening on port 3000"); +}); +``` + +#### `entry.browser.tsx` + +```tsx filename=src/entry.browser.tsx +"use client-entry"; + +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 unstable_RSCPayload as RSCServerPayload, +} from "react-router"; +import { + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, +} from "react-server-dom-parcel/client"; + +// 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: RSCServerPayload) => { + startTransition(async () => { + const formState = + payload.type === "render" + ? await payload.formState + : undefined; + + hydrateRoot( + document, + <StrictMode> + <RSCHydratedRouter + createFromReadableStream={ + createFromReadableStream + } + payload={payload} + /> + </StrictMode>, + { + formState, + } + ); + }); + } +); +``` + +### Vite + +See the [Vite RSC docs][vite-rsc-doc] for more information. You can also refer to our [Vite RSC template][vite-rsc-template] to see a working version. + +In addition to `react`, `react-dom`, and `react-router`, you'll need the following dependencies: + +```shellscript +npm i -D vite @vitejs/plugin-react @vitejs/plugin-rsc +``` + +#### `vite.config.ts` + +To configure Vite, add the following to your `vite.config.ts`: + +```ts filename=vite.config.ts +import rsc from "@vitejs/plugin-rsc/plugin"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + react(), + rsc({ + entries: { + client: "src/entry.browser.tsx", + rsc: "src/entry.rsc.tsx", + ssr: "src/entry.ssr.tsx", + }, + }), + ], +}); +``` + +```tsx filename=src/routes/config.ts +import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + +export function routes() { + return [ + { + id: "root", + path: "", + lazy: () => import("./root/route"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./home/route"), + }, + { + id: "about", + path: "about", + lazy: () => import("./about/route"), + }, + ], + }, + ] satisfies RSCRouteConfig; +} +``` + +#### `entry.ssr.tsx` + +The following is a simplified example of a Vite SSR Server. + +```tsx filename=src/entry.ssr.tsx +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<Response> +): Promise<Response> { + 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( + <RSCStaticRouter getPayload={getPayload} />, + { + bootstrapScriptContent, + formState, + } + ); + }, + }); +} +``` + +#### `entry.rsc.tsx` + +The following is a simplified example of a Vite RSC Server. + +```tsx filename=src/entry.rsc.tsx +import { + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + renderToReadableStream, +} from "@vitejs/plugin-rsc/rsc"; +import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router"; + +import { routes } from "./routes/config"; + +function fetchServer(request: Request) { + return matchRSCServerRequest({ + // Provide the React Server touchpoints. + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + // The incoming request. + request, + // The app routes. + routes: routes(), + // Encode the match with the React Server implementation. + generateResponse(match) { + return new Response( + renderToReadableStream(match.payload), + { + status: match.statusCode, + headers: match.headers, + } + ); + }, + }); +} + +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); +} +``` + +#### `entry.browser.tsx` + +```tsx filename=src/entry.browser.tsx +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 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<RSCServerPayload>( + getRSCStream() +).then((payload) => { + startTransition(async () => { + const formState = + payload.type === "render" + ? await payload.formState + : undefined; + + hydrateRoot( + document, + <StrictMode> + <RSCHydratedRouter + createFromReadableStream={ + createFromReadableStream + } + payload={payload} + /> + </StrictMode>, + { + formState, + } + ); + }); +}); +``` + +[react-server-components-doc]: https://react.dev/reference/rsc/server-components +[react-server-functions-doc]: https://react.dev/reference/rsc/server-functions +[use-client-docs]: https://react.dev/reference/rsc/use-client +[use-server-docs]: https://react.dev/reference/rsc/use-server +[route-module]: ../start/framework/route-module +[framework-mode]: ../start/framework/route-module +[custom-framework]: ../start/data/custom +[parcel-rsc-doc]: https://parceljs.org/recipes/rsc/ +[vite-rsc-doc]: https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc +[match-rsc-server-request]: ../api/rsc/matchRSCServerRequest +[route-rsc-server-request]: ../api/rsc/routeRSCServerRequest +[rsc-static-router]: ../api/rsc/RSCStaticRouter +[create-call-server]: ../api/rsc/createCallServer +[get-rsc-stream]: ../api/rsc/getRSCStream +[rsc-hydrated-router]: ../api/rsc/RSCHydratedRouter +[express]: https://expressjs.com/ +[node-fetch-server]: https://github.com/mjackson/remix-the-web/tree/main/packages/node-fetch-server +[parcel-rsc-template]: (https://github.com/remix-run/react-router-templates/tree/main/unstable_rsc-parcel) +[vite-rsc-template]: (https://github.com/remix-run/react-router-templates/tree/main/unstable_rsc-vite) diff --git a/docs/how-to/resource-routes.md b/docs/how-to/resource-routes.md index 78924fdd27..d3ccb1a52f 100644 --- a/docs/how-to/resource-routes.md +++ b/docs/how-to/resource-routes.md @@ -4,6 +4,11 @@ title: Resource Routes # Resource Routes +[MODES: framework, data] + +<br/> +<br/> + When server rendering, routes can serve "resources" instead of rendering components, like images, PDFs, JSON payloads, webhooks, etc. ## Defining a Resource Route diff --git a/docs/how-to/route-module-type-safety.md b/docs/how-to/route-module-type-safety.md index 9c6648a59b..06a3a4a3a3 100644 --- a/docs/how-to/route-module-type-safety.md +++ b/docs/how-to/route-module-type-safety.md @@ -4,6 +4,11 @@ title: Route Module Type Safety # Route Module Type Safety +[MODES: framework] + +<br/> +<br/> + React Router generates route-specific types to power type inference for URL params, loader data, and more. This guide will help you set it up if you didn't start with a template. diff --git a/docs/how-to/security.md b/docs/how-to/security.md index c4d3cd56ad..9ac29e4a2d 100644 --- a/docs/how-to/security.md +++ b/docs/how-to/security.md @@ -4,6 +4,11 @@ title: Security # Security +[MODES: framework] + +<br/> +<br/> + This is by no means a comprehensive guide, but React Router provides features to help address a few aspects under the _very large_ umbrella that is _Security_. ## `Content-Security-Policy` diff --git a/docs/how-to/server-bundles.md b/docs/how-to/server-bundles.md new file mode 100644 index 0000000000..2ca151fe25 --- /dev/null +++ b/docs/how-to/server-bundles.md @@ -0,0 +1,66 @@ +--- +title: Server Bundles +--- + +# Server Bundles + +[MODES: framework] + +<br/> +<br/> + +<docs-warning>This is an advanced feature designed for hosting provider integrations. When compiling your app into multiple server bundles, there will need to be a custom routing layer in front of your app directing requests to the correct bundle.</docs-warning> + +React Router typically builds your server code into a single bundle that exports a request handler function. However, there are scenarios where you might want to split your route tree into multiple server bundles, each exposing a request handler function for a subset of routes. To provide this flexibility, [`react-router.config.ts`][react-router-config] supports a `serverBundles` option, which is a function for assigning routes to different server bundles. + +The [`serverBundles` function][server-bundles-function] is called for each route in the tree (except for routes that aren't addressable, e.g., pathless layout routes) and returns a server bundle ID that you'd like to assign that route to. These bundle IDs will be used as directory names in your server build directory. + +For each route, this function receives an array of routes leading to and including that route, referred to as the route `branch`. This allows you to create server bundles for different portions of the route tree. For example, you could use this to create a separate server bundle containing all routes within a particular layout route: + +```ts filename=react-router.config.ts lines=[5-13] +import type { Config } from "@react-router/dev/config"; + +export default { + // ... + serverBundles: ({ branch }) => { + const isAuthenticatedRoute = branch.some((route) => + route.id.split("/").includes("_authenticated") + ); + + return isAuthenticatedRoute + ? "authenticated" + : "unauthenticated"; + }, +} satisfies Config; +``` + +Each `route` in the `branch` array contains the following properties: + +- `id` โ€” The unique ID for this route, named like its `file` but relative to the app directory and without the extension, e.g., `app/routes/gists.$username.tsx` will have an `id` of `routes/gists.$username` +- `path` โ€” The path this route uses to match the URL pathname +- `file` โ€” The absolute path to the entry point for this route +- `index` โ€” Whether this route is an index route + +## Build manifest + +When the build is complete, React Router will call the `buildEnd` hook, passing a `buildManifest` object. This is useful if you need to inspect the build manifest to determine how to route requests to the correct server bundle. + +```ts filename=react-router.config.ts lines=[5-7] +import type { Config } from "@react-router/dev/config"; + +export default { + // ... + buildEnd: async ({ buildManifest }) => { + // ... + }, +} satisfies Config; +``` + +When using server bundles, the build manifest contains the following properties: + +- `serverBundles` โ€” An object that maps bundle IDs to the bundle's `id` and `file` +- `routeIdToServerBundleId` โ€” An object that maps route IDs to their server bundle ID +- `routes` โ€” A route manifest that maps route IDs to route metadata. This can be used to drive a custom routing layer in front of your React Router request handlers + +[react-router-config]: https://api.reactrouter.com/v7/types/_react_router_dev.config.Config.html +[server-bundles-function]: https://api.reactrouter.com/v7/types/_react_router_dev.config.ServerBundlesFunction.html diff --git a/docs/how-to/spa.md b/docs/how-to/spa.md index a480ef4334..03a5af2857 100644 --- a/docs/how-to/spa.md +++ b/docs/how-to/spa.md @@ -6,9 +6,10 @@ title: Single Page App (SPA) [MODES: framework] -This guides focuses on how to build Single Page Apps with React Router Framework mode. If you're using React Router as a library, you can use it to build your own SPA architecture. +<br/> +<br/> -## Overview +<docs-info>This guide focuses on how to build Single Page Apps with React Router Framework mode. If you're using React Router in declarative or data mode, you can design your own SPA architecture.</docs-info> When using React Router as a framework, you can enable "SPA Mode" by setting `ssr:false` in your `react-router.config.ts` file. This will disable runtime server rendering and generate an `index.html` at build time that you can serve and hydrate as a SPA. diff --git a/docs/how-to/status.md b/docs/how-to/status.md index 23a21423a1..19ba7333dc 100644 --- a/docs/how-to/status.md +++ b/docs/how-to/status.md @@ -4,6 +4,11 @@ title: Status Codes # Status Codes +[MODES: framework ,data] + +<br/> +<br/> + Set status codes from loaders and actions with `data`. ```tsx filename=app/project.tsx lines=[3,12-15,20,23] diff --git a/docs/how-to/suspense.md b/docs/how-to/suspense.md index d3bcd1443f..af840e8054 100644 --- a/docs/how-to/suspense.md +++ b/docs/how-to/suspense.md @@ -4,6 +4,11 @@ title: Streaming with Suspense # Streaming with Suspense +[MODES: framework, data] + +<br/> +<br/> + Streaming with React Suspense allows apps to speed up initial renders by deferring non-critical data and unblocking UI rendering. React Router supports React Suspense by returning promises from loaders and actions. diff --git a/docs/how-to/view-transitions.md b/docs/how-to/view-transitions.md index 9b74a81647..d5ace0ab31 100644 --- a/docs/how-to/view-transitions.md +++ b/docs/how-to/view-transitions.md @@ -4,6 +4,11 @@ title: View Transitions # View Transitions +[MODES: framework, data] + +<br/> +<br/> + Enable smooth animations between page transitions in your React Router applications using the [View Transitions API][view-transitions-api]. This feature allows you to create seamless visual transitions during client-side navigation. ## Basic View Transition diff --git a/docs/start/framework/route-module.md b/docs/start/framework/route-module.md index 7547eb368c..085c05120c 100644 --- a/docs/start/framework/route-module.md +++ b/docs/start/framework/route-module.md @@ -372,6 +372,7 @@ The meta of the last matching route is used, allowing you to override parent rou **See also** - [`meta` params][meta-params] +- [`meta` function return types][meta-function] ## `shouldRevalidate` @@ -409,4 +410,5 @@ Next: [Rendering Strategies](./rendering) [link-element]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/link [meta-element]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta [meta-params]: https://api.reactrouter.com/v7/interfaces/react_router.MetaArgs +[meta-function]: https://api.reactrouter.com/v7/types/react_router.MetaDescriptor.html [use-revalidator]: https://api.reactrouter.com/v7/functions/react_router.useRevalidator.html diff --git a/docs/start/modes.md b/docs/start/modes.md index c16bf47043..055f49ebff 100644 --- a/docs/start/modes.md +++ b/docs/start/modes.md @@ -15,7 +15,7 @@ The features available in each mode are additive, so moving from Declarative to The mode depends on which "top level" router API you're using: -**Declarative** +## Declarative Declarative mode enables basic routing features like matching URLs to components, navigating around the app, and providing active states with APIs like `<Link>`, `useNavigate`, and `useLocation`. @@ -29,7 +29,7 @@ ReactDOM.createRoot(root).render( ); ``` -**Data** +## Data By moving route configuration outside of React rendering, Data Mode adds data loading, actions, pending states and more with APIs like `loader`, `action`, and `useFetcher`. @@ -52,7 +52,7 @@ ReactDOM.createRoot(root).render( ); ``` -**Framework** +## Framework Framework Mode wraps Data Mode with a Vite plugin to add the full React Router experience with: diff --git a/docs/tutorials/quickstart.md b/docs/tutorials/quickstart.md index 36e953d6e5..1305e5eb58 100644 --- a/docs/tutorials/quickstart.md +++ b/docs/tutorials/quickstart.md @@ -94,7 +94,7 @@ touch app/routes.js export default []; ``` -The existence of `routes.js` is required to build a React Router app; if you're using React Router we assume you'll want to do some routing eventually. You can read more about defining routes in our [Routing][routing] guide. +The existence of `routes.js` is required to build a React Router app; if you're using React Router, we assume you'll want to do some routing eventually. You can read more about defining routes in our [Routing][routing] guide. ## Build and Run @@ -292,7 +292,6 @@ What's next? [inspect]: https://nodejs.org/en/docs/guides/debugging-getting-started/ [vite-config]: https://vite.dev/config [routing]: ../start/framework/routing -[templates]: /resources?category=templates [http-localhost-3000]: http://localhost:3000 [vite]: https://vitejs.dev [react-router-config]: https://api.reactrouter.com/v7/types/_react_router_dev.config.Config.html diff --git a/docs/upgrading/remix.md b/docs/upgrading/remix.md index 42a53e7842..432b3c1011 100644 --- a/docs/upgrading/remix.md +++ b/docs/upgrading/remix.md @@ -111,8 +111,7 @@ In React Router v7 you define your routes using the `app/routes.ts` file. View t **๐Ÿ‘‰ Update dependencies (if using Remix v2 `v3_routeConfig` flag)** -```diff -// app/routes.ts +```diff filename=app/routes.ts -import { type RouteConfig } from "@remix-run/route-config"; -import { flatRoutes } from "@remix-run/fs-routes"; -import { remixRoutesOptionAdapter } from "@remix-run/routes-option-adapter"; @@ -123,7 +122,6 @@ In React Router v7 you define your routes using the `app/routes.ts` file. View t export default [ // however your routes are defined ] satisfies RouteConfig; - ``` **๐Ÿ‘‰ Add a `routes.ts` file (if _not_ using Remix v2 `v3_routeConfig` flag)** @@ -132,51 +130,67 @@ export default [ touch app/routes.ts ``` -For backwards-compatibility and for folks who prefer [file-based conventions][fs-routing], you can opt-into the same "flat routes" convention you are using in Remix v2 via the new `@react-router/fs-routes` package: - -```ts filename=app/routes.ts -import { type RouteConfig } from "@react-router/dev/routes"; -import { flatRoutes } from "@react-router/fs-routes"; - -export default flatRoutes() satisfies RouteConfig; -``` - -Or, if you were using the `routes` option to define config-based routes: - -```ts filename=app/routes.ts -import { type RouteConfig } from "@react-router/dev/routes"; -import { remixRoutesOptionAdapter } from "@react-router/remix-routes-option-adapter"; - -export default remixRoutesOptionAdapter((defineRoutes) => { - return defineRoutes((route) => { - route("/", "home/route.tsx", { index: true }); - route("about", "about/route.tsx"); - route("", "concerts/layout.tsx", () => { - route("trending", "concerts/trending.tsx"); - route(":city", "concerts/city.tsx"); - }); - }); -}) satisfies RouteConfig; -``` - -If you were using the `routes` option in your `vite.config.ts`, be sure to remove it. - -```diff -export default defineConfig({ - plugins: [ - remix({ - ssr: true, -- ignoredRouteFiles: ['**/*'], -- routes(defineRoutes) { -- return defineRoutes((route) => { -- route("/somewhere/cool/*", "catchall.tsx"); -- }); -- }, - }) - tsconfigPaths(), - ], -}); -``` +For backwards-compatibility, there are a few ways to adopt `routes.ts` to align with your route setup in Remix v2: + +1. If you were using the "flat routes" [file-based convention][fs-routing], you can continue to use that via the new `@react-router/fs-routes` package: + + ```ts filename=app/routes.ts + import { type RouteConfig } from "@react-router/dev/routes"; + import { flatRoutes } from "@react-router/fs-routes"; + + export default flatRoutes() satisfies RouteConfig; + ``` + +2. If you were using the "nested" convention from Remix v1 via the `@remix-run/v1-route-convention` package, you can continue using that as well in conjunction with `@react-router/remix-routes-option-adapter`: + + ```ts filename=app/routes.ts + import { type RouteConfig } from "@react-router/dev/routes"; + import { remixRoutesOptionAdapter } from "@react-router/remix-routes-option-adapter"; + import { createRoutesFromFolders } from "@remix-run/v1-route-convention"; + + export default remixRoutesOptionAdapter( + createRoutesFromFolders + ) satisfies RouteConfig; + ``` + +3. If you were using the `routes` option to define config-based routes, you can keep that config via `@react-router/remix-routes-option-adapter`: + + ```ts filename=app/routes.ts + import { type RouteConfig } from "@react-router/dev/routes"; + import { remixRoutesOptionAdapter } from "@react-router/remix-routes-option-adapter"; + + export default remixRoutesOptionAdapter( + (defineRoutes) => { + return defineRoutes((route) => { + route("/", "home/route.tsx", { index: true }); + route("about", "about/route.tsx"); + route("", "concerts/layout.tsx", () => { + route("trending", "concerts/trending.tsx"); + route(":city", "concerts/city.tsx"); + }); + }); + } + ) satisfies RouteConfig; + ``` + + - Be sure to also remove the `routes` option in your `vite.config.ts`: + + ```diff filename=vite.config.ts + export default defineConfig({ + plugins: [ + remix({ + ssr: true, + - ignoredRouteFiles: ['**/*'], + - routes(defineRoutes) { + - return defineRoutes((route) => { + - route("/somewhere/cool/*", "catchall.tsx"); + - }); + - }, + }) + tsconfigPaths(), + ], + }); + ``` ## 5. Add a React Router config @@ -190,8 +204,7 @@ Note: At this point you should remove the v3 future flags you added in step 1. touch react-router.config.ts ``` -```diff -// vite.config.ts +```diff filename=vite.config.ts export default defineConfig({ plugins: [ - remix({ @@ -202,8 +215,9 @@ export default defineConfig({ tsconfigPaths(), ], }); +``` -// react-router.config.ts +```diff filename=react-router.config.ts +import type { Config } from "@react-router/dev/config"; +export default { + ssr: true, @@ -222,7 +236,7 @@ If you used the codemod you can skip this step as it was automatically completed Change `vite.config.ts` to import and use the new `reactRouter` plugin from `@react-router/dev/vite`: -```diff +```diff filename=vite.config.ts -import { vitePlugin as remix } from "@remix-run/dev"; +import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; @@ -261,7 +275,7 @@ Update the `types` field in your `tsconfig.json` to include: - The appropriate `@react-router/*` package in the `types` field - `rootDirs` for simplified relative imports -```diff +```diff filename=tsconfig.json { "include": [ /* ... */ diff --git a/integration/action-test.ts b/integration/action-test.ts index c08e6d4bdb..447dc1ad83 100644 --- a/integration/action-test.ts +++ b/integration/action-test.ts @@ -7,206 +7,226 @@ import { } from "./helpers/create-fixture.js"; import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture, selectHtml } from "./helpers/playwright-fixture.js"; +import type { TemplateName } from "./helpers/vite.js"; -test.describe("actions", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - let FIELD_NAME = "message"; - let WAITING_VALUE = "Waiting..."; - let SUBMITTED_VALUE = "Submission"; - let THROWS_REDIRECT = "redirect-throw"; - let REDIRECT_TARGET = "page"; - let PAGE_TEXT = "PAGE_TEXT"; - - test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/urlencoded.tsx": js` - import { Form, useActionData } from "react-router"; - - export let action = async ({ request }) => { - let formData = await request.formData(); - return formData.get("${FIELD_NAME}"); - }; - - export default function Actions() { - let data = useActionData() - - return ( - <Form method="post" id="form"> - <p id="text"> - {data ? <span id="action-text">{data}</span> : "${WAITING_VALUE}"} - </p> - <p> - <input type="text" defaultValue="${SUBMITTED_VALUE}" name="${FIELD_NAME}" /> - <button type="submit" id="submit">Go</button> - </p> - </Form> - ); - } - `, - - "app/routes/request-text.tsx": js` - import { Form, useActionData } from "react-router"; - - export let action = async ({ request }) => { - let text = await request.text(); - return text; - }; - - export default function Actions() { - let data = useActionData() - - return ( - <Form method="post" id="form"> - <p id="text"> - {data ? <span id="action-text">{data}</span> : "${WAITING_VALUE}"} - </p> - <p> - <input name="a" defaultValue="1" /> - <input name="b" defaultValue="2" /> - <button type="submit" id="submit">Go</button> - </p> - </Form> - ); - } - `, - - [`app/routes/${THROWS_REDIRECT}.jsx`]: js` - import { redirect, Form } from "react-router"; - - export function action() { - throw redirect("/${REDIRECT_TARGET}") - } - - export default function () { - return ( - <Form method="post"> - <button type="submit">Go</button> - </Form> - ) - } - `, - - [`app/routes/${REDIRECT_TARGET}.jsx`]: js` - export default function () { - return <div id="${REDIRECT_TARGET}">${PAGE_TEXT}</div> - } - `, - - "app/routes/no-action.tsx": js` - import { Form } from "react-router"; - - export default function Component() { - return ( - <Form method="post"> - <button type="submit">Submit without action</button> - </Form> - ); - } - `, - }, - }); - - appFixture = await createAppFixture(fixture); - }); +const templateNames = [ + "vite-5-template", + "rsc-parcel-framework", +] as const satisfies TemplateName[]; - test.afterAll(() => { - appFixture.close(); - }); - - let logs: string[] = []; - - test.beforeEach(({ page }) => { - page.on("console", (msg) => { - logs.push(msg.text()); +test.describe("actions", () => { + for (const templateName of templateNames) { + test.describe(`template: ${templateName}`, () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let FIELD_NAME = "message"; + let WAITING_VALUE = "Waiting..."; + let SUBMITTED_VALUE = "Submission"; + let THROWS_REDIRECT = "redirect-throw"; + let REDIRECT_TARGET = "page"; + let PAGE_TEXT = "PAGE_TEXT"; + + test.beforeAll(async () => { + fixture = await createFixture({ + templateName, + files: { + "app/routes/urlencoded.tsx": js` + import { Form, useActionData } from "react-router"; + + export let action = async ({ request }) => { + let formData = await request.formData(); + return formData.get("${FIELD_NAME}"); + }; + + export default function Actions() { + let data = useActionData() + + return ( + <Form method="post" id="form"> + <p id="text"> + {data ? <span id="action-text">{data}</span> : "${WAITING_VALUE}"} + </p> + <p> + <input type="text" defaultValue="${SUBMITTED_VALUE}" name="${FIELD_NAME}" /> + <button type="submit" id="submit">Go</button> + </p> + </Form> + ); + } + `, + + "app/routes/request-text.tsx": js` + import { Form, useActionData } from "react-router"; + + export let action = async ({ request }) => { + let text = await request.text(); + return text; + }; + + export default function Actions() { + let data = useActionData() + + return ( + <Form method="post" id="form"> + <p id="text"> + {data ? <span id="action-text">{data}</span> : "${WAITING_VALUE}"} + </p> + <p> + <input name="a" defaultValue="1" /> + <input name="b" defaultValue="2" /> + <button type="submit" id="submit">Go</button> + </p> + </Form> + ); + } + `, + + [`app/routes/${THROWS_REDIRECT}.jsx`]: js` + import { redirect, Form } from "react-router"; + + export function action() { + throw redirect("/${REDIRECT_TARGET}") + } + + export default function () { + return ( + <Form method="post"> + <button type="submit">Go</button> + </Form> + ) + } + `, + + [`app/routes/${REDIRECT_TARGET}.jsx`]: js` + export default function () { + return <div id="${REDIRECT_TARGET}">${PAGE_TEXT}</div> + } + `, + + "app/routes/no-action.tsx": js` + import { Form } from "react-router"; + + export default function Component() { + return ( + <Form method="post"> + <button type="submit">Submit without action</button> + </Form> + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + let logs: string[] = []; + + test.beforeEach(({ page }) => { + page.on("console", (msg) => { + logs.push(msg.text()); + }); + }); + + test.afterEach(() => { + expect(logs).toHaveLength(0); + }); + + test("is not called on document GET requests", async () => { + let res = await fixture.requestDocument("/urlencoded"); + let html = selectHtml(await res.text(), "#text"); + expect(html).toMatch(WAITING_VALUE); + }); + + test("is called on document POST requests", async () => { + let FIELD_VALUE = "cheeseburger"; + + let params = new URLSearchParams(); + params.append(FIELD_NAME, FIELD_VALUE); + + let res = await fixture.postDocument("/urlencoded", params); + + let html = selectHtml(await res.text(), "#text"); + expect(html).toMatch(FIELD_VALUE); + }); + + test("is called on script transition POST requests", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/urlencoded`); + await page.waitForSelector(`#text:has-text("${WAITING_VALUE}")`); + + await page.click("button[type=submit]"); + await page.waitForSelector("#action-text"); + await page.waitForSelector(`#text:has-text("${SUBMITTED_VALUE}")`); + }); + + test("throws a 405 when no action exists", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/no-action`); + await page.click("button[type=submit]"); + await page.waitForSelector(`h1:has-text("405 Method Not Allowed")`); + expect(logs.length).toBe(2); + expect(logs[0]).toMatch( + 'Route "routes/no-action" does not have an action' + ); + // logs[1] is the raw ErrorResponse instance from the boundary but playwright + // seems to just log the name of the constructor, which in the minified code + // is meaningless so we don't bother asserting + + // The rest of the tests in this suite assert no logs, so clear this out to + // avoid failures in afterEach + logs = []; + }); + + test("properly encodes form data for request.text() usage", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/request-text`); + await page.waitForSelector(`#text:has-text("${WAITING_VALUE}")`); + + await page.click("button[type=submit]"); + await page.waitForSelector("#action-text"); + expect(await app.getHtml("#action-text")).toBe( + '<span id="action-text">a=1&b=2</span>' + ); + }); + + test("redirects a thrown response on document requests", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(`/${THROWS_REDIRECT}`, params); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe(`/${REDIRECT_TARGET}`); + }); + + test("redirects a thrown response on script transitions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/${THROWS_REDIRECT}`); + let responses = app.collectSingleFetchResponses(); + await app.clickSubmitButton(`/${THROWS_REDIRECT}`); + + await page.waitForSelector(`#${REDIRECT_TARGET}`); + + // In RSC, every route implicitly has a loader, so we get an extra + // response for the page we've redirected to. To keep the rest of the + // test RSC-agnostic, we drop the last response. + if (templateName.includes("rsc")) { + responses = responses.slice(0, -1); + } + + expect(responses).toHaveLength(1); + expect(responses[0].status()).toBe(202); + + expect(new URL(page.url()).pathname).toBe(`/${REDIRECT_TARGET}`); + expect(await app.getHtml()).toMatch(PAGE_TEXT); + }); }); - }); - - test.afterEach(() => { - expect(logs).toHaveLength(0); - }); - - test("is not called on document GET requests", async () => { - let res = await fixture.requestDocument("/urlencoded"); - let html = selectHtml(await res.text(), "#text"); - expect(html).toMatch(WAITING_VALUE); - }); - - test("is called on document POST requests", async () => { - let FIELD_VALUE = "cheeseburger"; - - let params = new URLSearchParams(); - params.append(FIELD_NAME, FIELD_VALUE); - - let res = await fixture.postDocument("/urlencoded", params); - - let html = selectHtml(await res.text(), "#text"); - expect(html).toMatch(FIELD_VALUE); - }); - - test("is called on script transition POST requests", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/urlencoded`); - await page.waitForSelector(`#text:has-text("${WAITING_VALUE}")`); - - await page.click("button[type=submit]"); - await page.waitForSelector("#action-text"); - await page.waitForSelector(`#text:has-text("${SUBMITTED_VALUE}")`); - }); - - test("throws a 405 when no action exists", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/no-action`); - await page.click("button[type=submit]"); - await page.waitForSelector(`h1:has-text("405 Method Not Allowed")`); - expect(logs.length).toBe(2); - expect(logs[0]).toMatch('Route "routes/no-action" does not have an action'); - // logs[1] is the raw ErrorResponse instance from the boundary but playwright - // seems to just log the name of the constructor, which in the minified code - // is meaningless so we don't bother asserting - - // The rest of the tests in this suite assert no logs, so clear this out to - // avoid failures in afterEach - logs = []; - }); - - test("properly encodes form data for request.text() usage", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/request-text`); - await page.waitForSelector(`#text:has-text("${WAITING_VALUE}")`); - - await page.click("button[type=submit]"); - await page.waitForSelector("#action-text"); - expect(await app.getHtml("#action-text")).toBe( - '<span id="action-text">a=1&b=2</span>' - ); - }); - - test("redirects a thrown response on document requests", async () => { - let params = new URLSearchParams(); - let res = await fixture.postDocument(`/${THROWS_REDIRECT}`, params); - expect(res.status).toBe(302); - expect(res.headers.get("Location")).toBe(`/${REDIRECT_TARGET}`); - }); - - test("redirects a thrown response on script transitions", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/${THROWS_REDIRECT}`); - let responses = app.collectSingleFetchResponses(); - await app.clickSubmitButton(`/${THROWS_REDIRECT}`); - - await page.waitForSelector(`#${REDIRECT_TARGET}`); - - expect(responses.length).toBe(1); - expect(responses[0].status()).toBe(202); - - expect(new URL(page.url()).pathname).toBe(`/${REDIRECT_TARGET}`); - expect(await app.getHtml()).toMatch(PAGE_TEXT); - }); + } }); diff --git a/integration/catch-boundary-data-test.ts b/integration/catch-boundary-data-test.ts index 7f6c89284e..89d9983549 100644 --- a/integration/catch-boundary-data-test.ts +++ b/integration/catch-boundary-data-test.ts @@ -7,9 +7,12 @@ import { } from "./helpers/create-fixture.js"; import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import type { TemplateName } from "./helpers/vite.js"; -let fixture: Fixture; -let appFixture: AppFixture; +const templateNames = [ + "vite-5-template", + "rsc-parcel-framework", +] as const satisfies TemplateName[]; let ROOT_BOUNDARY_TEXT = "ROOT_TEXT" as const; let LAYOUT_BOUNDARY_TEXT = "LAYOUT_BOUNDARY_TEXT" as const; @@ -29,215 +32,227 @@ let ROOT_DATA = "root data"; let LAYOUT_DATA = "root data"; test.describe("ErrorBoundary (thrown responses)", () => { - test.beforeEach(async ({ context }) => { - await context.route(/.data/, async (route) => { - await new Promise((resolve) => setTimeout(resolve, 50)); - route.continue(); - }); - }); - - test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/root.tsx": js` - import { - Links, - Meta, - Outlet, - Scripts, - useLoaderData, - useMatches, - } from "react-router"; - - export const loader = () => "${ROOT_DATA}"; - - export default function Root() { - const data = useLoaderData(); - - return ( - <html lang="en"> - <head> - <Meta /> - <Links /> - </head> - <body> - <div id="root-data">{data}</div> - <Outlet /> - <Scripts /> - </body> - </html> - ); - } - - export function ErrorBoundary() { - let matches = useMatches(); - let { data } = matches.find(match => match.id === "root"); - - return ( - <html> - <head /> - <body> - <div id="root-boundary">${ROOT_BOUNDARY_TEXT}</div> - <div id="root-boundary-data">{data}</div> - <Scripts /> - </body> - </html> - ); - } - `, - - "app/routes/_index.tsx": js` - import { Link } from "react-router"; - export default function Index() { - return ( - <div> - <Link to="${NO_BOUNDARY_LOADER}">${NO_BOUNDARY_LOADER}</Link> - <Link to="${HAS_BOUNDARY_LAYOUT_NESTED_LOADER}">${HAS_BOUNDARY_LAYOUT_NESTED_LOADER}</Link> - <Link to="${HAS_BOUNDARY_NESTED_LOADER}">${HAS_BOUNDARY_NESTED_LOADER}</Link> - </div> - ); - } - `, - - [`app/routes${NO_BOUNDARY_LOADER_FILE}.jsx`]: js` - export function loader() { - throw new Response("", { status: 401 }); - } - export default function Index() { - return <div/>; - } - `, - - [`app/routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}.jsx`]: js` - import { useMatches } from "react-router"; - export function loader() { - return "${LAYOUT_DATA}"; - } - export default function Layout() { - return <div/>; - } - export function ErrorBoundary() { - let matches = useMatches(); - let { data } = matches.find(match => match.id === "routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}"); - - return ( - <div> - <div id="layout-boundary">${LAYOUT_BOUNDARY_TEXT}</div> - <div id="layout-boundary-data">{data}</div> - </div> - ); - } - `, - - [`app/routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}._index.jsx`]: js` - export function loader() { - throw new Response("", { status: 401 }); - } - export default function Index() { - return <div/>; - } - `, - - [`app/routes${HAS_BOUNDARY_NESTED_LOADER_FILE}.jsx`]: js` - import { Outlet, useLoaderData } from "react-router"; - export function loader() { - return "${LAYOUT_DATA}"; - } - export default function Layout() { - let data = useLoaderData(); - return ( - <div> - <div id="layout-data">{data}</div> - <Outlet/> - </div> - ); - } - `, - - [`app/routes${HAS_BOUNDARY_NESTED_LOADER_FILE}._index.jsx`]: js` - export function loader() { - throw new Response("", { status: 401 }); - } - export default function Index() { - return <div/>; - } - export function ErrorBoundary() { - return ( - <div id="own-boundary">${OWN_BOUNDARY_TEXT}</div> - ); - } - `, - }, - }); + for (const templateName of templateNames) { + let fixture: Fixture; + let appFixture: AppFixture; + + test.describe(`template: ${templateName}`, () => { + test.beforeEach(async ({ context }) => { + await context.route(/.(data|rsc)/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 50)); + route.continue(); + }); + }); + + test.beforeAll(async () => { + fixture = await createFixture({ + templateName, + files: { + "app/root.tsx": js` + import { + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + useMatches, + } from "react-router"; + + export const loader = () => "${ROOT_DATA}"; + + export default function Root() { + const data = useLoaderData(); + + return ( + <html lang="en"> + <head> + <Meta /> + <Links /> + </head> + <body> + <div id="root-data">{data}</div> + <Outlet /> + <Scripts /> + </body> + </html> + ); + } + + export function ErrorBoundary() { + let matches = useMatches(); + let { data } = matches.find(match => match.id === "root"); + + return ( + <html> + <head /> + <body> + <div id="root-boundary">${ROOT_BOUNDARY_TEXT}</div> + <div id="root-boundary-data">{data}</div> + <Scripts /> + </body> + </html> + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + export default function Index() { + return ( + <div> + <Link to="${NO_BOUNDARY_LOADER}">${NO_BOUNDARY_LOADER}</Link> + <Link to="${HAS_BOUNDARY_LAYOUT_NESTED_LOADER}">${HAS_BOUNDARY_LAYOUT_NESTED_LOADER}</Link> + <Link to="${HAS_BOUNDARY_NESTED_LOADER}">${HAS_BOUNDARY_NESTED_LOADER}</Link> + </div> + ); + } + `, + + [`app/routes${NO_BOUNDARY_LOADER_FILE}.jsx`]: js` + export function loader() { + throw new Response("", { status: 401 }); + } + export default function Index() { + return <div/>; + } + `, + + [`app/routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}.jsx`]: js` + import { useMatches } from "react-router"; + export function loader() { + return "${LAYOUT_DATA}"; + } + export default function Layout() { + return <div/>; + } + export function ErrorBoundary() { + let matches = useMatches(); + let { data } = matches.find(match => match.id === "routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}"); - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("renders root boundary with data available", async () => { - let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); - expect(res.status).toBe(401); - let html = await res.text(); - expect(html).toMatch(ROOT_BOUNDARY_TEXT); - expect(html).toMatch(ROOT_DATA); - }); - - test("renders root boundary with data available on transition", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(NO_BOUNDARY_LOADER); - await page.waitForSelector("#root-boundary"); - await page.waitForSelector(`#root-boundary-data:has-text("${ROOT_DATA}")`); - }); - - test("renders layout boundary with data available", async () => { - let res = await fixture.requestDocument(HAS_BOUNDARY_LAYOUT_NESTED_LOADER); - expect(res.status).toBe(401); - let html = await res.text(); - expect(html).toMatch(ROOT_DATA); - expect(html).toMatch(LAYOUT_BOUNDARY_TEXT); - expect(html).toMatch(LAYOUT_DATA); - }); - - test("renders layout boundary with data available on transition", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(HAS_BOUNDARY_LAYOUT_NESTED_LOADER); - await page.waitForSelector(`#root-data:has-text("${ROOT_DATA}")`); - await page.waitForSelector( - `#layout-boundary:has-text("${LAYOUT_BOUNDARY_TEXT}")` - ); - await page.waitForSelector( - `#layout-boundary-data:has-text("${LAYOUT_DATA}")` - ); - }); - - test("renders self boundary with layout data available", async () => { - let res = await fixture.requestDocument(HAS_BOUNDARY_NESTED_LOADER); - expect(res.status).toBe(401); - let html = await res.text(); - expect(html).toMatch(ROOT_DATA); - expect(html).toMatch(LAYOUT_DATA); - expect(html).toMatch(OWN_BOUNDARY_TEXT); - }); - - test("renders self boundary with layout data available on transition", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink(HAS_BOUNDARY_NESTED_LOADER); - await page.waitForSelector(`#root-data:has-text("${ROOT_DATA}")`); - await page.waitForSelector(`#layout-data:has-text("${LAYOUT_DATA}")`); - await page.waitForSelector( - `#own-boundary:has-text("${OWN_BOUNDARY_TEXT}")` - ); - }); + return ( + <div> + <div id="layout-boundary">${LAYOUT_BOUNDARY_TEXT}</div> + <div id="layout-boundary-data">{data}</div> + </div> + ); + } + `, + + [`app/routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}._index.jsx`]: js` + export function loader() { + throw new Response("", { status: 401 }); + } + export default function Index() { + return <div/>; + } + `, + + [`app/routes${HAS_BOUNDARY_NESTED_LOADER_FILE}.jsx`]: js` + import { Outlet, useLoaderData } from "react-router"; + export function loader() { + return "${LAYOUT_DATA}"; + } + export default function Layout() { + let data = useLoaderData(); + return ( + <div> + <div id="layout-data">{data}</div> + <Outlet/> + </div> + ); + } + `, + + [`app/routes${HAS_BOUNDARY_NESTED_LOADER_FILE}._index.jsx`]: js` + export function loader() { + throw new Response("", { status: 401 }); + } + export default function Index() { + return <div/>; + } + export function ErrorBoundary() { + return ( + <div id="own-boundary">${OWN_BOUNDARY_TEXT}</div> + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("renders root boundary with data available", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); + expect(res.status).toBe(401); + let html = await res.text(); + expect(html).toMatch(ROOT_BOUNDARY_TEXT); + expect(html).toMatch(ROOT_DATA); + }); + + test("renders root boundary with data available on transition", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_LOADER); + await page.waitForSelector("#root-boundary"); + await page.waitForSelector( + `#root-boundary-data:has-text("${ROOT_DATA}")` + ); + }); + + test("renders layout boundary with data available", async () => { + let res = await fixture.requestDocument( + HAS_BOUNDARY_LAYOUT_NESTED_LOADER + ); + expect(res.status).toBe(401); + let html = await res.text(); + expect(html).toMatch(ROOT_DATA); + expect(html).toMatch(LAYOUT_BOUNDARY_TEXT); + expect(html).toMatch(LAYOUT_DATA); + }); + + test("renders layout boundary with data available on transition", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_LAYOUT_NESTED_LOADER); + await page.waitForSelector(`#root-data:has-text("${ROOT_DATA}")`); + await page.waitForSelector( + `#layout-boundary:has-text("${LAYOUT_BOUNDARY_TEXT}")` + ); + await page.waitForSelector( + `#layout-boundary-data:has-text("${LAYOUT_DATA}")` + ); + }); + + test("renders self boundary with layout data available", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_NESTED_LOADER); + expect(res.status).toBe(401); + let html = await res.text(); + expect(html).toMatch(ROOT_DATA); + expect(html).toMatch(LAYOUT_DATA); + expect(html).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("renders self boundary with layout data available on transition", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_NESTED_LOADER); + await page.waitForSelector(`#root-data:has-text("${ROOT_DATA}")`); + await page.waitForSelector(`#layout-data:has-text("${LAYOUT_DATA}")`); + await page.waitForSelector( + `#own-boundary:has-text("${OWN_BOUNDARY_TEXT}")` + ); + }); + }); + } }); diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts index 3ea53cef5f..2317903687 100644 --- a/integration/client-data-test.ts +++ b/integration/client-data-test.ts @@ -8,7 +8,12 @@ import { } from "./helpers/create-fixture.js"; import type { AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; -import { reactRouterConfig } from "./helpers/vite.js"; +import { type TemplateName, reactRouterConfig } from "./helpers/vite.js"; + +const templateNames = [ + "vite-5-template", + "rsc-parcel-framework", +] as const satisfies TemplateName[]; function getFiles({ splitRouteModules, @@ -141,617 +146,212 @@ function getFiles({ } test.describe("Client Data", () => { - let appFixture: AppFixture; - - test.afterAll(() => { - appFixture.close(); - }); - - [true, false].forEach((splitRouteModules) => { - test.describe(`splitRouteModules: ${splitRouteModules}`, () => { - test.describe("clientLoader - critical route module", () => { - test("no client loaders or fallbacks", async ({ page }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - // Full SSR - normal Remix behavior due to lack of clientLoader - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - }); - - test("parent.clientLoader/child.clientLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - // Full SSR - normal Remix behavior due to lack of HydrateFallback components - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - }); - - test("parent.clientLoader.hydrate/child.clientLoader", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: true, - parentClientLoaderHydrate: true, - childClientLoader: true, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Fallback"); - expect(html).not.toMatch("Parent Server Loader"); - expect(html).not.toMatch("Child Server Loader"); - - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).not.toMatch("Parent Fallback"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader"); - }); - - test("parent.clientLoader/child.clientLoader.hydrate", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: true, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Fallback"); - expect(html).not.toMatch("Child Server Loader"); - - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).not.toMatch("Child Fallback"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - }); - - test("parent.clientLoader.hydrate/child.clientLoader.hydrate", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: true, - parentClientLoaderHydrate: true, - childClientLoader: true, - childClientLoaderHydrate: true, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Fallback"); - expect(html).not.toMatch("Parent Server Loader"); - expect(html).not.toMatch("Child Fallback"); - expect(html).not.toMatch("Child Server Loader"); - - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).not.toMatch("Parent Fallback"); - expect(html).not.toMatch("Child Fallback"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - }); - - test("handles synchronous client loaders", async ({ page }) => { - let fixture = await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - parentAdditions: js` - export function clientLoader() { - return { message: "Parent Client Loader" }; - } - clientLoader.hydrate=true - export function HydrateFallback() { - return <p>Parent Fallback</p> - } - `, - childAdditions: js` - export function clientLoader() { - return { message: "Child Client Loader" }; - } - clientLoader.hydrate=true - `, - }), - }); - - // Ensure we SSR the fallbacks - let doc = await fixture.requestDocument("/parent/child"); - let html = await doc.text(); - expect(html).toMatch("Parent Fallback"); - - appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Client Loader"); - expect(html).toMatch("Child Client Loader"); - }); - - test("handles deferred data through client loaders", async ({ - page, - }) => { - let fixture = await createFixture({ - files: { - ...getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { Await, useLoaderData } from "react-router" - export function loader() { - return { - message: 'Child Server Loader', - lazy: new Promise(r => setTimeout(() => r("Child Deferred Data"), 1000)), - }; - } - export async function clientLoader({ serverLoader }) { - let data = await serverLoader(); - return { - ...data, - message: data.message + " (mutated by client)", - }; - } - clientLoader.hydrate = true; - export function HydrateFallback() { - return <p>Child Fallback</p> - } - export default function Component() { - let data = useLoaderData(); - return ( - <> - <p id="child-data">{data.message}</p> - <React.Suspense fallback={<p>Loading Deferred Data...</p>}> - <Await resolve={data.lazy}> - {(value) => <p id="child-deferred-data">{value}</p>} - </Await> - </React.Suspense> - </> - ); - } - `, - }, - }); - - // Ensure initial document request contains the child fallback _and_ the - // subsequent streamed/resolved deferred data - let doc = await fixture.requestDocument("/parent/child"); - let html = await doc.text(); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Fallback"); - expect(html).toMatch("Child Deferred Data"); - - appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - await page.waitForSelector("#child-deferred-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - // app.goto() doesn't resolve until the document finishes loading so by - // then the HTML has updated via the streamed suspense updates - expect(html).toMatch("Child Server Loader (mutated by client)"); - expect(html).toMatch("Child Deferred Data"); - }); - - test("allows hydration execution without rendering a fallback", async ({ - page, - }) => { - let fixture = await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientLoader() { - await new Promise(r => setTimeout(r, 100)); - return { message: "Child Client Loader" }; - } - clientLoader.hydrate=true - `, - }), - }); - - appFixture = await createAppFixture(fixture); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Child Server Loader"); - await page.waitForSelector(':has-text("Child Client Loader")'); - html = await app.getHtml("main"); - expect(html).toMatch("Child Client Loader"); - }); - - test("HydrateFallback is not rendered if clientLoader.hydrate is not set (w/server loader)", async ({ - page, - }) => { - let fixture = await createFixture({ - files: { - ...getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { useLoaderData } from "react-router"; - export function loader() { - return { message: "Child Server Loader Data" }; - } - export async function clientLoader({ serverLoader }) { - await new Promise(r => setTimeout(r, 100)); - return { message: "Child Client Loader Data" }; - } - export function HydrateFallback() { - return <p>SHOULD NOT SEE ME</p> - } - export default function Component() { - let data = useLoaderData(); - return <p id="child-data">{data.message}</p>; - } - `, - }, - }); - appFixture = await createAppFixture(fixture); - - // Ensure initial document request contains the child fallback _and_ the - // subsequent streamed/resolved deferred data - let doc = await fixture.requestDocument("/parent/child"); - let html = await doc.text(); - expect(html).toMatch("Child Server Loader Data"); - expect(html).not.toMatch("SHOULD NOT SEE ME"); - - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Child Server Loader Data"); - }); + for (const templateName of templateNames) { + let appFixture: AppFixture; - test("clientLoader.hydrate is automatically implied when no server loader exists (w HydrateFallback)", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: { - ...getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { useLoaderData } from "react-router"; - // Even without setting hydrate=true, this should run on hydration - export async function clientLoader({ serverLoader }) { - await new Promise(r => setTimeout(r, 100)); - return { - message: "Loader Data (clientLoader only)", - }; - } - export function HydrateFallback() { - return <p>Child Fallback</p> - } - export default function Component() { - let data = useLoaderData(); - return <p id="child-data">{data.message}</p>; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Child Fallback"); - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Loader Data (clientLoader only)"); - }); + test.afterEach(async () => { + appFixture?.close(); + }); - test("clientLoader.hydrate is automatically implied when no server loader exists (w/o HydrateFallback)", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: { - ...getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { useLoaderData } from "react-router"; - // Even without setting hydrate=true, this should run on hydration - export async function clientLoader({ serverLoader }) { - await new Promise(r => setTimeout(r, 100)); - return { - message: "Loader Data (clientLoader only)", - }; - } - export default function Component() { - let data = useLoaderData(); - return <p id="child-data">{data.message}</p>; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - let html = await app.getHtml(); - expect(html).toMatch( - "๐Ÿ’ฟ Hey developer ๐Ÿ‘‹. You can provide a way better UX than this" - ); - expect(html).not.toMatch("child-data"); - await page.waitForSelector("#child-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Loader Data (clientLoader only)"); - }); + test.describe(`template: ${templateName}`, () => { + [true, false].forEach((splitRouteModules) => { + test.skip( + templateName === "rsc-parcel-framework" && splitRouteModules, + "RSC Data Mode doesn't support splitRouteModules" + ); - test("throws a 400 if you call serverLoader without a server loader", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: { - ...getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { useLoaderData, useRouteError } from "react-router"; - export async function clientLoader({ serverLoader }) { - return await serverLoader(); - } - export default function Component() { - return <p>Child</p>; - } - export function HydrateFallback() { - return <p>Loading...</p>; - } - export function ErrorBoundary() { - let error = useRouteError(); - return <p id="child-error">{error.status} {error.data}</p>; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - await page.waitForSelector("#child-error"); - let html = await app.getHtml("#child-error"); - expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( - "400 Error: You are trying to call serverLoader() on a route that does " + - 'not have a server loader (routeId: "routes/parent.child")' - ); - }); + test.skip( + ({ browserName }) => + Boolean(process.env.CI) && + splitRouteModules && + (browserName === "webkit" || process.platform === "win32"), + "Webkit/Windows tests only run on a single worker in CI and splitRouteModules is not OS/browser-specific" + ); - test("initial hydration data check functions properly", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: { - ...getFiles({ + test.describe(`splitRouteModules: ${splitRouteModules}`, () => { + test.describe("clientLoader - critical route module", () => { + test("no client loaders or fallbacks", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + // Full SSR - normal loader behavior due to lack of clientLoader + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + }); + + test("parent.clientLoader/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + // Full SSR - normal loader behavior due to lack of HydrateFallback components + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + }); + + test("parent.clientLoader.hydrate/child.clientLoader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: true, + parentClientLoaderHydrate: true, + childClientLoader: true, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Fallback"); + expect(html).not.toMatch("Parent Server Loader"); + expect(html).not.toMatch("Child Server Loader"); + + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).not.toMatch("Parent Fallback"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + }); + + test("parent.clientLoader/child.clientLoader.hydrate", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: true, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Fallback"); + expect(html).not.toMatch("Child Server Loader"); + + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).not.toMatch("Child Fallback"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + }); + + test("parent.clientLoader.hydrate/child.clientLoader.hydrate", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: true, + parentClientLoaderHydrate: true, + childClientLoader: true, + childClientLoaderHydrate: true, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Fallback"); + expect(html).not.toMatch("Parent Server Loader"); + expect(html).not.toMatch("Child Fallback"); + expect(html).not.toMatch("Child Server Loader"); + + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).not.toMatch("Parent Fallback"); + expect(html).not.toMatch("Child Fallback"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + }); + + test("handles synchronous client loaders", async ({ page }) => { + let fixture = await createFixture({ + templateName, + files: getFiles({ splitRouteModules, parentClientLoader: false, parentClientLoaderHydrate: false, childClientLoader: false, childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { useLoaderData, useRevalidator } from "react-router"; - let isFirstCall = true; - export async function loader({ serverLoader }) { - if (isFirstCall) { - isFirstCall = false - return { message: "Child Server Loader Data (1)" }; + parentAdditions: js` + export function clientLoader() { + return { message: "Parent Client Loader" }; } - return { message: "Child Server Loader Data (2+)" }; - } - export async function clientLoader({ serverLoader }) { - await new Promise(r => setTimeout(r, 100)); - let serverData = await serverLoader(); - return { - message: serverData.message + " (mutated by client)", - }; - } - clientLoader.hydrate=true; - export default function Component() { - let data = useLoaderData(); - let revalidator = useRevalidator(); - return ( - <> - <p id="child-data">{data.message}</p> - <button onClick={() => revalidator.revalidate()}>Revalidate</button> - </> - ); - } - export function HydrateFallback() { - return <p>Loading...</p> - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - await page.waitForSelector("#child-data"); - let html = await app.getHtml(); - expect(html).toMatch( - "Child Server Loader Data (1) (mutated by client)" - ); - app.clickElement("button"); - await page.waitForSelector( - ':has-text("Child Server Loader Data (2+)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch( - "Child Server Loader Data (2+) (mutated by client)" - ); - }); - - test("initial hydration data check functions properly even if serverLoader isn't called on hydration", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: { - ...getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { useLoaderData, useRevalidator } from "react-router"; - let isFirstCall = true; - export async function loader({ serverLoader }) { - if (isFirstCall) { - isFirstCall = false - return { message: "Child Server Loader Data (1)" }; + clientLoader.hydrate=true + export function HydrateFallback() { + return <p>Parent Fallback</p> } - return { message: "Child Server Loader Data (2+)" }; - } - let isFirstClientCall = true; - export async function clientLoader({ serverLoader }) { - await new Promise(r => setTimeout(r, 100)); - if (isFirstClientCall) { - isFirstClientCall = false; - // First time through - don't even call serverLoader - return { - message: "Child Client Loader Data", - }; + `, + childAdditions: js` + export function clientLoader() { + return { message: "Child Client Loader" }; } - // Only call the serverLoader on subsequent calls and this - // should *not* return us the initialData any longer - let serverData = await serverLoader(); - return { - message: serverData.message + " (mutated by client)", - }; - } - clientLoader.hydrate=true; - export default function Component() { - let data = useLoaderData(); - let revalidator = useRevalidator(); - return ( - <> - <p id="child-data">{data.message}</p> - <button onClick={() => revalidator.revalidate()}>Revalidate</button> - </> - ); - } - export function HydrateFallback() { - return <p>Loading...</p> - } + clientLoader.hydrate=true `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - await page.waitForSelector("#child-data"); - let html = await app.getHtml(); - expect(html).toMatch("Child Client Loader Data"); - app.clickElement("button"); - await page.waitForSelector( - ':has-text("Child Server Loader Data (2+)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch( - "Child Server Loader Data (2+) (mutated by client)" - ); - }); - - test("server loader errors are re-thrown from serverLoader()", async ({ - page, - }) => { - let _consoleError = console.error; - console.error = () => {}; - appFixture = await createAppFixture( - await createFixture( - { + }), + }); + + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + // Ensure we SSR the fallbacks + let response = await app.goto("/parent/child"); + let html = await response?.text(); + expect(html).toMatch("Parent Fallback"); + + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Client Loader"); + expect(html).toMatch("Child Client Loader"); + }); + + test("handles deferred data through client loaders", async ({ + page, + }) => { + let fixture = await createFixture({ + templateName, files: { ...getFiles({ splitRouteModules, @@ -761,427 +361,67 @@ test.describe("Client Data", () => { childClientLoaderHydrate: false, }), "app/routes/parent.child.tsx": js` - import { useRouteError } from "react-router"; - + import * as React from 'react'; + import { Await, useLoaderData } from "react-router" export function loader() { - throw new Error("Broken!") - } - - export async function clientLoader({ serverLoader }) { - return await serverLoader(); - } - clientLoader.hydrate = true; - - export default function Index() { - return <h1>Should not see me</h1>; - } - - export function ErrorBoundary() { - let error = useRouteError(); - return <p id="child-error">{error.message}</p>; - } - `, - }, - }, - ServerMode.Development // Avoid error sanitization - ), - ServerMode.Development // Avoid error sanitization - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Broken!"); - // Ensure we hydrate and remain on the boundary - await new Promise((r) => setTimeout(r, 100)); - html = await app.getHtml("main"); - expect(html).toMatch("Broken!"); - expect(html).not.toMatch("Should not see me"); - console.error = _consoleError; - }); - - test("bubbled server loader errors are persisted for hydrating routes", async ({ - page, - }) => { - let _consoleError = console.error; - console.error = () => {}; - appFixture = await createAppFixture( - await createFixture( - { - files: { - ...getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.tsx": js` - import { Outlet, useLoaderData, useRouteLoaderData, useRouteError } from 'react-router' - export function loader() { - return { message: 'Parent Server Loader' }; + return { + message: 'Child Server Loader', + lazy: new Promise(r => setTimeout(() => r("Child Deferred Data"), 1000)), + }; } export async function clientLoader({ serverLoader }) { - console.log('running parent client loader') - // Need a small delay to ensure we capture the server-rendered - // fallbacks for assertions - await new Promise(r => setTimeout(r, 100)); let data = await serverLoader(); - return { message: data.message + " (mutated by client)" }; + return { + ...data, + message: data.message + " (mutated by client)", + }; } clientLoader.hydrate = true; - export default function Component() { - let data = useLoaderData(); - return ( - <> - <p id="parent-data">{data.message}</p> - <Outlet/> - </> - ); - } - export function ErrorBoundary() { - let data = useRouteLoaderData("routes/parent") - let error = useRouteError(); - return ( - <> - <h1>Parent Error</h1> - <p id="parent-data">{data?.message}</p> - <p id="parent-error">{error?.message}</p> - </> - ); + export function HydrateFallback() { + return <p>Child Fallback</p> } - `, - "app/routes/parent.child.tsx": js` - import { useRouteError, useLoaderData } from 'react-router' - export function loader() { - throw new Error('Child Server Error'); - } - export function clientLoader() { - console.log('running child client loader') - return "Should not see me"; - } - clientLoader.hydrate = true; export default function Component() { - let data = useLoaderData() + let data = useLoaderData(); return ( <> - <p>Should not see me</p> - <p>{data}</p>; + <p id="child-data">{data.message}</p> + <React.Suspense fallback={<p>Loading Deferred Data...</p>}> + <Await resolve={data.lazy}> + {(value) => <p id="child-deferred-data">{value}</p>} + </Await> + </React.Suspense> </> ); } `, }, - }, - ServerMode.Development // Avoid error sanitization - ), - ServerMode.Development // Avoid error sanitization - ); - let app = new PlaywrightFixture(appFixture, page); - let logs: string[] = []; - page.on("console", (msg) => { - let text = msg.text(); - if ( - // Chrome logs the 500 as a console error, so skip that since it's not - // what we are asserting against here - /500 \(Internal Server Error\)/.test(text) || - // Ignore any dev tools messages. This may only happen locally when dev - // tools is installed and not in CI but either way we don't care - /Download the React DevTools/.test(text) - ) { - return; - } - logs.push(text); - }); - await app.goto("/parent/child", false); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader</p>"); - expect(html).toMatch("Child Server Error"); - expect(html).not.toMatch("Should not see me"); - // Ensure we hydrate and remain on the boundary - await page.waitForSelector( - ":has-text('Parent Server Loader (mutated by client)')" - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)</p>"); - expect(html).toMatch("Child Server Error"); - expect(html).not.toMatch("Should not see me"); - expect(logs).toEqual(["running parent client loader"]); - console.error = _consoleError; - }); - - test("hydrating clientLoader redirects trigger new .data requests to the server", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: { - "react-router.config.ts": reactRouterConfig({ - splitRouteModules, - }), - "app/root.tsx": js` - import { Outlet, Scripts } from "react-router" - - let count = 1; - export function loader() { - return count++; - } - - export default function Root({ loaderData }) { - return ( - <html> - <head></head> - <body> - <main> - <p id="root-data">{loaderData}</p> - <Outlet /> - </main> - <Scripts /> - </body> - </html> - ); - } - `, - "app/routes/parent.tsx": js` - import { Outlet } from 'react-router' - let count = 1; - export function loader() { - return count++; - } - export default function Component({ loaderData }) { - return ( - <> - <p id="parent-data">{loaderData}</p> - <Outlet/> - </> - ); - } - export function shouldRevalidate() { - return false; - } - `, - "app/routes/parent.a.tsx": js` - import { redirect } from 'react-router' - export function clientLoader() { - return redirect('/parent/b'); - } - clientLoader.hydrate = true; - export default function Component({ loaderData }) { - return <p>Should not see me</p>; - } - `, - "app/routes/parent.b.tsx": js` - export default function Component({ loaderData }) { - return <p id="b">Hi!</p>; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/parent/a"); - await page.waitForSelector("#b"); - // Root re-runs - await expect(page.locator("#root-data")).toHaveText("2"); - // But parent opted out of revalidation - await expect(page.locator("#parent-data")).toHaveText("1"); - await expect(page.locator("#b")).toHaveText("Hi!"); - }); - }); - - test.describe("clientLoader - lazy route module", () => { - test("no client loaders or fallbacks", async ({ page }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - - // Normal Remix behavior due to lack of clientLoader - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - }); - - test("parent.clientLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader"); - }); - - test("child.clientLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - }); - - test("parent.clientLoader/child.clientLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader (mutated by client"); - }); - - test("throws a 400 if you call serverLoader without a server loader", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: { - ...getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { useLoaderData, useRouteError } from "react-router"; - export async function clientLoader({ serverLoader }) { - return await serverLoader(); - } - export default function Component() { - return <p>Child</p>; - } - export function HydrateFallback() { - return <p>Loading...</p>; - } - export function ErrorBoundary() { - let error = useRouteError(); - return <p id="child-error">{error.status} {error.data}</p>; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-error"); - let html = await app.getHtml("#child-error"); - expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( - "400 Error: You are trying to call serverLoader() on a route that does " + - 'not have a server loader (routeId: "routes/parent.child")' - ); - }); - test("does not prefetch server loader if a client loader is present", async ({ - page, - browserName, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: { - ...getFiles({ - splitRouteModules, - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/_index.tsx": js` - import { Link } from 'react-router' - export default function Component() { - return ( - <> - <Link prefetch="render" to="/parent">Go to /parent</Link> - <Link prefetch="render" to="/parent/child">Go to /parent/child</Link> - </> - ); - } - `, - }, - }) - ); - - let dataUrls: string[] = []; - page.on("request", (request) => { - if (request.url().includes(".data")) { - dataUrls.push(request.url()); - } - }); - - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/", true); - - if (browserName === "webkit") { - // No prefetch support :/ - expect(dataUrls).toEqual([]); - } else { - // Only prefetch child server loader since parent has a `clientLoader` - expect(dataUrls).toEqual([ - expect.stringMatching( - /parent\/child\.data\?_routes=routes%2Fparent\.child/ - ), - ]); - } - }); - }); - - test.describe("clientAction - critical route module", () => { - test("child.clientAction", async ({ page }) => { - appFixture = await createAppFixture( - await createFixture( - { + }); + + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + // Ensure initial document request contains the child fallback _and_ the + // subsequent streamed/resolved deferred data + let response = await app.goto("/parent/child"); + let html = await response?.text(); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Fallback"); + expect(html).toMatch("Child Deferred Data"); + + await page.waitForSelector("#child-deferred-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + // app.goto() doesn't resolve until the document finishes loading so by + // then the HTML has updated via the streamed suspense updates + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Deferred Data"); + }); + + test("allows hydration execution without rendering a fallback", async ({ + page, + }) => { + let fixture = await createFixture({ + templateName, files: getFiles({ splitRouteModules, parentClientLoader: false, @@ -1189,433 +429,1268 @@ test.describe("Client Data", () => { childClientLoader: false, childClientLoaderHydrate: false, childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" + export async function clientLoader() { + await new Promise(r => setTimeout(r, 100)); + return { message: "Child Client Loader" }; } - } + clientLoader.hydrate=true `, }), - }, - ServerMode.Development - ), - ServerMode.Development - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("child.clientAction/parent.childLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" - } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); // still revalidating - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - - await page.waitForSelector( - ':has-text("Parent Server Loader (mutated by client)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("child.clientAction/child.clientLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" + }); + + appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Child Server Loader"); + await page.waitForSelector(':has-text("Child Client Loader")'); + html = await app.getHtml("main"); + expect(html).toMatch("Child Client Loader"); + }); + + test("HydrateFallback is not rendered if clientLoader.hydrate is not set (w/server loader)", async ({ + page, + }) => { + let fixture = await createFixture({ + templateName, + files: { + ...getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData } from "react-router"; + export function loader() { + return { message: "Child Server Loader Data" }; } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); // still revalidating - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - - await page.waitForSelector( - ':has-text("Child Server Loader (mutated by client)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("child.clientAction/parent.childLoader/child.clientLoader", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + return { message: "Child Client Loader Data" }; } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); // still revalidating - expect(html).toMatch("Child Server Loader"); // still revalidating - expect(html).toMatch("Child Server Action (mutated by client)"); - - await page.waitForSelector( - ':has-text("Child Server Loader (mutated by client)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("throws a 400 if you call serverAction without a server action", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: { - ...getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { Form, useRouteError } from "react-router"; - export async function clientAction({ serverAction }) { - return await serverAction(); - } - export default function Component() { - return ( - <Form method="post"> - <button type="submit">Submit</button> - </Form> - ); - } - export function ErrorBoundary() { - let error = useRouteError(); - return <p id="child-error">{error.status} {error.data}</p>; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/parent/child"); - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-error"); - let html = await app.getHtml("#child-error"); - expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( - "400 Error: You are trying to call serverAction() on a route that does " + - 'not have a server action (routeId: "routes/parent.child")' - ); - }); - }); - - test.describe("clientAction - lazy route module", () => { - test("child.clientAction", async ({ page }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" + export function HydrateFallback() { + return <p>SHOULD NOT SEE ME</p> } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); - - test("child.clientAction/parent.childLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" + export default function Component() { + let data = useLoaderData(); + return <p id="child-data">{data.message}</p>; } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); // still revalidating - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - - await page.waitForSelector( - ':has-text("Parent Server Loader (mutated by client)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + + // Ensure initial document request contains the child fallback _and_ the + // subsequent streamed/resolved deferred data + let response = await app.goto("/parent/child"); + let html = await response?.text(); + expect(html).toMatch("Child Server Loader Data"); + expect(html).not.toMatch("SHOULD NOT SEE ME"); + + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Child Server Loader Data"); + }); + + test("clientLoader.hydrate is automatically implied when no server loader exists (w HydrateFallback)", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: { + ...getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData } from "react-router"; + // Even without setting hydrate=true, this should run on hydration + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + return { + message: "Loader Data (clientLoader only)", + }; + } + export function HydrateFallback() { + return <p>Child Fallback</p> + } + export default function Component() { + let data = useLoaderData(); + return <p id="child-data">{data.message}</p>; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Child Fallback"); + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Loader Data (clientLoader only)"); + }); + + test("clientLoader.hydrate is automatically implied when no server loader exists (w/o HydrateFallback)", async ({ + page, + }) => { + test.skip( + templateName === "rsc-parcel-framework", + "RSC Data Mode doesn't need to provide a default root HydrateFallback since it doesn't need to ensure <Scripts /> is rendered, and you already get a console warning" + ); + + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: { + ...getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData } from "react-router"; + // Even without setting hydrate=true, this should run on hydration + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + return { + message: "Loader Data (clientLoader only)", + }; + } + export default function Component() { + let data = useLoaderData(); + return <p id="child-data">{data.message}</p>; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml(); + expect(html).toMatch( + "๐Ÿ’ฟ Hey developer ๐Ÿ‘‹. You can provide a way better UX than this" + ); + expect(html).not.toMatch("child-data"); + await page.waitForSelector("#child-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Loader Data (clientLoader only)"); + }); + + test("throws a 400 if you call serverLoader without a server loader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: { + ...getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData, useRouteError } from "react-router"; + export async function clientLoader({ serverLoader }) { + return await serverLoader(); + } + export default function Component() { + return <p>Child</p>; + } + export function HydrateFallback() { + return <p>Loading...</p>; + } + export function ErrorBoundary() { + let error = useRouteError(); + return <p id="child-error">{error.status} {error.data}</p>; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverLoader() on a route that does " + + 'not have a server loader (routeId: "routes/parent.child")' + ); + }); + + test("initial hydration data check functions properly", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: { + ...getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData, useRevalidator } from "react-router"; + let isFirstCall = true; + export async function loader({ serverLoader }) { + if (isFirstCall) { + isFirstCall = false + return { message: "Child Server Loader Data (1)" }; + } + return { message: "Child Server Loader Data (2+)" }; + } + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + let serverData = await serverLoader(); + return { + message: serverData.message + " (mutated by client)", + }; + } + clientLoader.hydrate=true; + export default function Component() { + let data = useLoaderData(); + let revalidator = useRevalidator(); + return ( + <> + <p id="child-data">{data.message}</p> + <button onClick={() => revalidator.revalidate()}>Revalidate</button> + </> + ); + } + export function HydrateFallback() { + return <p>Loading...</p> + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml(); + expect(html).toMatch( + "Child Server Loader Data (1) (mutated by client)" + ); + app.clickElement("button"); + await page.waitForSelector( + ':has-text("Child Server Loader Data (2+)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch( + "Child Server Loader Data (2+) (mutated by client)" + ); + }); + + test("initial hydration data check functions properly even if serverLoader isn't called on hydration", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: { + ...getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData, useRevalidator } from "react-router"; + let isFirstCall = true; + export async function loader({ serverLoader }) { + if (isFirstCall) { + isFirstCall = false + return { message: "Child Server Loader Data (1)" }; + } + return { message: "Child Server Loader Data (2+)" }; + } + let isFirstClientCall = true; + export async function clientLoader({ serverLoader }) { + await new Promise(r => setTimeout(r, 100)); + if (isFirstClientCall) { + isFirstClientCall = false; + // First time through - don't even call serverLoader + return { + message: "Child Client Loader Data", + }; + } + // Only call the serverLoader on subsequent calls and this + // should *not* return us the initialData any longer + let serverData = await serverLoader(); + return { + message: serverData.message + " (mutated by client)", + }; + } + clientLoader.hydrate=true; + export default function Component() { + let data = useLoaderData(); + let revalidator = useRevalidator(); + return ( + <> + <p id="child-data">{data.message}</p> + <button onClick={() => revalidator.revalidate()}>Revalidate</button> + </> + ); + } + export function HydrateFallback() { + return <p>Loading...</p> + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml(); + expect(html).toMatch("Child Client Loader Data"); + app.clickElement("button"); + await page.waitForSelector( + ':has-text("Child Server Loader Data (2+)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch( + "Child Server Loader Data (2+) (mutated by client)" + ); + }); + + test("server loader errors are re-thrown from serverLoader()", async ({ + page, + }) => { + let _consoleError = console.error; + console.error = () => {}; + appFixture = await createAppFixture( + await createFixture( + { + templateName, + files: { + ...getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import { useRouteError } from "react-router"; + + export function loader() { + throw new Error("Broken!") + } + + export async function clientLoader({ serverLoader }) { + return await serverLoader(); + } + clientLoader.hydrate = true; + + export default function Index() { + return <h1>Should not see me</h1>; + } + + export function ErrorBoundary() { + let error = useRouteError(); + return <p id="child-error">{error.message}</p>; + } + `, + }, + }, + ServerMode.Development // Avoid error sanitization + ), + ServerMode.Development // Avoid error sanitization + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Broken!"); + // Ensure we hydrate and remain on the boundary + await new Promise((r) => setTimeout(r, 100)); + html = await app.getHtml("main"); + expect(html).toMatch("Broken!"); + expect(html).not.toMatch("Should not see me"); + console.error = _consoleError; + }); + + test("bubbled server loader errors are persisted for hydrating routes", async ({ + page, + }) => { + let _consoleError = console.error; + console.error = () => {}; + appFixture = await createAppFixture( + await createFixture( + { + templateName, + files: { + ...getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.tsx": js` + import { Outlet, useLoaderData, useRouteLoaderData, useRouteError } from 'react-router' + export function loader() { + return { message: 'Parent Server Loader' }; + } + export async function clientLoader({ serverLoader }) { + console.log('running parent client loader') + // Need a small delay to ensure we capture the server-rendered + // fallbacks for assertions + await new Promise(r => setTimeout(r, 100)); + let data = await serverLoader(); + return { message: data.message + " (mutated by client)" }; + } + clientLoader.hydrate = true; + export default function Component() { + let data = useLoaderData(); + return ( + <> + <p id="parent-data">{data.message}</p> + <Outlet/> + </> + ); + } + export function ErrorBoundary() { + let data = useRouteLoaderData("routes/parent") + let error = useRouteError(); + return ( + <> + <h1>Parent Error</h1> + <p id="parent-data">{data?.message}</p> + <p id="parent-error">{error?.message}</p> + </> + ); + } + `, + "app/routes/parent.child.tsx": js` + import { useRouteError, useLoaderData } from 'react-router' + export function loader() { + throw new Error('Child Server Error'); + } + export function clientLoader() { + console.log('running child client loader') + return "Should not see me"; + } + clientLoader.hydrate = true; + export default function Component() { + let data = useLoaderData() + return ( + <> + <p>Should not see me</p> + <p>{data}</p>; + </> + ); + } + `, + }, + }, + ServerMode.Development // Avoid error sanitization + ), + ServerMode.Development // Avoid error sanitization + ); + let app = new PlaywrightFixture(appFixture, page); + let logs: string[] = []; + page.on("console", (msg) => { + let text = msg.text(); + if ( + // Chrome logs the 500 as a console error, so skip that since it's not + // what we are asserting against here + /500 \(Internal Server Error\)/.test(text) || + // Ignore any dev tools messages. This may only happen locally when dev + // tools is installed and not in CI but either way we don't care + /Download the React DevTools/.test(text) || + (templateName === "rsc-parcel-framework" && + /The <Scripts \/> element is a no-op when using RSC and can be safely removed./.test( + text + )) || + // TODO: Render outlet on RSC render error? + (templateName === "rsc-parcel-framework" && + /Matched leaf route at location "\/parent\/child" does not have an element/.test( + text + )) + ) { + return; + } + logs.push(text); + }); + await app.goto("/parent/child", false); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader</p>"); + expect(html).toMatch("Child Server Error"); + expect(html).not.toMatch("Should not see me"); + // Ensure we hydrate and remain on the boundary + await page.waitForSelector( + ":has-text('Parent Server Loader (mutated by client)')" + ); + html = await app.getHtml("main"); + expect(html).toMatch( + "Parent Server Loader (mutated by client)</p>" + ); + expect(html).toMatch("Child Server Error"); + expect(html).not.toMatch("Should not see me"); + expect(logs).toEqual(["running parent client loader"]); + console.error = _consoleError; + }); + + test("hydrating clientLoader redirects trigger new .data requests to the server", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: { + "react-router.config.ts": reactRouterConfig({ + splitRouteModules, + }), + "app/root.tsx": js` + import { Outlet, Scripts } from "react-router" + + let count = 1; + export function loader() { + return count++; + } + + export default function Root({ loaderData }) { + return ( + <html> + <head></head> + <body> + <main> + <p id="root-data">{loaderData}</p> + <Outlet /> + </main> + <Scripts /> + </body> + </html> + ); + } + `, + "app/routes/parent.tsx": js` + import { Outlet } from 'react-router' + let count = 1; + export function loader() { + return count++; + } + export default function Component({ loaderData }) { + return ( + <> + <p id="parent-data">{loaderData}</p> + <Outlet/> + </> + ); + } + export function shouldRevalidate() { + return false; + } + `, + "app/routes/parent.a.tsx": js` + import { redirect } from 'react-router' + export function clientLoader() { + return redirect('/parent/b'); + } + clientLoader.hydrate = true; + export default function Component({ loaderData }) { + return <p>Should not see me</p>; + } + `, + "app/routes/parent.b.tsx": js` + export default function Component({ loaderData }) { + return <p id="b">Hi!</p>; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/parent/a"); + await page.waitForSelector("#b"); + // Root re-runs + await expect(page.locator("#root-data")).toHaveText("2"); + // But parent opted out of revalidation + await expect(page.locator("#parent-data")).toHaveText("1"); + await expect(page.locator("#b")).toHaveText("Hi!"); + }); + }); - test("child.clientAction/child.clientLoader", async ({ page }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" - } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); // still revalidating - expect(html).toMatch("Child Server Loader"); - expect(html).toMatch("Child Server Action (mutated by client)"); - - await page.waitForSelector( - ':has-text("Child Server Loader (mutated by client)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); + test.describe("clientLoader - lazy route module", () => { + test("no client loaders or fallbacks", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + + // Normal Remix behavior due to lack of clientLoader + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + }); + + test("parent.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + }); + + test("child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + }); + + test("parent.clientLoader/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client"); + }); + + test("throws a 400 if you call serverLoader without a server loader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: { + ...getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { useLoaderData, useRouteError } from "react-router"; + export async function clientLoader({ serverLoader }) { + return await serverLoader(); + } + export default function Component() { + return <p>Child</p>; + } + export function HydrateFallback() { + return <p>Loading...</p>; + } + export function ErrorBoundary() { + let error = useRouteError(); + return <p id="child-error">{error.status} {error.data}</p>; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverLoader() on a route that does " + + 'not have a server loader (routeId: "routes/parent.child")' + ); + }); + test("does not prefetch server loader if a client loader is present", async ({ + page, + browserName, + }) => { + test.skip( + templateName === "rsc-parcel-framework", + "This test is specific to non-RSC Data Mode" + ); + + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: { + ...getFiles({ + splitRouteModules, + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/_index.tsx": js` + import { Link } from 'react-router' + export default function Component() { + return ( + <> + <Link prefetch="render" to="/parent">Go to /parent</Link> + <Link prefetch="render" to="/parent/child">Go to /parent/child</Link> + </> + ); + } + `, + }, + }) + ); + + let dataUrls: string[] = []; + page.on("request", (request) => { + let url = request.url(); + if (url.includes(".data") || url.includes(".rsc")) { + dataUrls.push(url); + } + }); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + + if (browserName === "webkit") { + // No prefetch support :/ + expect(dataUrls).toEqual([]); + } else { + // Only prefetch child server loader since parent has a `clientLoader` + expect(dataUrls).toEqual([ + expect.stringMatching( + /parent\/child\.data\?_routes=routes%2Fparent\.child/ + ), + ]); + } + }); + }); - test("child.clientAction/parent.childLoader/child.clientLoader", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: getFiles({ - splitRouteModules, - parentClientLoader: true, - parentClientLoaderHydrate: false, - childClientLoader: true, - childClientLoaderHydrate: false, - childAdditions: js` - export async function clientAction({ serverAction }) { - let data = await serverAction(); - return { - message: data.message + " (mutated by client)" - } - } - `, - }), - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.clickLink("/parent/child"); - await page.waitForSelector("#child-data"); - let html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); - expect(html).toMatch("Child Server Loader"); - expect(html).not.toMatch("Child Server Action"); - - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-action-data"); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader"); // still revalidating - expect(html).toMatch("Child Server Loader"); // still revalidating - expect(html).toMatch("Child Server Action (mutated by client)"); - - await page.waitForSelector( - ':has-text("Child Server Loader (mutated by client)")' - ); - html = await app.getHtml("main"); - expect(html).toMatch("Parent Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Loader (mutated by client)"); - expect(html).toMatch("Child Server Action (mutated by client)"); - }); + test.describe("clientAction - critical route module", () => { + test("child.clientAction", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture( + { + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }, + ServerMode.Development + ), + ServerMode.Development + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Parent Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader/child.clientLoader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); // still revalidating + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("throws a 400 if you call serverAction without a server action", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: { + ...getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { Form, useRouteError } from "react-router"; + export async function clientAction({ serverAction }) { + return await serverAction(); + } + export default function Component() { + return ( + <Form method="post"> + <button type="submit">Submit</button> + </Form> + ); + } + export function ErrorBoundary() { + let error = useRouteError(); + return <p id="child-error">{error.status} {error.data}</p>; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverAction() on a route that does " + + 'not have a server action (routeId: "routes/parent.child")' + ); + }); + }); - test("throws a 400 if you call serverAction without a server action", async ({ - page, - }) => { - appFixture = await createAppFixture( - await createFixture({ - files: { - ...getFiles({ - splitRouteModules, - parentClientLoader: false, - parentClientLoaderHydrate: false, - childClientLoader: false, - childClientLoaderHydrate: false, - }), - "app/routes/parent.child.tsx": js` - import * as React from 'react'; - import { Form, useRouteError } from "react-router"; - export async function clientAction({ serverAction }) { - return await serverAction(); - } - export default function Component() { - return ( - <Form method="post"> - <button type="submit">Submit</button> - </Form> - ); - } - export function ErrorBoundary() { - let error = useRouteError(); - return <p id="child-error">{error.status} {error.data}</p>; - } - `, - }, - }) - ); - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/"); - await app.goto("/parent/child"); - await page.waitForSelector("form"); - app.clickSubmitButton("/parent/child"); - await page.waitForSelector("#child-error"); - let html = await app.getHtml("#child-error"); - expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( - "400 Error: You are trying to call serverAction() on a route that does " + - 'not have a server action (routeId: "routes/parent.child")' - ); + test.describe("clientAction - lazy route module", () => { + test("child.clientAction", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Parent Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/child.clientLoader", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("child.clientAction/parent.childLoader/child.clientLoader", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: getFiles({ + splitRouteModules, + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: true, + childClientLoaderHydrate: false, + childAdditions: js` + export async function clientAction({ serverAction }) { + let data = await serverAction(); + return { + message: data.message + " (mutated by client)" + } + } + `, + }), + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-data"); + let html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); + expect(html).toMatch("Child Server Loader"); + expect(html).not.toMatch("Child Server Action"); + + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-action-data"); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader"); // still revalidating + expect(html).toMatch("Child Server Loader"); // still revalidating + expect(html).toMatch("Child Server Action (mutated by client)"); + + await page.waitForSelector( + ':has-text("Child Server Loader (mutated by client)")' + ); + html = await app.getHtml("main"); + expect(html).toMatch("Parent Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Loader (mutated by client)"); + expect(html).toMatch("Child Server Action (mutated by client)"); + }); + + test("throws a 400 if you call serverAction without a server action", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + templateName, + files: { + ...getFiles({ + splitRouteModules, + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { Form, useRouteError } from "react-router"; + export async function clientAction({ serverAction }) { + return await serverAction(); + } + export default function Component() { + return ( + <Form method="post"> + <button type="submit">Submit</button> + </Form> + ); + } + export function ErrorBoundary() { + let error = useRouteError(); + return <p id="child-error">{error.status} {error.data}</p>; + } + `, + }, + }) + ); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.goto("/parent/child"); + await page.waitForSelector("form"); + app.clickSubmitButton("/parent/child"); + await page.waitForSelector("#child-error"); + let html = await app.getHtml("#child-error"); + expect(html.replace(/\n/g, " ").replace(/ +/g, " ")).toMatch( + "400 Error: You are trying to call serverAction() on a route that does " + + 'not have a server action (routeId: "routes/parent.child")' + ); + }); + }); }); }); }); - }); + } }); diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts index f021ea36f3..f0d36a9110 100644 --- a/integration/fetcher-test.ts +++ b/integration/fetcher-test.ts @@ -247,7 +247,7 @@ test.describe("useFetcher", () => { // a <pre> but Edge puts it in some weird code editor markup: // <body data-code-mirror="Readonly code editor."> // <div hidden="true">"LUNCH"</div> - expect(await app.getHtml()).toContain(LUNCH); + await page.getByText(LUNCH); }); test("Form can hit an action", async ({ page }) => { @@ -264,7 +264,7 @@ test.describe("useFetcher", () => { // a <pre> but Edge puts it in some weird code editor markup: // <body data-code-mirror="Readonly code editor."> // <div hidden="true">"LUNCH"</div> - expect(await app.getHtml()).toContain(CHEESESTEAK); + await page.getByText(CHEESESTEAK); }); }); @@ -288,9 +288,7 @@ test.describe("useFetcher", () => { await page.fill("#fetcher-input", "input value"); await app.clickElement("#fetcher-submit-json"); await page.waitForSelector(`#fetcher-idle`); - expect(await app.getHtml()).toMatch( - 'ACTION (application/json) input value"' - ); + await page.getByText('ACTION (application/json) input value"'); }); test("submit can hit an action with null json", async ({ page }) => { @@ -299,7 +297,7 @@ test.describe("useFetcher", () => { await app.clickElement("#fetcher-submit-json-null"); await new Promise((r) => setTimeout(r, 1000)); await page.waitForSelector(`#fetcher-idle`); - expect(await app.getHtml()).toMatch('ACTION (application/json) null"'); + await page.getByText('ACTION (application/json) null"'); }); test("submit can hit an action with text", async ({ page }) => { @@ -308,9 +306,7 @@ test.describe("useFetcher", () => { await page.fill("#fetcher-input", "input value"); await app.clickElement("#fetcher-submit-text"); await page.waitForSelector(`#fetcher-idle`); - expect(await app.getHtml()).toMatch( - 'ACTION (text/plain;charset=UTF-8) input value"' - ); + await page.getByText('ACTION (text/plain;charset=UTF-8) input value"'); }); test("submit can hit an action with empty text", async ({ page }) => { @@ -319,7 +315,7 @@ test.describe("useFetcher", () => { await app.clickElement("#fetcher-submit-text-empty"); await new Promise((r) => setTimeout(r, 1000)); await page.waitForSelector(`#fetcher-idle`); - expect(await app.getHtml()).toMatch('ACTION (text/plain;charset=UTF-8) "'); + await page.getByText('ACTION (text/plain;charset=UTF-8) "'); }); test("submit can hit an action only route", async ({ page }) => { @@ -360,21 +356,19 @@ test.describe("useFetcher", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/fetcher-echo", true); - expect(await app.getHtml("pre")).toMatch( - JSON.stringify(["idle/undefined"]) - ); + await page.getByText(JSON.stringify(["idle/undefined"])); await page.fill("#fetcher-input", "1"); await app.clickElement("#fetcher-load"); await page.waitForSelector("#fetcher-idle"); - expect(await app.getHtml("pre")).toMatch( + await page.getByText( JSON.stringify(["idle/undefined", "loading/undefined", "idle/LOADER 1"]) ); await page.fill("#fetcher-input", "2"); await app.clickElement("#fetcher-load"); await page.waitForSelector("#fetcher-idle"); - expect(await app.getHtml("pre")).toMatch( + await page.getByText( JSON.stringify([ "idle/undefined", "loading/undefined", @@ -391,14 +385,12 @@ test.describe("useFetcher", () => { let app = new PlaywrightFixture(appFixture, page); await app.goto("/fetcher-echo", true); - expect(await app.getHtml("pre")).toMatch( - JSON.stringify(["idle/undefined"]) - ); + await page.getByText(JSON.stringify(["idle/undefined"])); await page.fill("#fetcher-input", "1"); await app.clickElement("#fetcher-submit"); await page.waitForSelector("#fetcher-idle"); - expect(await app.getHtml("pre")).toMatch( + await page.getByText( JSON.stringify([ "idle/undefined", "submitting/undefined", @@ -410,7 +402,7 @@ test.describe("useFetcher", () => { await page.fill("#fetcher-input", "2"); await app.clickElement("#fetcher-submit"); await page.waitForSelector("#fetcher-idle"); - expect(await app.getHtml("pre")).toMatch( + await page.getByText( JSON.stringify([ "idle/undefined", "submitting/undefined", diff --git a/integration/form-test.ts b/integration/form-test.ts index 1b829899b9..1d99cee902 100644 --- a/integration/form-test.ts +++ b/integration/form-test.ts @@ -1102,11 +1102,7 @@ test.describe("Forms", () => { test("empty file inputs resolve to File objects on the server", async ({ page, - channel, }) => { - // TODO: Look into this test failing on windows - test.skip(channel === "msedge", "Fails on windows with undici"); - let app = new PlaywrightFixture(appFixture, page); await app.goto("/empty-file-upload"); diff --git a/integration/helpers/cloudflare-dev-proxy-template/package.json b/integration/helpers/cloudflare-dev-proxy-template/package.json index 52207f8acb..1b569577dc 100644 --- a/integration/helpers/cloudflare-dev-proxy-template/package.json +++ b/integration/helpers/cloudflare-dev-proxy-template/package.json @@ -27,7 +27,7 @@ "@types/react-dom": "^18.2.7", "typescript": "^5.1.6", "vite": "^6.1.0", - "wrangler": "^4.2.0" + "wrangler": "^4.23.0" }, "engines": { "node": ">=20.0.0" diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 038a339a3d..643c056e4e 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -25,6 +25,65 @@ const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); const root = path.join(__dirname, "../.."); const TMP_DIR = path.join(root, ".tmp", "integration"); +export async function spawnTestServer({ + command, + regex, + validate, + env = {}, + cwd, + timeout = 20000, +}: { + command: string[]; + regex: RegExp; + validate?: (matches: RegExpMatchArray) => void | Promise<void>; + env?: Record<string, string>; + cwd?: string; + timeout?: number; +}): Promise<{ stop: VoidFunction }> { + return new Promise((accept, reject) => { + let serverProcess = spawn(command[0], command.slice(1), { + env: { ...process.env, ...env }, + cwd, + stdio: "pipe", + }); + + let started = false; + let stdout = ""; + let rejectTimeout = setTimeout(() => { + reject(new Error(`Timed out waiting for server to start (${timeout}ms)`)); + }, timeout); + + serverProcess.stderr.pipe(process.stderr); + serverProcess.stdout.on("data", (chunk: Buffer) => { + if (started) return; + let newChunk = chunk.toString(); + stdout += newChunk; + let match = stdout.match(regex); + if (match) { + clearTimeout(rejectTimeout); + started = true; + + Promise.resolve(validate?.(match)) + .then(() => { + accept({ + stop: () => { + serverProcess.kill(); + }, + }); + }) + .catch((error: unknown) => { + reject(error); + }); + } + }); + + serverProcess.on("error", (error: unknown) => { + clearTimeout(rejectTimeout); + reject(error); + }); + }); +} + export interface FixtureInit { buildStdio?: Writable; files?: { [filename: string]: string }; @@ -45,7 +104,10 @@ export function json(value: JsonObject) { return JSON.stringify(value, null, 2); } +const defaultTemplateName = "vite-5-template" satisfies TemplateName; + export async function createFixture(init: FixtureInit, mode?: ServerMode) { + let templateName = init.templateName ?? defaultTemplateName; let projectDir = await createFixtureProject(init, mode); let buildPath = url.pathToFileURL( path.join(projectDir, "build/server/index.js") @@ -134,8 +196,22 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { }; } - let app: ServerBuild = await import(buildPath); - let handler = createRequestHandler(app, mode || ServerMode.Production); + let build: ServerBuild | null = null; + type RequestHandler = (request: Request) => Promise<Response>; + let handler: RequestHandler; + if (templateName.includes("parcel")) { + let serverBuild = await import(buildPath); + handler = (serverBuild?.requestHandler ?? + serverBuild?.default?.requestHandler) as RequestHandler; + if (!handler) { + throw new Error( + "Expected a 'requestHandler' export in Parcel server build" + ); + } + } else { + build = (await import(buildPath)) as ServerBuild; + handler = createRequestHandler(build, mode || ServerMode.Production); + } let requestDocument = async (href: string, init?: RequestInit) => { let url = new URL(href, "test://test"); @@ -184,8 +260,10 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) { }; return { + templateName, projectDir, - build: app, + build, + handler, isSpaMode: init.spaMode, prerender: init.prerender, requestDocument, @@ -210,66 +288,29 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { stop: VoidFunction; }> => { if (fixture.useReactRouterServe) { - return new Promise(async (accept, reject) => { - let port = await getPort(); - - let nodebin = process.argv[0]; - let serveProcess = spawn( - nodebin, - [ - "node_modules/@react-router/serve/dist/cli.js", - "build/server/index.js", - ], - { - env: { - ...process.env, - NODE_ENV: mode || "production", - PORT: port.toFixed(0), - }, - cwd: fixture.projectDir, - stdio: "pipe", - } - ); - // Wait for `started at http://localhost:${port}` to be printed - // and extract the port from it. - let started = false; - let stdout = ""; - let rejectTimeout = setTimeout(() => { - reject( - new Error("Timed out waiting for react-router-serve to start") - ); - }, 20000); - serveProcess.stderr.pipe(process.stderr); - serveProcess.stdout.on("data", (chunk) => { - if (started) return; - let newChunk = chunk.toString(); - stdout += newChunk; - let match: RegExpMatchArray | null = stdout.match( - /\[react-router-serve\] http:\/\/localhost:(\d+)\s/ - ); - if (match) { - clearTimeout(rejectTimeout); - started = true; - let parsedPort = parseInt(match[1], 10); - - if (port !== parsedPort) { - reject( - new Error( - `Expected react-router-serve to start on port ${port}, but it started on port ${parsedPort}` - ) - ); - return; - } - - accept({ - stop: () => { - serveProcess.kill(); - }, - port, - }); + let port = await getPort(); + let { stop } = await spawnTestServer({ + cwd: fixture.projectDir, + command: [ + process.argv[0], + "node_modules/@react-router/serve/dist/cli.js", + "build/server/index.js", + ], + env: { + NODE_ENV: mode || "production", + PORT: port.toFixed(0), + }, + regex: /\[react-router-serve\] http:\/\/localhost:(\d+)\s/, + validate: (matches) => { + let parsedPort = parseInt(matches[1], 10); + if (port !== parsedPort) { + throw new Error( + `Expected react-router-serve to start on port ${port}, but it started on port ${parsedPort}` + ); } - }); + }, }); + return { stop, port }; } if (fixture.isSpaMode) { @@ -313,7 +354,30 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { }); } - if (!fixture.build) { + if (fixture.templateName.includes("parcel")) { + let port = await getPort(); + let { stop } = await spawnTestServer({ + cwd: fixture.projectDir, + command: [process.argv[0], "start.js"], + env: { + NODE_ENV: mode || "production", + PORT: port.toFixed(0), + }, + regex: /Server listening on port (\d+)\s/, + validate: (matches) => { + let parsedPort = parseInt(matches[1], 10); + if (port !== parsedPort) { + throw new Error( + `Expected Parcel build server to start on port ${port}, but it started on port ${parsedPort}` + ); + } + }, + }); + return { stop, port }; + } + + const build = fixture.build; + if (!build) { return Promise.reject( new Error("Cannot start app server without a build") ); @@ -327,7 +391,7 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { app.all( "*", createExpressHandler({ - build: fixture.build, + build, mode: mode || ServerMode.Production, }) ); @@ -366,9 +430,9 @@ export async function createFixtureProject( init: FixtureInit = {}, mode?: ServerMode ): Promise<string> { - let template = init.templateName ?? "vite-5-template"; - let integrationTemplateDir = path.resolve(__dirname, template); - let projectName = `rr-${template}-${Math.random().toString(32).slice(2)}`; + let templateName = init.templateName ?? defaultTemplateName; + let integrationTemplateDir = path.resolve(__dirname, templateName); + let projectName = `rr-${templateName}-${Math.random().toString(32).slice(2)}`; let projectDir = path.join(TMP_DIR, projectName); let port = init.port ?? (await getPort()); @@ -406,12 +470,56 @@ export async function createFixtureProject( projectDir ); - build(projectDir, init.buildStdio, mode); + if (templateName.includes("parcel")) { + parcelBuild(projectDir, init.buildStdio, mode); + } else { + reactRouterBuild(projectDir, init.buildStdio, mode); + } return projectDir; } -function build(projectDir: string, buildStdio?: Writable, mode?: ServerMode) { +function parcelBuild( + projectDir: string, + buildStdio?: Writable, + mode?: ServerMode +) { + let parcelBin = "node_modules/parcel/lib/bin.js"; + + let buildArgs: string[] = [parcelBin, "build", "--no-cache"]; + + let buildSpawn = spawnSync("node", buildArgs, { + cwd: projectDir, + env: { + ...process.env, + NODE_ENV: mode || ServerMode.Production, + }, + }); + + // These logs are helpful for debugging. Remove comments if needed. + // console.log("spawning node " + buildArgs.join(" ") + ":\n"); + // console.log(" STDOUT:"); + // console.log(" " + buildSpawn.stdout.toString("utf-8")); + // console.log(" STDERR:"); + // console.log(" " + buildSpawn.stderr.toString("utf-8")); + + if (buildStdio) { + buildStdio.write(buildSpawn.stdout.toString("utf-8")); + buildStdio.write(buildSpawn.stderr.toString("utf-8")); + buildStdio.end(); + } + + if (buildSpawn.error || buildSpawn.status) { + console.error(buildSpawn.stderr.toString("utf-8")); + throw buildSpawn.error || new Error(`Build failed, check the output above`); + } +} + +function reactRouterBuild( + projectDir: string, + buildStdio?: Writable, + mode?: ServerMode +) { // We have a "require" instead of a dynamic import in readConfig gated // behind mode === ServerMode.Test to make jest happy, but that doesn't // work for ESM configs, those MUST be dynamic imports. So we need to diff --git a/integration/helpers/playwright-fixture.ts b/integration/helpers/playwright-fixture.ts index 07bfa2b92a..e03f8cf19c 100644 --- a/integration/helpers/playwright-fixture.ts +++ b/integration/helpers/playwright-fixture.ts @@ -167,7 +167,9 @@ export class PlaywrightFixture { * loaders were called (or not). */ collectSingleFetchResponses() { - return this.collectResponses((url) => url.pathname.endsWith(".data")); + return this.collectResponses( + (url) => url.pathname.endsWith(".data") || url.pathname.endsWith(".rsc") + ); } /** diff --git a/integration/helpers/rsc-parcel-framework/.gitignore b/integration/helpers/rsc-parcel-framework/.gitignore new file mode 100644 index 0000000000..0ad794a35c --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/.gitignore @@ -0,0 +1,5 @@ +.parcel-cache +.react-router +.react-router-parcel +build +node_modules diff --git a/integration/helpers/rsc-parcel-framework/.parcelrc b/integration/helpers/rsc-parcel-framework/.parcelrc new file mode 100644 index 0000000000..49bb729196 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/.parcelrc @@ -0,0 +1,3 @@ +{ + "extends": "parcel-config-react-router-experimental" +} diff --git a/integration/helpers/rsc-parcel-framework/app/entry.browser.tsx b/integration/helpers/rsc-parcel-framework/app/entry.browser.tsx new file mode 100644 index 0000000000..1d29db0cff --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/app/entry.browser.tsx @@ -0,0 +1,41 @@ +"use client-entry"; + +import * as React from "react"; +import { hydrateRoot } from "react-dom/client"; +import { + type unstable_RSCPayload as RSCPayload, + unstable_createCallServer as createCallServer, + unstable_getRSCStream as getRSCStream, + unstable_RSCHydratedRouter as RSCHydratedRouter, +} from "react-router"; +import { + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, + // @ts-expect-error +} from "react-server-dom-parcel/client"; + +setServerCallback( + createCallServer({ + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + }) +); + +createFromReadableStream(getRSCStream()).then((payload: RSCPayload) => { + React.startTransition(() => { + hydrateRoot( + document, + React.createElement( + React.StrictMode, + null, + React.createElement(RSCHydratedRouter, { + createFromReadableStream, + payload, + }) + ) + ); + }); +}); diff --git a/integration/helpers/rsc-parcel-framework/app/entry.rsc.ts b/integration/helpers/rsc-parcel-framework/app/entry.rsc.ts new file mode 100644 index 0000000000..0613878d09 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/app/entry.rsc.ts @@ -0,0 +1,32 @@ +"use server-entry"; + +import { + createTemporaryReferenceSet, + decodeAction, + decodeReply, + loadServerAction, + renderToReadableStream, + // @ts-expect-error +} from "react-server-dom-parcel/server.edge"; +import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router"; + +import routes from "virtual:react-router/routes"; + +import "./entry.browser.tsx"; + +export function fetchServer(request: Request) { + return matchRSCServerRequest({ + createTemporaryReferenceSet, + decodeAction, + decodeReply, + loadServerAction, + request, + routes, + generateResponse(match) { + return new Response(renderToReadableStream(match.payload), { + status: match.statusCode, + headers: match.headers, + }); + }, + }); +} diff --git a/integration/helpers/rsc-parcel-framework/app/index.ts b/integration/helpers/rsc-parcel-framework/app/index.ts new file mode 100644 index 0000000000..a065952af4 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/app/index.ts @@ -0,0 +1,29 @@ +import * as React from "react"; +// @ts-expect-error - no types +import { renderToReadableStream } from "react-dom/server.edge" assert { env: "react-client" }; +import { + unstable_routeRSCServerRequest, + unstable_RSCStaticRouter, +} from "react-router" assert { env: "react-client" }; +// @ts-expect-error +import { createFromReadableStream } from "react-server-dom-parcel/client.edge" assert { env: "react-client" }; + +import { fetchServer } from "./entry.rsc" assert { env: "react-server" }; + +export const requestHandler = async (request: Request) => { + return unstable_routeRSCServerRequest({ + request, + fetchServer, + createFromReadableStream, + async renderHTML(getPayload) { + return await renderToReadableStream( + React.createElement(unstable_RSCStaticRouter, { getPayload }), + { + bootstrapScriptContent: ( + fetchServer as unknown as { bootstrapScript: string } + ).bootstrapScript, + } + ); + }, + }); +}; diff --git a/integration/helpers/rsc-parcel-framework/app/root.tsx b/integration/helpers/rsc-parcel-framework/app/root.tsx new file mode 100644 index 0000000000..9f824e6c53 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/app/root.tsx @@ -0,0 +1,18 @@ +import { Links, Meta, Outlet, ScrollRestoration } from "react-router"; + +export default function App() { + return ( + <html lang="en"> + <head> + <meta charSet="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <Meta /> + <Links /> + </head> + <body> + <Outlet /> + <ScrollRestoration /> + </body> + </html> + ); +} diff --git a/integration/helpers/rsc-parcel-framework/app/routes.ts b/integration/helpers/rsc-parcel-framework/app/routes.ts new file mode 100644 index 0000000000..4c05936cb6 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/app/routes.ts @@ -0,0 +1,4 @@ +import { type RouteConfig } from "@react-router/dev/routes"; +import { flatRoutes } from "@react-router/fs-routes"; + +export default flatRoutes() satisfies RouteConfig; diff --git a/integration/helpers/rsc-parcel-framework/app/routes/_index.tsx b/integration/helpers/rsc-parcel-framework/app/routes/_index.tsx new file mode 100644 index 0000000000..ecfc25c614 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/app/routes/_index.tsx @@ -0,0 +1,16 @@ +import type { MetaFunction } from "react-router"; + +export const meta: MetaFunction = () => { + return [ + { title: "New React Router App" }, + { name: "description", content: "Welcome to React Router!" }, + ]; +}; + +export default function Index() { + return ( + <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}> + <h1>Welcome to React Router</h1> + </div> + ); +} diff --git a/integration/helpers/rsc-parcel-framework/package.json b/integration/helpers/rsc-parcel-framework/package.json new file mode 100644 index 0000000000..a17de0ed03 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/package.json @@ -0,0 +1,46 @@ +{ + "name": "integration-rsc-parcel-framework", + "version": "0.0.0", + "private": true, + "targets": { + "server": { + "source": "app/index.ts", + "distDir": "build", + "context": "react-server", + "scopeHoist": false, + "includeNodeModules": { + "express": false + } + } + }, + "scripts": { + "clean": "rm -rf dist .parcel-cache .react-router .react-router-parcel", + "dev": "parcel --no-cache --no-autoinstall", + "build": "parcel build --no-cache --no-autoinstall", + "start": "cross-env NODE_ENV=production node start.js", + "typecheck": "react-router typegen && pnpm build && tsc" + }, + "devDependencies": { + "@react-router/dev": "workspace:*", + "@react-router/fs-routes": "workspace:*", + "@types/express": "^5.0.0", + "@types/node": "^22.13.1", + "@types/parcel-env": "0.0.8", + "@types/react-dom": "^19.0.3", + "@types/react": "^19.0.8", + "parcel": "2.15.0", + "parcel-config-react-router-experimental": "1.0.25", + "typescript": "^5.1.6" + }, + "dependencies": { + "@mjackson/node-fetch-server": "0.6.1", + "@parcel/runtime-rsc": "2.15.0", + "cross-env": "^7.0.3", + "express": "^4.21.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "workspace:*", + "react-server-dom-parcel": "^19.0.0", + "remix-utils": "^8.7.0" + } +} diff --git a/integration/helpers/rsc-parcel-framework/public/favicon.ico b/integration/helpers/rsc-parcel-framework/public/favicon.ico new file mode 100644 index 0000000000..5dbdfcddcb Binary files /dev/null and b/integration/helpers/rsc-parcel-framework/public/favicon.ico differ diff --git a/integration/helpers/rsc-parcel-framework/start.js b/integration/helpers/rsc-parcel-framework/start.js new file mode 100644 index 0000000000..8ebd27db0f --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/start.js @@ -0,0 +1,20 @@ +const { createRequestListener } = require("@mjackson/node-fetch-server"); +const express = require("express"); +const reactRouterRequestHandler = + require("./build/server/index.js").requestHandler; + +const app = express(); + +app.use(express.static("public")); +app.use("/client", express.static("build/client")); + +app.get("/.well-known/appspecific/com.chrome.devtools.json", (_, res) => { + res.status(404); + res.send("Not Found"); +}); + +app.use(createRequestListener(reactRouterRequestHandler)); + +const port = process.env.PORT || 3000; +app.listen(port); +console.log(`Server listening on port ${port} (http://localhost:${port})`); diff --git a/integration/helpers/rsc-parcel-framework/tsconfig.json b/integration/helpers/rsc-parcel-framework/tsconfig.json new file mode 100644 index 0000000000..edcc15fde0 --- /dev/null +++ b/integration/helpers/rsc-parcel-framework/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": [ + "**/*.ts", + "**/*.tsx", + "./.react-router/types/**/*", + "./.react-router-parcel/types/**/*" + ], + "compilerOptions": { + "strict": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "moduleResolution": "bundler", + "module": "esnext", + "isolatedModules": true, + "esModuleInterop": true, + "target": "es2022", + "noEmit": true, + "rootDirs": [".", "./.react-router/types", "./.react-router-parcel/types"] + } +} diff --git a/integration/helpers/rsc-parcel/.gitignore b/integration/helpers/rsc-parcel/.gitignore new file mode 100644 index 0000000000..77738287f0 --- /dev/null +++ b/integration/helpers/rsc-parcel/.gitignore @@ -0,0 +1 @@ +dist/ \ No newline at end of file diff --git a/integration/helpers/rsc-parcel/package.json b/integration/helpers/rsc-parcel/package.json new file mode 100644 index 0000000000..0b5c2eecac --- /dev/null +++ b/integration/helpers/rsc-parcel/package.json @@ -0,0 +1,49 @@ +{ + "name": "integration-rsc-parcel", + "version": "0.0.0", + "private": true, + "targets": { + "server": { + "context": "react-server", + "source": "src/server.tsx", + "scopeHoist": false, + "includeNodeModules": { + "express": false + } + } + }, + "scripts": { + "dev": "parcel --no-cache", + "build": "parcel build --no-cache", + "start": "cross-env NODE_ENV=production node dist/server/server.js", + "typecheck": "tsc" + }, + "devDependencies": { + "@parcel/packager-react-static": "2.15.0", + "@parcel/transformer-react-static": "2.15.0", + "@types/express": "^5.0.0", + "@types/node": "^22.13.1", + "@types/parcel-env": "0.0.8", + "@types/react-dom": "^19.0.3", + "@types/react": "^19.0.8", + "browserify-zlib": "^0.2.0", + "buffer": "^5.5.0||^6.0.0", + "events": "^3.1.0", + "parcel": "2.15.0", + "path-browserify": "^1.0.0", + "querystring-es3": "^0.2.1", + "stream-http": "^3.1.0", + "typescript": "^5.1.6", + "url": "^0.11.0" + }, + "dependencies": { + "@mjackson/node-fetch-server": "0.6.1", + "@parcel/runtime-rsc": "2.15.0", + "cross-env": "^7.0.3", + "express": "^4.21.2", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router": "workspace:*", + "react-server-dom-parcel": "^19.1.0" + } +} diff --git a/integration/helpers/rsc-parcel/public/favicon.ico b/integration/helpers/rsc-parcel/public/favicon.ico new file mode 100644 index 0000000000..5dbdfcddcb Binary files /dev/null and b/integration/helpers/rsc-parcel/public/favicon.ico differ diff --git a/integration/helpers/rsc-parcel/src/browser.tsx b/integration/helpers/rsc-parcel/src/browser.tsx new file mode 100644 index 0000000000..a4344c35e5 --- /dev/null +++ b/integration/helpers/rsc-parcel/src/browser.tsx @@ -0,0 +1,51 @@ +"use client-entry"; + +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import type { unstable_RSCPayload as RSCPayload } from "react-router"; +import { + unstable_createCallServer as createCallServer, + unstable_getRSCStream as getRSCStream, + unstable_RSCHydratedRouter as RSCHydratedRouter, +} from "react-router"; +import { + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, + // @ts-expect-error - no types for this yet +} from "react-server-dom-parcel/client"; +import { unstable_getContext } from "./config/unstable-get-context"; + +// 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: RSCPayload) => { + // @ts-expect-error - on 18 types, requires 19. + startTransition(async () => { + const formState = + payload.type === "render" ? await payload.formState : undefined; + + hydrateRoot( + document, + <StrictMode> + <RSCHydratedRouter + payload={payload} + createFromReadableStream={createFromReadableStream} + unstable_getContext={unstable_getContext} + /> + </StrictMode>, + { + // @ts-expect-error - no types for this yet + formState, + } + ); + }); +}); diff --git a/integration/helpers/rsc-parcel/src/config/basename.ts b/integration/helpers/rsc-parcel/src/config/basename.ts new file mode 100644 index 0000000000..17b384ae41 --- /dev/null +++ b/integration/helpers/rsc-parcel/src/config/basename.ts @@ -0,0 +1,2 @@ +// THIS FILE IS DESIGNED TO BE OVERRIDDEN IN TESTS IF NEEDED +export const basename = undefined; diff --git a/integration/helpers/rsc-parcel/src/config/request-context.ts b/integration/helpers/rsc-parcel/src/config/request-context.ts new file mode 100644 index 0000000000..517ea0e6ef --- /dev/null +++ b/integration/helpers/rsc-parcel/src/config/request-context.ts @@ -0,0 +1,2 @@ +// THIS FILE IS DESIGNED TO BE OVERRIDDEN IN TESTS IF NEEDED +export const requestContext = undefined; diff --git a/integration/helpers/rsc-parcel/src/config/unstable-get-context.ts b/integration/helpers/rsc-parcel/src/config/unstable-get-context.ts new file mode 100644 index 0000000000..2616a6a1b1 --- /dev/null +++ b/integration/helpers/rsc-parcel/src/config/unstable-get-context.ts @@ -0,0 +1,2 @@ +// THIS FILE IS DESIGNED TO BE OVERRIDDEN IN TESTS IF NEEDED +export const unstable_getContext = undefined; diff --git a/integration/helpers/rsc-parcel/src/parcel-entry-wrapper.ts b/integration/helpers/rsc-parcel/src/parcel-entry-wrapper.ts new file mode 100644 index 0000000000..9b9a30eaa8 --- /dev/null +++ b/integration/helpers/rsc-parcel/src/parcel-entry-wrapper.ts @@ -0,0 +1,5 @@ +"use server-entry"; + +import "./browser"; + +export function assets() {} diff --git a/integration/helpers/rsc-parcel/src/prerender.tsx b/integration/helpers/rsc-parcel/src/prerender.tsx new file mode 100644 index 0000000000..599935e692 --- /dev/null +++ b/integration/helpers/rsc-parcel/src/prerender.tsx @@ -0,0 +1,37 @@ +// @ts-expect-error - no types for this yet +import { renderToReadableStream as renderHTMLToReadableStream } from "react-dom/server.edge"; +import { + unstable_routeRSCServerRequest as routeRSCServerRequest, + unstable_RSCStaticRouter as RSCStaticRouter, +} from "react-router"; +// @ts-expect-error - no types for this yet +import { createFromReadableStream } from "react-server-dom-parcel/client.edge"; + +export async function prerender( + request: Request, + fetchServer: (request: Request) => Promise<Response>, + bootstrapScriptContent: string | undefined +): Promise<Response> { + return await routeRSCServerRequest({ + // The incoming request. + request, + // How to fetch from 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; + + return await renderHTMLToReadableStream( + <RSCStaticRouter getPayload={getPayload} />, + { + bootstrapScriptContent, + formState, + } + ); + }, + }); +} diff --git a/integration/helpers/rsc-parcel/src/routes.ts b/integration/helpers/rsc-parcel/src/routes.ts new file mode 100644 index 0000000000..6ea30ad68a --- /dev/null +++ b/integration/helpers/rsc-parcel/src/routes.ts @@ -0,0 +1,16 @@ +import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + +export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + ], + }, +] satisfies RSCRouteConfig; diff --git a/integration/helpers/rsc-parcel/src/routes/home.tsx b/integration/helpers/rsc-parcel/src/routes/home.tsx new file mode 100644 index 0000000000..b5e522802e --- /dev/null +++ b/integration/helpers/rsc-parcel/src/routes/home.tsx @@ -0,0 +1,3 @@ +export default function HomeRoute() { + return <h2>Home</h2>; +} diff --git a/integration/helpers/rsc-parcel/src/routes/root.tsx b/integration/helpers/rsc-parcel/src/routes/root.tsx new file mode 100644 index 0000000000..778a4858d6 --- /dev/null +++ b/integration/helpers/rsc-parcel/src/routes/root.tsx @@ -0,0 +1,22 @@ +import { Links, Outlet, ScrollRestoration } from "react-router"; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + <html lang="en"> + <head> + <meta charSet="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Vite (RSC) + + + + {children} + + + + ); +} + +export default function ServerComponent() { + return ; +} diff --git a/integration/helpers/rsc-parcel/src/server.tsx b/integration/helpers/rsc-parcel/src/server.tsx new file mode 100644 index 0000000000..0a022eb6f0 --- /dev/null +++ b/integration/helpers/rsc-parcel/src/server.tsx @@ -0,0 +1,83 @@ +import { parseArgs } from "node:util"; +import { createRequestListener } from "@mjackson/node-fetch-server"; +import express from "express"; +import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router"; +import { + createTemporaryReferenceSet, + decodeAction, + decodeFormState, + decodeReply, + loadServerAction, + renderToReadableStream, + // @ts-expect-error - no types for this yet +} from "react-server-dom-parcel/server.edge"; + +// Import the prerender function from the client environment +import { prerender } from "./prerender" with { env: "react-client" }; +import { routes } from "./routes"; +import { assets } from "./parcel-entry-wrapper" +import { basename } from "./config/basename"; +import { requestContext } from "./config/request-context"; + +function fetchServer(request: Request) { + return matchRSCServerRequest({ + // Provide the React Server touchpoints. + createTemporaryReferenceSet, + decodeReply, + decodeAction, + decodeFormState, + loadServerAction, + // The incoming request. + request, + requestContext, + // The app routes. + routes, + basename, + // Encode the match with the React Server implementation. + generateResponse(match, options) { + return new Response(renderToReadableStream(match.payload, options), { + status: match.statusCode, + headers: match.headers, + }); + }, + }); +} + +const app = express(); + +// Serve static assets with compression and long cache lifetime. +app.use( + "/client", + express.static("dist/client", { + immutable: true, + maxAge: "1y", + }) +); +app.use(express.static("public")); + +// Ignore Chrome extension requests. +app.get("/.well-known/appspecific/com.chrome.devtools.json", (_, res) => { + res.status(404); + res.end(); +}); + +// Hookup our application. +app.use( + createRequestListener((request) => + prerender( + request, + fetchServer, + (assets as unknown as { bootstrapScript?: string }).bootstrapScript + ) + ) +); + +const { values } = parseArgs({ + options: { p: { type: "string", default: process.env.RR_PORT || "3000" } }, + allowPositionals: true, +}); + +const port = parseInt(values.p, 10); +app.listen(port, () => { + console.log(`Server started on http://localhost:${port}`); +}); diff --git a/integration/helpers/rsc-parcel/tsconfig.json b/integration/helpers/rsc-parcel/tsconfig.json new file mode 100644 index 0000000000..009d4507cf --- /dev/null +++ b/integration/helpers/rsc-parcel/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "moduleResolution": "bundler", + "module": "esnext", + "isolatedModules": true, + "esModuleInterop": true, + "target": "es2022", + "noEmit": true + } +} diff --git a/integration/helpers/rsc-vite/.gitignore b/integration/helpers/rsc-vite/.gitignore new file mode 100644 index 0000000000..de4d1f007d --- /dev/null +++ b/integration/helpers/rsc-vite/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules diff --git a/integration/helpers/rsc-vite/package.json b/integration/helpers/rsc-vite/package.json new file mode 100644 index 0000000000..9058d98848 --- /dev/null +++ b/integration/helpers/rsc-vite/package.json @@ -0,0 +1,31 @@ +{ + "name": "integration-rsc-vite", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build --app", + "start": "cross-env NODE_ENV=production node server.js", + "typecheck": "tsc" + }, + "devDependencies": { + "@vitejs/plugin-rsc": "0.4.11", + "@types/express": "^5.0.0", + "@types/node": "^22.13.1", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "typescript": "^5.1.6", + "vite": "^6.2.0" + }, + "dependencies": { + "@mjackson/node-fetch-server": "0.6.1", + "compression": "^1.8.0", + "cross-env": "^7.0.3", + "express": "^4.21.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "workspace:*" + } +} diff --git a/integration/helpers/rsc-vite/public/favicon.ico b/integration/helpers/rsc-vite/public/favicon.ico new file mode 100644 index 0000000000..5dbdfcddcb Binary files /dev/null and b/integration/helpers/rsc-vite/public/favicon.ico differ diff --git a/integration/helpers/rsc-vite/server.js b/integration/helpers/rsc-vite/server.js new file mode 100644 index 0000000000..c897acd7ad --- /dev/null +++ b/integration/helpers/rsc-vite/server.js @@ -0,0 +1,28 @@ +import { parseArgs } from "node:util"; +import { createRequestListener } from "@mjackson/node-fetch-server"; +import compression from "compression"; +import express from "express"; + +import rscRequestHandler from "./dist/rsc/index.js"; + +const app = express(); + +app.use(compression()); +app.use(express.static("dist/client")); + +app.get("/.well-known/appspecific/com.chrome.devtools.json", (req, res) => { + res.status(404); + res.end(); +}); + +app.use(createRequestListener(rscRequestHandler)); + +const { values } = parseArgs({ + options: { p: { type: "string", default: "3000" } }, + allowPositionals: true, +}); + +const port = parseInt(values.p, 10); +app.listen(port, () => { + console.log(`Server started on http://localhost:${port}`); +}); diff --git a/integration/helpers/rsc-vite/src/config/basename.ts b/integration/helpers/rsc-vite/src/config/basename.ts new file mode 100644 index 0000000000..17b384ae41 --- /dev/null +++ b/integration/helpers/rsc-vite/src/config/basename.ts @@ -0,0 +1,2 @@ +// THIS FILE IS DESIGNED TO BE OVERRIDDEN IN TESTS IF NEEDED +export const basename = undefined; diff --git a/integration/helpers/rsc-vite/src/config/request-context.ts b/integration/helpers/rsc-vite/src/config/request-context.ts new file mode 100644 index 0000000000..517ea0e6ef --- /dev/null +++ b/integration/helpers/rsc-vite/src/config/request-context.ts @@ -0,0 +1,2 @@ +// THIS FILE IS DESIGNED TO BE OVERRIDDEN IN TESTS IF NEEDED +export const requestContext = undefined; diff --git a/integration/helpers/rsc-vite/src/config/unstable-get-context.ts b/integration/helpers/rsc-vite/src/config/unstable-get-context.ts new file mode 100644 index 0000000000..2616a6a1b1 --- /dev/null +++ b/integration/helpers/rsc-vite/src/config/unstable-get-context.ts @@ -0,0 +1,2 @@ +// THIS FILE IS DESIGNED TO BE OVERRIDDEN IN TESTS IF NEEDED +export const unstable_getContext = undefined; diff --git a/integration/helpers/rsc-vite/src/entry.browser.tsx b/integration/helpers/rsc-vite/src/entry.browser.tsx new file mode 100644 index 0000000000..469e4a156e --- /dev/null +++ b/integration/helpers/rsc-vite/src/entry.browser.tsx @@ -0,0 +1,38 @@ +import { startTransition, StrictMode } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + setServerCallback, +} from "@vitejs/plugin-rsc/browser"; +import { + unstable_createCallServer as createCallServer, + unstable_getRSCStream as getRSCStream, + unstable_RSCHydratedRouter as RSCHydratedRouter, +} from "react-router"; +import type { unstable_RSCPayload as RSCPayload } from "react-router"; +import { unstable_getContext } from "./config/unstable-get-context"; + +setServerCallback( + createCallServer({ + createFromReadableStream, + createTemporaryReferenceSet, + encodeReply, + }) +); + +createFromReadableStream(getRSCStream()).then((payload) => { + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); +}); diff --git a/integration/helpers/rsc-vite/src/entry.rsc.tsx b/integration/helpers/rsc-vite/src/entry.rsc.tsx new file mode 100644 index 0000000000..7087205214 --- /dev/null +++ b/integration/helpers/rsc-vite/src/entry.rsc.tsx @@ -0,0 +1,39 @@ +import { + createTemporaryReferenceSet, + decodeAction, + decodeReply, + loadServerAction, + renderToReadableStream, +} from "@vitejs/plugin-rsc/rsc"; +import { unstable_matchRSCServerRequest as matchRSCServerRequest } from "react-router"; +import { basename } from "./config/basename"; + +import { routes } from "./routes"; +import { requestContext } from "./config/request-context"; + +export async function fetchServer(request: Request) { + return await matchRSCServerRequest({ + createTemporaryReferenceSet, + decodeReply, + decodeAction, + loadServerAction, + request, + requestContext, + routes, + basename, + generateResponse(match, options) { + return new Response(renderToReadableStream(match.payload, options), { + status: match.statusCode, + headers: match.headers, + }); + }, + }); +} + +export default async function handler(request: Request) { + const ssr = await import.meta.viteRsc.loadModule< + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + typeof import("./entry.ssr") + >("ssr", "index"); + return ssr.default(request, fetchServer); +} diff --git a/integration/helpers/rsc-vite/src/entry.ssr.tsx b/integration/helpers/rsc-vite/src/entry.ssr.tsx new file mode 100644 index 0000000000..827bddf8c1 --- /dev/null +++ b/integration/helpers/rsc-vite/src/entry.ssr.tsx @@ -0,0 +1,29 @@ +import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr"; +// @ts-expect-error +import * as ReactDomServer from "react-dom/server.edge"; +import { + unstable_RSCStaticRouter as RSCStaticRouter, + unstable_routeRSCServerRequest as routeRSCServerRequest, +} from "react-router"; + +export default async function handler( + request: Request, + fetchServer: (request: Request) => Promise +) { + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent("index"); + return routeRSCServerRequest({ + request, + fetchServer, + createFromReadableStream, + renderHTML(getPayload) { + return ReactDomServer.renderToReadableStream( + , + { + bootstrapScriptContent, + signal: request.signal, + } + ); + }, + }); +} diff --git a/integration/helpers/rsc-vite/src/routes.ts b/integration/helpers/rsc-vite/src/routes.ts new file mode 100644 index 0000000000..6ea30ad68a --- /dev/null +++ b/integration/helpers/rsc-vite/src/routes.ts @@ -0,0 +1,16 @@ +import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + +export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + ], + }, +] satisfies RSCRouteConfig; diff --git a/integration/helpers/rsc-vite/src/routes/home.tsx b/integration/helpers/rsc-vite/src/routes/home.tsx new file mode 100644 index 0000000000..b5e522802e --- /dev/null +++ b/integration/helpers/rsc-vite/src/routes/home.tsx @@ -0,0 +1,3 @@ +export default function HomeRoute() { + return

Home

; +} diff --git a/integration/helpers/rsc-vite/src/routes/root.tsx b/integration/helpers/rsc-vite/src/routes/root.tsx new file mode 100644 index 0000000000..d498b32ffa --- /dev/null +++ b/integration/helpers/rsc-vite/src/routes/root.tsx @@ -0,0 +1,22 @@ +import { Links, Outlet, ScrollRestoration } from "react-router"; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + Vite (RSC) + + + + {children} + + + + ); +} + +export default function RootRoute() { + return ; +} diff --git a/integration/helpers/rsc-vite/tsconfig.json b/integration/helpers/rsc-vite/tsconfig.json new file mode 100644 index 0000000000..77438d9dbe --- /dev/null +++ b/integration/helpers/rsc-vite/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/integration/helpers/rsc-vite/vite.config.ts b/integration/helpers/rsc-vite/vite.config.ts new file mode 100644 index 0000000000..ecffb9c6c1 --- /dev/null +++ b/integration/helpers/rsc-vite/vite.config.ts @@ -0,0 +1,16 @@ +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/entry.browser.tsx", + rsc: "src/entry.rsc.tsx", + ssr: "src/entry.ssr.tsx", + }, + }), + ], +}); diff --git a/integration/helpers/vite-5-template/package.json b/integration/helpers/vite-5-template/package.json index a5472d6169..4a2f9ca24f 100644 --- a/integration/helpers/vite-5-template/package.json +++ b/integration/helpers/vite-5-template/package.json @@ -14,8 +14,8 @@ "@react-router/express": "workspace:*", "@react-router/node": "workspace:*", "@react-router/serve": "workspace:*", - "@vanilla-extract/css": "^1.10.0", - "@vanilla-extract/vite-plugin": "^3.9.2", + "@vanilla-extract/css": "^1.17.4", + "@vanilla-extract/vite-plugin": "^5.1.0", "express": "^4.19.2", "isbot": "^5.1.11", "react": "^19.1.0", diff --git a/integration/helpers/vite-6-template/package.json b/integration/helpers/vite-6-template/package.json index c2fad917f2..334e4f6957 100644 --- a/integration/helpers/vite-6-template/package.json +++ b/integration/helpers/vite-6-template/package.json @@ -14,8 +14,8 @@ "@react-router/express": "workspace:*", "@react-router/node": "workspace:*", "@react-router/serve": "workspace:*", - "@vanilla-extract/css": "^1.10.0", - "@vanilla-extract/vite-plugin": "^3.9.2", + "@vanilla-extract/css": "^1.17.4", + "@vanilla-extract/vite-plugin": "^5.1.0", "express": "^4.19.2", "isbot": "^5.1.11", "react": "^19.1.0", diff --git a/integration/helpers/vite-7-beta-template/package.json b/integration/helpers/vite-7-beta-template/package.json index b374c7edd2..c3e9f8a657 100644 --- a/integration/helpers/vite-7-beta-template/package.json +++ b/integration/helpers/vite-7-beta-template/package.json @@ -14,8 +14,8 @@ "@react-router/express": "workspace:*", "@react-router/node": "workspace:*", "@react-router/serve": "workspace:*", - "@vanilla-extract/css": "^1.10.0", - "@vanilla-extract/vite-plugin": "^3.9.2", + "@vanilla-extract/css": "^1.17.4", + "@vanilla-extract/vite-plugin": "^5.1.0", "express": "^4.19.2", "isbot": "^5.1.11", "react": "^19.1.0", diff --git a/integration/helpers/vite-plugin-cloudflare-template/package.json b/integration/helpers/vite-plugin-cloudflare-template/package.json index 504b4ec134..7d9afd57d2 100644 --- a/integration/helpers/vite-plugin-cloudflare-template/package.json +++ b/integration/helpers/vite-plugin-cloudflare-template/package.json @@ -19,7 +19,7 @@ "serialize-javascript": "^6.0.1" }, "devDependencies": { - "@cloudflare/vite-plugin": "^0.1.13", + "@cloudflare/vite-plugin": "^1.9.0", "@react-router/dev": "workspace:*", "@react-router/fs-routes": "workspace:*", "@types/node": "^20.0.0", @@ -29,7 +29,7 @@ "typescript": "^5.1.6", "vite": "^6.1.0", "vite-tsconfig-paths": "^4.2.1", - "wrangler": "^4.2.0" + "wrangler": "^4.23.0" }, "engines": { "node": ">=20.0.0" diff --git a/integration/helpers/vite-rolldown-template/package.json b/integration/helpers/vite-rolldown-template/package.json index e1138e2514..55928daae4 100644 --- a/integration/helpers/vite-rolldown-template/package.json +++ b/integration/helpers/vite-rolldown-template/package.json @@ -14,8 +14,8 @@ "@react-router/express": "workspace:*", "@react-router/node": "workspace:*", "@react-router/serve": "workspace:*", - "@vanilla-extract/css": "^1.10.0", - "@vanilla-extract/vite-plugin": "^3.9.2", + "@vanilla-extract/css": "^1.17.4", + "@vanilla-extract/vite-plugin": "^5.1.0", "express": "^4.19.2", "isbot": "^5.1.11", "react": "^19.1.0", diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts index 862877b916..4ed5081f61 100644 --- a/integration/helpers/vite.ts +++ b/integration/helpers/vite.ts @@ -1,4 +1,5 @@ -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import { sync as spawnSync, spawn } from "cross-spawn"; import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { platform } from "node:os"; @@ -71,6 +72,7 @@ type ViteConfigServerArgs = { type ViteConfigBuildArgs = { assetsInlineLimit?: number; assetsDir?: string; + cssCodeSplit?: boolean; }; type ViteConfigBaseArgs = { @@ -98,7 +100,11 @@ export const viteConfig = { `; return text; }, - build: ({ assetsInlineLimit, assetsDir }: ViteConfigBuildArgs = {}) => { + build: ({ + assetsInlineLimit, + assetsDir, + cssCodeSplit, + }: ViteConfigBuildArgs = {}) => { return dedent` build: { // Detect rolldown-vite. This should ideally use "rolldownVersion" @@ -115,6 +121,9 @@ export const viteConfig = { : undefined, assetsInlineLimit: ${assetsInlineLimit ?? "undefined"}, assetsDir: ${assetsDir ? `"${assetsDir}"` : "undefined"}, + cssCodeSplit: ${ + cssCodeSplit !== undefined ? cssCodeSplit : "undefined" + }, }, `; }, @@ -185,14 +194,27 @@ export const EXPRESS_SERVER = (args: { app.listen(port, () => console.log('http://localhost/' + port)); `; -export type TemplateName = - | "cloudflare-dev-proxy-template" +type FrameworkModeViteMajorTemplateName = | "vite-5-template" | "vite-6-template" | "vite-7-beta-template" | "vite-plugin-cloudflare-template" | "vite-rolldown-template"; +type FrameworkModeRscTemplateName = "rsc-parcel-framework"; + +type FrameworkModeCloudflareTemplateName = + | "cloudflare-dev-proxy-template" + | "vite-plugin-cloudflare-template"; + +export type RscBundlerTemplateName = "rsc-vite" | "rsc-parcel"; + +export type TemplateName = + | FrameworkModeViteMajorTemplateName + | FrameworkModeRscTemplateName + | FrameworkModeCloudflareTemplateName + | RscBundlerTemplateName; + export const viteMajorTemplates = [ { templateName: "vite-5-template", templateDisplayName: "Vite 5" }, { templateName: "vite-6-template", templateDisplayName: "Vite 6" }, @@ -202,7 +224,15 @@ export const viteMajorTemplates = [ templateDisplayName: "Vite Rolldown", }, ] as const satisfies Array<{ - templateName: TemplateName; + templateName: FrameworkModeViteMajorTemplateName; + templateDisplayName: string; +}>; + +export const rscBundlerTemplates = [ + { templateName: "rsc-vite", templateDisplayName: "RSC (Vite)" }, + { templateName: "rsc-parcel", templateDisplayName: "RSC (Parcel)" }, +] as const satisfies Array<{ + templateName: RscBundlerTemplateName; templateDisplayName: string; }>; @@ -320,7 +350,7 @@ type ServerArgs = { basename?: string; }; -const createDev = +export const createDev = (nodeArgs: string[]) => async ({ cwd, port, env, basename }: ServerArgs): Promise<() => unknown> => { let proc = node(nodeArgs, { cwd, env }); @@ -460,7 +490,9 @@ async function waitForServer( await waitOn({ resources: [ - `http://${args.host ?? "localhost"}:${args.port}${args.basename ?? "/"}`, + `http://${args.host ?? "localhost"}:${args.port}${ + args.basename ?? "/favicon.ico" + }`, ], timeout: platform() === "win32" ? 20000 : 10000, }).catch((err) => { diff --git a/integration/package.json b/integration/package.json index 36ec79ee46..db79b45df4 100644 --- a/integration/package.json +++ b/integration/package.json @@ -8,15 +8,20 @@ "typecheck": "tsc" }, "dependencies": { + "@mdx-js/rollup": "^3.1.0", "@playwright/test": "^1.49.1", "@react-router/dev": "workspace:*", "@react-router/express": "workspace:*", "@react-router/node": "workspace:*", + "@types/cross-spawn": "^6.0.6", "@types/dedent": "^0.7.0", "@types/express": "^4.17.9", - "@types/wait-on": "^5.3.2", - "@vanilla-extract/css": "^1.10.0", - "@vanilla-extract/vite-plugin": "^3.9.2", + "@types/glob": "^8.1.0", + "@types/semver": "^7.7.0", + "@types/shelljs": "^0.8.16", + "@types/wait-on": "^5.3.4", + "@vanilla-extract/css": "^1.17.4", + "@vanilla-extract/vite-plugin": "^5.1.0", "cheerio": "^1.0.0-rc.12", "cross-spawn": "^7.0.3", "dedent": "^0.7.0", @@ -32,6 +37,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-router": "workspace:*", + "semver": "^7.7.2", "serialize-javascript": "^6.0.1", "shelljs": "^0.8.5", "strip-ansi": "^6.0.1", diff --git a/integration/playwright.config.ts b/integration/playwright.config.ts index da6159be6b..965aacd57d 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -18,7 +18,8 @@ const config: PlaywrightTestConfig = { }, /* Maximum time one test can run for. */ timeout: isWindows ? 60_000 : 30_000, - fullyParallel: true, + fullyParallel: !(isWindows && process.env.CI), + workers: isWindows && process.env.CI ? 1 : undefined, expect: { /* Maximum time expect() should wait for the condition to be met. */ timeout: isWindows ? 10_000 : 5_000, diff --git a/integration/redirects-test.ts b/integration/redirects-test.ts index f3eefd7963..9c71b4b2c5 100644 --- a/integration/redirects-test.ts +++ b/integration/redirects-test.ts @@ -7,179 +7,302 @@ import { } from "./helpers/create-fixture.js"; import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import type { TemplateName } from "./helpers/vite.js"; + +const templateNames = [ + "vite-5-template", + "rsc-parcel-framework", +] as const satisfies TemplateName[]; test.describe("redirects", () => { - let fixture: Fixture; - let appFixture: AppFixture; - - test.beforeAll(async () => { - fixture = await createFixture({ - files: { - "app/routes/absolute.tsx": js` - import * as React from 'react'; - import { Outlet } from "react-router"; - - export default function Component() { - let [count, setCount] = React.useState(0); - return ( - <> - - - - ); - } - `, + for (const templateName of templateNames) { + test.describe(`template: ${templateName}`, () => { + let fixture: Fixture; + let appFixture: AppFixture; - "app/routes/absolute._index.tsx": js` - import { redirect, Form } from "react-router"; + test.beforeAll(async () => { + fixture = await createFixture({ + templateName, + files: { + "app/routes/absolute.tsx": js` + import * as React from 'react'; + import { Outlet } from "react-router"; - export async function action({ request }) { - return redirect(new URL(request.url).origin + "/absolute/landing"); - }; + export default function Component() { + let [count, setCount] = React.useState(0); + return ( + <> + + + + ); + } + `, - export default function Component() { - return ( -
- -
- ); - } - `, + "app/routes/absolute._index.tsx": js` + import { redirect, Form } from "react-router"; - "app/routes/absolute.landing.tsx": js` - export default function Component() { - return

Landing

- } - `, + export async function action({ request }) { + return redirect(new URL(request.url).origin + "/absolute/landing"); + }; - "app/routes/loader.external.ts": js` - import { redirect } from "react-router"; - export const loader = () => { - return redirect("https://remix.run/"); - } - `, - - "app/routes/redirect-document.tsx": js` - import * as React from "react"; - import { Outlet } from "react-router"; - - export default function Component() { - let [count, setCount] = React.useState(0); - let countText = 'Count:' + count; - return ( - <> - - - - ); - } - `, + export default function Component() { + return ( +
+ +
+ ); + } + `, - "app/routes/redirect-document._index.tsx": js` - import { Link } from "react-router"; + "app/routes/absolute.landing.tsx": js` + export default function Component() { + return

Landing

+ } + `, - export default function Component() { - return Link - } - `, + "app/routes/absolute.content-length.tsx": js` + import { redirect, Form } from "react-router"; + export async function action({ request }) { + return redirect(new URL(request.url).origin + "/absolute/landing", { + headers: { 'Content-Length': '0' } + }); + }; + export default function Component() { + return ( +
+ +
+ ); + } + `, - "app/routes/redirect-document.a.tsx": js` - import { redirectDocument } from "react-router"; - export const loader = () => redirectDocument("/redirect-document/b"); - `, + "app/routes/loader.external.ts": js` + import { redirect } from "react-router"; + export const loader = () => { + return redirect("https://reactrouter.com/"); + } + `, - "app/routes/redirect-document.b.tsx": js` - export default function Component() { - return

Hello B!

- } - `, + "app/routes/redirect-document.tsx": js` + import * as React from "react"; + import { Outlet } from "react-router"; - "app/routes/replace.a.tsx": js` - import { Link } from "react-router"; - export default function () { - return <>

A

Go to B; - } - `, + export default function Component() { + let [count, setCount] = React.useState(0); + let countText = 'Count:' + count; + return ( + <> + + + + ); + } + `, - "app/routes/replace.b.tsx": js` - import { Link } from "react-router"; - export default function () { - return <>

B

Go to C - } - `, + "app/routes/redirect-document._index.tsx": js` + import { Link } from "react-router"; - "app/routes/replace.c.tsx": js` - import { replace } from "react-router"; - export const loader = () => replace("/replace/d"); - export default function () { - return

C

- } - `, + export default function Component() { + return Link + } + `, + + "app/routes/redirect-document.a.tsx": js` + import { redirectDocument } from "react-router"; + export const loader = () => redirectDocument("/redirect-document/b"); + `, + + "app/routes/redirect-document.b.tsx": js` + export default function Component() { + return

Hello B!

+ } + `, + + "app/routes/replace.a.tsx": js` + import { Link } from "react-router"; + export default function () { + return <>

A

Go to B; + } + `, + + "app/routes/replace.b.tsx": js` + import { Link } from "react-router"; + export default function () { + return <>

B

Go to C + } + `, + + "app/routes/replace.c.tsx": js` + import { replace } from "react-router"; + export const loader = () => replace("/replace/d"); + export default function () { + return

C

+ } + `, + + "app/routes/replace.d.tsx": js` + export default function () { + return

D

+ } + `, + + "app/routes/headers.tsx": js` + import * as React from 'react'; + import { Link, Form, redirect, useLocation } from 'react-router'; - "app/routes/replace.d.tsx": js` - export default function () { - return

D

+ export function action() { + return redirect('/headers?action-redirect', { + headers: { 'X-Test': 'Foo' } + }) + } + + export function loader({ request }) { + let url = new URL(request.url); + if (url.searchParams.has('redirect')) { + return redirect('/headers?loader-redirect', { + headers: { 'X-Test': 'Foo' } + }) + } + return null + } + + export default function Component() { + let location = useLocation() + return ( + <> + Redirect +
+ +
+

+ Search Params: {location.search} +

+ + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("redirects to external URLs", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.waitForNetworkAfter(() => app.goto("/loader/external")); + expect(app.page.url()).toBe("https://reactrouter.com/"); + }); + + test("redirects to absolute URLs in the app with a SPA navigation", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/absolute`, true); + await app.clickElement("#increment"); + expect(await app.getHtml("#increment")).toMatch("Count:1"); + await app.waitForNetworkAfter(() => + app.clickSubmitButton("/absolute?index") + ); + await page.waitForSelector(`h1:has-text("Landing")`); + // No hard reload + expect(await app.getHtml("#increment")).toMatch("Count:1"); + }); + + test("redirects to absolute URLs in the app with a SPA navigation and Content-Length header", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/absolute/content-length`, true); + await app.clickElement("#increment"); + expect(await app.getHtml("#increment")).toMatch("Count:1"); + await app.waitForNetworkAfter(() => + app.clickSubmitButton("/absolute/content-length") + ); + await page.waitForSelector(`h1:has-text("Landing")`); + // No hard reload + expect(await app.getHtml("#increment")).toMatch("Count:1"); + }); + + test("supports hard redirects within the app via reloadDocument", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect-document", true); + expect(await app.getHtml("button")).toMatch("Count:0"); + await app.clickElement("button"); + expect(await app.getHtml("button")).toMatch("Count:1"); + await app.waitForNetworkAfter(() => + app.clickLink("/redirect-document/a") + ); + await page.waitForSelector('h1:has-text("Hello B!")'); + // Hard reload resets client side react state + expect(await app.getHtml("button")).toMatch("Count:0"); + }); + + test("supports replace redirects within the app", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/replace/a", true); + await page.waitForSelector("#a"); // [/a] + await app.clickLink("/replace/b"); + await page.waitForSelector("#b"); // [/a, /b] + await app.clickLink("/replace/c"); + await page.waitForSelector("#d"); // [/a, /d] + await page.goBack(); + await page.waitForSelector("#a"); // [/a] + }); + + test("maintains custom headers on redirects", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + + let hasGetHeader = false; + let hasPostHeader = false; + page.on("request", async (request) => { + let extension = /^rsc/.test(templateName) ? "rsc" : "data"; + if ( + request.method() === "GET" && + request.url().endsWith(`headers.${extension}?redirect=`) + ) { + const headers = (await request.response())?.headers() || {}; + hasGetHeader = headers["x-test"] === "Foo"; } - `, - }, - }); + if ( + request.method() === "POST" && + request.url().endsWith(`headers.${extension}`) + ) { + const headers = (await request.response())?.headers() || {}; + hasPostHeader = headers["x-test"] === "Foo"; + } + }); + + await app.goto("/headers", true); + await app.clickElement("#loader-redirect"); + await expect(page.locator("#search-params")).toHaveText( + "Search Params: ?loader-redirect" + ); + expect(hasGetHeader).toBe(true); + expect(hasPostHeader).toBe(false); - appFixture = await createAppFixture(fixture); - }); - - test.afterAll(() => { - appFixture.close(); - }); - - test("redirects to external URLs", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - - await app.waitForNetworkAfter(() => app.goto("/loader/external")); - expect(app.page.url()).toBe("https://remix.run/"); - }); - - test("redirects to absolute URLs in the app with a SPA navigation", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto(`/absolute`, true); - await app.clickElement("#increment"); - expect(await app.getHtml("#increment")).toMatch("Count:1"); - await app.waitForNetworkAfter(() => - app.clickSubmitButton("/absolute?index") - ); - await page.waitForSelector(`h1:has-text("Landing")`); - // No hard reload - expect(await app.getHtml("#increment")).toMatch("Count:1"); - }); - - test("supports hard redirects within the app via reloadDocument", async ({ - page, - }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/redirect-document", true); - expect(await app.getHtml("button")).toMatch("Count:0"); - await app.clickElement("button"); - expect(await app.getHtml("button")).toMatch("Count:1"); - await app.waitForNetworkAfter(() => app.clickLink("/redirect-document/a")); - await page.waitForSelector('h1:has-text("Hello B!")'); - // Hard reload resets client side react state - expect(await app.getHtml("button")).toMatch("Count:0"); - }); - - test("supports replace redirects within the app", async ({ page }) => { - let app = new PlaywrightFixture(appFixture, page); - await app.goto("/replace/a", true); - await page.waitForSelector("#a"); // [/a] - await app.clickLink("/replace/b"); - await page.waitForSelector("#b"); // [/a, /b] - await app.clickLink("/replace/c"); - await page.waitForSelector("#d"); // [/a, /d] - await page.goBack(); - await page.waitForSelector("#a"); // [/a] - }); + hasGetHeader = false; + hasPostHeader = false; + + await app.goto("/headers", true); + await app.clickElement("#action-redirect"); + await expect(page.locator("#search-params")).toHaveText( + "Search Params: ?action-redirect" + ); + expect(hasGetHeader).toBe(false); + expect(hasPostHeader).toBe(true); + }); + }); + } }); diff --git a/integration/revalidate-test.ts b/integration/revalidate-test.ts index b14c73ff5d..5ccdfa79b8 100644 --- a/integration/revalidate-test.ts +++ b/integration/revalidate-test.ts @@ -77,7 +77,7 @@ test.describe("Revalidation", () => { let data = useLoaderData(); return ( <> -

{'Value:' + data.value}

+

{'Value:' + data.value}

); @@ -122,7 +122,7 @@ test.describe("Revalidation", () => { let revalidator = useRevalidator(); return ( <> -

{'Value:' + data.value}

+

{'Value:' + data.value}

@@ -168,37 +168,59 @@ test.describe("Revalidation", () => { // Should call parent (first load) await app.clickLink("/parent"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call child (first load) but not parent (no param) await app.clickLink("/parent/child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call neither await app.clickLink("/parent/child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call both await app.clickLink("/parent/child?revalidate=parent,child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:2"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); // Should call parent only await app.clickLink("/parent/child?revalidate=parent"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); // Should call child only await app.clickLink("/parent/child?revalidate=child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); }); test("Revalidates according to shouldRevalidate (submission navigations)", async ({ @@ -210,32 +232,52 @@ test.describe("Revalidation", () => { // Should call both (first load) await app.clickLink("/parent/child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call neither await app.clickElement("#submit-neither"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call both await app.clickElement("#submit-both"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:2"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); // Should call parent only await app.clickElement("#submit-parent"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); // Should call child only await app.clickElement("#submit-child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); }); test("Revalidates on demand with useRevalidator", async ({ page }) => { @@ -245,49 +287,81 @@ test.describe("Revalidation", () => { // Should call both (first load) await app.clickLink("/parent/child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call neither on manual revalidate (no params) await app.clickElement("#revalidate"); await page.waitForSelector("#revalidation-idle", { state: "visible" }); - expect(await app.getHtml("#parent-data")).toMatch("Value:1"); - expect(await app.getHtml("#child-data")).toMatch("Value:1"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:1" }) + ).toBeAttached(); // Should call both await app.clickLink("/parent/child?revalidate=parent,child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:2"); - expect(await app.getHtml("#child-data")).toMatch("Value:2"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:2" }) + ).toBeAttached(); // Should call both on manual revalidate await app.clickElement("#revalidate"); await page.waitForSelector("#revalidation-idle", { state: "visible" }); - expect(await app.getHtml("#parent-data")).toMatch("Value:3"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); // Should call parent only await app.clickLink("/parent/child?revalidate=parent"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:4"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:4" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); // Should call parent only on manual revalidate await app.clickElement("#revalidate"); await page.waitForSelector("#revalidation-idle", { state: "visible" }); - expect(await app.getHtml("#parent-data")).toMatch("Value:5"); - expect(await app.getHtml("#child-data")).toMatch("Value:3"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:5" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:3" }) + ).toBeAttached(); // Should call child only await app.clickLink("/parent/child?revalidate=child"); await page.waitForSelector("#idle"); - expect(await app.getHtml("#parent-data")).toMatch("Value:5"); - expect(await app.getHtml("#child-data")).toMatch("Value:4"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:5" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:4" }) + ).toBeAttached(); // Should call child only on manual revalidate await app.clickElement("#revalidate"); await page.waitForSelector("#revalidation-idle", { state: "visible" }); - expect(await app.getHtml("#parent-data")).toMatch("Value:5"); - expect(await app.getHtml("#child-data")).toMatch("Value:5"); + await expect( + page.getByTestId("parent-data").filter({ hasText: "Value:5" }) + ).toBeAttached(); + await expect( + page.getByTestId("child-data").filter({ hasText: "Value:5" }) + ).toBeAttached(); }); }); diff --git a/integration/rsc/rsc-test.ts b/integration/rsc/rsc-test.ts new file mode 100644 index 0000000000..decee485c4 --- /dev/null +++ b/integration/rsc/rsc-test.ts @@ -0,0 +1,2531 @@ +import { test, expect } from "@playwright/test"; +import { sync as spawnSync } from "cross-spawn"; +import getPort from "get-port"; + +import { + type TemplateName, + createDev, + createProject, +} from "../helpers/vite.js"; + +const js = String.raw; + +type Implementation = { + name: string; + template: TemplateName; + /** Build a production app */ + build: ({ cwd }: { cwd: string }) => ReturnType; + /** Run a production app */ + run: ({ cwd, port }: { cwd: string; port: number }) => Promise<() => void>; + /** Run the dev server */ + dev: ({ cwd, port }: { cwd: string; port: number }) => Promise<() => void>; +}; + +// Run tests against vite and parcel to ensure our code is bundler agnostic +const implementations: Implementation[] = [ + { + name: "vite", + template: "rsc-vite", + build: ({ cwd }: { cwd: string }) => spawnSync("pnpm", ["build"], { cwd }), + run: ({ cwd, port }) => + createDev(["server.js", "-p", String(port)])({ + cwd, + port, + env: { + NODE_ENV: "production", + }, + }), + dev: ({ cwd, port }) => + createDev(["node_modules/vite/bin/vite.js", "--port", String(port)])({ + cwd, + port, + }), + }, + { + name: "parcel", + template: "rsc-parcel", + build: ({ cwd }: { cwd: string }) => spawnSync("pnpm", ["build"], { cwd }), + run: ({ cwd, port }) => + createDev(["dist/server/server.js", "-p", String(port)])({ + cwd, + port, + env: { + NODE_ENV: "production", + }, + }), + dev: ({ cwd, port }) => + createDev(["node_modules/parcel/lib/bin.js"])({ + // Since we run through parcels dev server we can't use `-p` because that + // only changes the dev server and doesn't pass through to the internal + // server. So we setup the internal server to choose from `RR_PORT` + env: { RR_PORT: String(port) }, + cwd, + port, + }), + }, +] as Implementation[]; + +async function setupRscTest({ + implementation, + port, + dev, + files, +}: { + implementation: Implementation; + port: number; + dev?: boolean; + files: Record; +}) { + let cwd = await createProject(files, implementation.template); + + let { error, status, stderr, stdout } = implementation.build({ cwd }); + if (status !== 0) { + console.error("Error building project", { + status, + error, + stdout: stdout?.toString(), + stderr: stderr?.toString(), + }); + throw new Error("Error building project"); + } + return dev + ? implementation.dev({ cwd, port }) + : implementation.run({ cwd, port }); +} + +const validateRSCHtml = (html: string) => + expect(html).toMatch(/\(self\.__FLIGHT_DATA\|\|=\[\]\)\.push\(/); + +implementations.forEach((implementation) => { + let stop: () => void; + + test.afterEach(() => { + stop?.(); + }); + + test.describe(`RSC (${implementation.name})`, () => { + test.describe("Basic functionality", () => { + test("Renders a page using server components", async ({ page }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes/home.tsx": js` + export function loader() { + return { message: "Loader Data" }; + } + export default function HomeRoute({ loaderData }) { + return

Home: {loaderData.message}

; + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + await page.waitForSelector("[data-home]"); + expect(await page.locator("[data-home]").textContent()).toBe( + "Home: Loader Data" + ); + + // Ensure this is actually using RSC lol + validateRSCHtml(await page.content()); + }); + + test("Works with client components using 'use client'", async ({ + page, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes/home.tsx": js` + import { ClientCounter } from "./home.client"; + + export function loader() { + return { message: "Loader Data" }; + } + + export default function HomeRoute({ loaderData }) { + return ( +
+

Home: {loaderData.message}

+ +
+ ); + } + `, + "src/routes/home.client.tsx": js` + "use client"; + + import { useState } from "react"; + + export function ClientCounter() { + const [count, setCount] = useState(0); + + return ( +
+

{count}

+ +
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Verify server component rendered + await page.waitForSelector("[data-home]"); + expect(await page.locator("[data-home]").textContent()).toBe( + "Home: Loader Data" + ); + + // Verify client component rendered + await page.waitForSelector("[data-testid=client-component]"); + expect(await page.locator("[data-count]").textContent()).toBe("0"); + + // Test interactivity of client component + await page.click("[data-increment]"); + expect(await page.locator("[data-count]").textContent()).toBe("1"); + + // Click again to ensure it's truly interactive + await page.click("[data-increment]"); + expect(await page.locator("[data-count]").textContent()).toBe("2"); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports navigating between server-first/client-first routes starting on a server route", async ({ + page, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + { + id: "dashboard", + path: "dashboard", + lazy: () => import("./routes/dashboard"), + }, + ], + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/home.tsx": js` + import { Link } from "react-router"; + + export function loader() { + return { message: "Home Page Data" }; + } + + export default function HomeRoute({ loaderData }) { + return ( +
+

Home Page

+

{loaderData.message}

+ Dashboard +
+ ); + } + `, + "src/routes/dashboard.tsx": js` + export function loader() { + return { count: 1 }; + } + + export { default } from "./dashboard.client"; + `, + "src/routes/dashboard.client.tsx": js` + "use client"; + + import { useState } from "react"; + import { Link } from "react-router"; + + // Export the entire route as a client component + export default function DashboardRoute({ loaderData }) { + const [count, setCount] = useState(loaderData.count); + + return ( +
+

Dashboard

+ + {/* Server data rendered in client component */} +

+ Server count: {loaderData.count} +

+ + {/* Client interactive elements */} +

+ Client count: {count} +

+ + + + Home +
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Load a server route + await page.waitForSelector("[data-page=home]"); + expect(await page.locator("[data-content]").textContent()).toBe( + "Home Page Data" + ); + + // Navigate to a client route + await page.click("a[href='https://redirect.github.com/dashboard']"); + await page.waitForSelector("[data-page=dashboard]"); + + // Verify server data + expect(await page.locator("[data-server-count]").textContent()).toBe( + "Server count: 1" + ); + expect(await page.locator("[data-client-count]").textContent()).toBe( + "Client count: 1" + ); + + // Increment via the client component + await page.click("[data-increment]"); + expect(await page.locator("[data-server-count]").textContent()).toBe( + "Server count: 1" + ); + expect(await page.locator("[data-client-count]").textContent()).toBe( + "Client count: 2" + ); + + // Navigate back to a server route + await page.click("a[href='https://redirect.github.com/']"); + await page.waitForSelector("[data-page=home]"); + expect(await page.locator("[data-content]").textContent()).toBe( + "Home Page Data" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports navigating between server-first/client-first routes starting on a client route", async ({ + page, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + { + id: "dashboard", + path: "dashboard", + lazy: () => import("./routes/dashboard"), + }, + ], + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/home.tsx": js` + import { Link } from "react-router"; + + export function loader() { + return { message: "Home Page Data" }; + } + + export default function HomeRoute({ loaderData }) { + return ( +
+

Home Page

+

{loaderData.message}

+ Dashboard +
+ ); + } + `, + "src/routes/dashboard.tsx": js` + export function loader() { + return { count: 1 }; + } + + export { Dashboard as Component } from "./dashboard.client"; + `, + "src/routes/dashboard.client.tsx": js` + "use client"; + + import { useState } from "react"; + import { Link } from "react-router"; + + // Export the entire route as a client component + export function Dashboard({ loaderData }) { + const [count, setCount] = useState(loaderData.count); + + return ( +
+

Dashboard

+ + {/* Server data rendered in client component */} +

+ Server count: {loaderData.count} +

+ + {/* Client interactive elements */} +

+ Client count: {count} +

+ + + + Home +
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/dashboard`); + await page.waitForSelector("[data-page=dashboard]"); + + // Verify server data + expect(await page.locator("[data-server-count]").textContent()).toBe( + "Server count: 1" + ); + expect(await page.locator("[data-client-count]").textContent()).toBe( + "Client count: 1" + ); + + // Increment via the client component + await page.click("[data-increment]"); + expect(await page.locator("[data-server-count]").textContent()).toBe( + "Server count: 1" + ); + expect(await page.locator("[data-client-count]").textContent()).toBe( + "Client count: 2" + ); + + // Navigate to a server route + await page.click("a[href='https://redirect.github.com/']"); + await page.waitForSelector("[data-page=home]"); + expect(await page.locator("[data-content]").textContent()).toBe( + "Home Page Data" + ); + + // Navigate back to a client route + await page.click("a[href='https://redirect.github.com/dashboard']"); + await page.waitForSelector("[data-page=dashboard]"); + expect(await page.locator("[data-server-count]").textContent()).toBe( + "Server count: 1" + ); + expect(await page.locator("[data-client-count]").textContent()).toBe( + "Client count: 1" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports request context using the unstable_RouterContextProvider API", async ({ + page, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/config/request-context.ts": js` + // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION + import { unstable_createContext, unstable_RouterContextProvider } from "react-router"; + + export const testContext = unstable_createContext("default-value"); + + export const requestContext = new unstable_RouterContextProvider( + new Map([[testContext, "test-context-value"]]) + ); + `, + "src/routes/home.tsx": js` + import { testContext } from "../config/request-context"; + + export function loader({ context }) { + return { contextValue: context.get(testContext) }; + } + + export default function HomeRoute({ loaderData }) { + return ( +
+

Home: {loaderData.contextValue}

+
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + await page.waitForSelector("[data-home]"); + expect(await page.locator("[data-home]").textContent()).toBe( + "Home: test-context-value" + ); + + // Ensure this is actually using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports request context in resource routes using the unstable_RouterContextProvider API", async ({ + page, + request, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/config/request-context.ts": js` + // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION + import { unstable_createContext, unstable_RouterContextProvider } from "react-router"; + + export const testContext = unstable_createContext("default-value"); + + export const requestContext = new unstable_RouterContextProvider( + new Map([[testContext, "test-context-value"]]) + ); + `, + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + ], + }, + { + id: "resource", + path: "resource", + lazy: () => import("./routes/resource"), + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/home.client.tsx": js` + "use client"; + + import { useFetcher } from "react-router"; + + export function ResourceFetcher() { + const fetcher = useFetcher(); + + const loadResource = () => { + fetcher.submit({ hello: "world" }, { method: "post", action: "/resource" }); + }; + + return ( +
+ + {!!fetcher.data && ( +
+                        {JSON.stringify(fetcher.data)}
+                      
+ )} +
+ ); + } + `, + "src/routes/home.tsx": js` + import { ResourceFetcher } from "./home.client"; + + export default function HomeRoute() { + return ; + } + `, + "src/routes/resource.tsx": js` + import { testContext } from "../config/request-context"; + + export function loader({ context }) { + return Response.json({ + message: "Hello from resource route!", + contextValue: context.get(testContext) + }); + } + + export async function action({ context }) { + return Response.json({ + message: "Hello from resource route!", + contextValue: context.get(testContext), + }); + } + `, + }, + }); + + const getResponse = await request.get( + `http://localhost:${port}/resource` + ); + expect(getResponse?.status()).toBe(200); + expect((await getResponse?.json()).contextValue).toBe( + "test-context-value" + ); + + const postResponse = await request.post( + `http://localhost:${port}/resource` + ); + expect(postResponse?.status()).toBe(200); + expect((await postResponse?.json()).contextValue).toBe( + "test-context-value" + ); + + await page.goto(`http://localhost:${port}/`); + await page.click("button"); + + await page.waitForSelector("[data-testid=resource-data]"); + const fetcherData = JSON.parse( + (await page.locator("[data-testid=resource-data]").textContent()) || + "{}" + ); + expect(fetcherData.contextValue).toBe("test-context-value"); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports request context in middleware using the unstable_RouterContextProvider API", async ({ + page, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/config/request-context.ts": js` + // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION + import { unstable_createContext, unstable_RouterContextProvider } from "react-router"; + + export const testContext = unstable_createContext("default-value"); + + export const requestContext = new unstable_RouterContextProvider( + new Map([[testContext, "test-context-value"]]) + ); + `, + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + ], + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/root.tsx": js` + import type { unstable_MiddlewareFunction } from "react-router"; + import { Outlet } from "react-router"; + import { testContext } from "../config/request-context"; + + export const unstable_middleware: unstable_MiddlewareFunction[] = [ + async ({ request, context }, next) => { + const contextValue = context.get(testContext); + request.headers.set("x-middleware-context", contextValue); + return await next(); + }, + ]; + + export default function RootRoute() { + return ( +
+

Root Route

+ +
+ ); + } + `, + "src/routes/home.tsx": js` + export function loader({ request }) { + const contextValue = request.headers.get("x-middleware-context"); + return { contextValue }; + } + + export default function HomeRoute({ loaderData }) { + return ( +
+

Context value: {loaderData.contextValue}

+
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + await page.waitForSelector("[data-context-value]"); + expect(await page.locator("[data-context-value]").textContent()).toBe( + "Context value: test-context-value" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports client context using unstable_getContext", async ({ + page, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/config/unstable-get-context.ts": js` + // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION + import { unstable_createContext } from "react-router"; + + export const testContext = unstable_createContext("default-value"); + + export function unstable_getContext() { + return new Map([[testContext, "client-context-value"]]); + } + `, + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + ], + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/root.tsx": js` + "use client"; + + import { Outlet } from "react-router"; + import type { unstable_ClientMiddlewareFunction } from "react-router"; + import { testContext } from "../config/unstable-get-context"; + + export const unstable_clientMiddleware = [ + async ({ context }, next) => { + context.set(testContext, "client-context-value"); + return await next(); + }, + ]; + + export function HydrateFallback() { + return
Loading...
; + } + + export default function RootRoute() { + return ( +
+

Root Route

+ +
+ ); + } + `, + "src/routes/home.tsx": js` + "use client"; + + import { useLoaderData } from "react-router"; + import { testContext } from "../config/unstable-get-context"; + + export function clientLoader({ context }) { + const contextValue = context.get(testContext); + return { contextValue }; + } + + clientLoader.hydrate = true; + + export default function HomeRoute() { + const loaderData = useLoaderData(); + return ( +
+

Client context value: {loaderData.contextValue}

+
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + await page.waitForSelector("[data-client-context]"); + expect(await page.locator("[data-client-context]").textContent()).toBe( + "Client context value: client-context-value" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports resource routes as URL and fetchers", async ({ + page, + request, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + ], + }, + { + id: "resource", + path: "resource", + lazy: () => import("./routes/resource"), + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/resource.tsx": js` + export function loader() { + return Response.json({ message: "Hello from resource route!" }); + } + + export async function action({ request }) { + return { + message: "Hello from resource route!", + echo: await request.text(), + }; + } + `, + "src/routes/home.client.tsx": js` + "use client"; + + import { useFetcher } from "react-router"; + + export function ResourceFetcher() { + const fetcher = useFetcher(); + + const loadResource = () => { + fetcher.submit({ hello: "world" }, { method: "post", action: "/resource" }); + }; + + return ( +
+ + {!!fetcher.data && ( +
+                        {JSON.stringify(fetcher.data)}
+                      
+ )} +
+ ); + } + `, + "src/routes/home.tsx": js` + import { ResourceFetcher } from "./home.client"; + + export default function HomeRoute() { + return ; + } + `, + }, + }); + const getResponse = await request.get( + `http://localhost:${port}/resource` + ); + expect(getResponse?.status()).toBe(200); + expect(await getResponse?.json()).toEqual({ + message: "Hello from resource route!", + }); + + const postResponse = await request.post( + `http://localhost:${port}/resource`, + { + data: { hello: "world" }, + } + ); + expect(postResponse?.status()).toBe(200); + expect(await postResponse?.json()).toEqual({ + message: "Hello from resource route!", + echo: JSON.stringify({ hello: "world" }), + }); + + await page.goto(`http://localhost:${port}/`); + await page.click("button"); + + await page.waitForSelector("[data-testid=resource-data]"); + expect( + await page.locator("[data-testid=resource-data]").textContent() + ).toBe( + JSON.stringify({ + message: "Hello from resource route!", + echo: "hello=world", + }) + ); + }); + }); + + test("Handles error responses from resource routes missing loaders/actions", async ({ + page, + request, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + ], + }, + { + id: "no-loader-resource", + path: "no-loader-resource", + lazy: () => import("./routes/no-loader-resource"), + }, + { + id: "no-action-resource", + path: "no-action-resource", + lazy: () => import("./routes/no-action-resource"), + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/root.tsx": js` + import { Outlet } from "react-router"; + export default function RootRoute() { + return ( +
+

Root Route

+ +
+ ); + } + `, + "src/routes/home.tsx": js` + export default function HomeRoute() { + return ( +
+

Home Route

+
+ ); + } + `, + "src/routes/no-loader-resource.tsx": js` + // This resource route has no loader, so GET requests should fail + export async function action() { + return { message: "no-loader-resource action works" }; + } + `, + "src/routes/no-action-resource.tsx": js` + // This resource route has no action, so POST requests should fail + export function loader() { + return { message: "no-action-resource loader works" }; + } + `, + }, + }); + + const getResponse = await request.get( + `http://localhost:${port}/no-loader-resource` + ); + expect(getResponse?.status()).toBe(400); + expect(await getResponse?.text()).toBe( + 'Error: You made a GET request to "/no-loader-resource" but did not provide a `loader` for route "no-loader-resource", so there is no way to handle the request.' + ); + + const postResponse = await request.post( + `http://localhost:${port}/no-action-resource` + ); + expect(postResponse?.status()).toBe(405); + expect(await postResponse?.text()).toBe( + 'Error: You made a POST request to "/no-action-resource" but did not provide an `action` for route "no-action-resource", so there is no way to handle the request.' + ); + + const postWithActionResponse = await request.post( + `http://localhost:${port}/no-loader-resource` + ); + expect(postWithActionResponse?.status()).toBe(200); + expect(await postWithActionResponse?.json()).toEqual({ + message: "no-loader-resource action works", + }); + + const getWithLoaderResponse = await request.get( + `http://localhost:${port}/no-action-resource` + ); + expect(getWithLoaderResponse?.status()).toBe(200); + expect(await getWithLoaderResponse?.json()).toEqual({ + message: "no-action-resource loader works", + }); + + // Ensure this is using RSC + await page.goto(`http://localhost:${port}/`); + validateRSCHtml(await page.content()); + }); + + test.describe("Server Actions", () => { + test("Supports React Server Functions", async ({ page }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes/home.actions.ts": js` + "use server"; + + export async function incrementCounter(count: number, formData: FormData) { + return count + parseInt(formData.get("by") as string || "1", 10); + } + `, + "src/routes/home.tsx": js` + export { default } from "./home.client"; + `, + "src/routes/home.client.tsx": js` + "use client"; + + import { useActionState } from "react"; + + import { incrementCounter } from "./home.actions"; + + export default function HomeRoute() { + const [count, incrementCounterAction, incrementing] = useActionState(incrementCounter, 0); + + return ( +
+

Home: ({count})

+ + + + +
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Verify initial server render + await page.waitForSelector("[data-home]"); + expect(await page.locator("[data-home]").textContent()).toBe( + "Home: (0)" + ); + + // Submit the form to trigger server function + await page.click("[data-submit]"); + + // Verify server function updated the UI + await expect(page.locator("[data-home]")).toHaveText("Home: (1)"); + + // Submit again to ensure server functions work repeatedly + await page.click("[data-submit]"); + await expect(page.locator("[data-home]")).toHaveText("Home: (2)"); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports Inline React Server Functions", async ({ page }) => { + // FIXME: Waiting on parcel support: https://github.com/parcel-bundler/parcel/pull/10165 + test.skip( + implementation.name === "parcel", + "Not supported in parcel yet" + ); + + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes/home.tsx": js` + let count = 0; + let name = "Default"; + + export function loader() { + return { name, count }; + } + + export default function HomeRoute({ loaderData }) { + const updateCounter = async (formData: FormData) => { + "use server"; + name = formData.get("name"); + ++count + return { name, count }; + } + + return ( +
+

Home: {loaderData.name} ({loaderData.count})

+
+ + +
+
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Verify initial server render + await page.waitForSelector("[data-home]"); + expect(await page.locator("[data-home]").textContent()).toBe( + "Home: Default (0)" + ); + + // Submit the form to trigger server function + await page.click("[data-submit]"); + + // Verify server function updated the UI + await expect(page.locator("[data-home]")).toHaveText( + "Home: Updated (1)" + ); + + // Submit again to ensure server functions work repeatedly + await page.click("[data-submit]"); + await expect(page.locator("[data-home]")).toHaveText( + "Home: Updated (2)" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports React Server Functions thrown redirects", async ({ + page, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes/home.actions.ts": js` + "use server"; + import { redirect } from "react-router"; + + export async function redirectAction(formData: FormData) { + throw redirect("/?redirected=true"); + } + `, + "src/routes/home.client.tsx": js` + "use client"; + import { useState } from "react"; + + export function Counter() { + const [count, setCount] = useState(0); + return ; + } + `, + "src/routes/home.tsx": js` + import { redirectAction } from "./home.actions"; + import { Counter } from "./home.client"; + + export default function HomeRoute(props) { + console.log({props}); + return ( +
+
+ +
+ +
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Verify initial server render + await page.waitForSelector("[data-count]"); + expect(await page.locator("[data-count]").textContent()).toBe( + "Count: 0" + ); + await page.click("[data-count]"); + expect(await page.locator("[data-count]").textContent()).toBe( + "Count: 1" + ); + + // Submit the form to trigger server function redirect + await page.click("[data-submit]"); + + await expect(page).toHaveURL( + `http://localhost:${port}/?redirected=true` + ); + + expect(await page.locator("[data-count]").textContent()).toBe( + "Count: 1" + ); + // Validate things are still interactive after redirect + await page.click("[data-count]"); + expect(await page.locator("[data-count]").textContent()).toBe( + "Count: 2" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports React Server Functions side-effect redirects", async ({ + page, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes/home.actions.ts": js` + "use server"; + import { redirect } from "react-router"; + + export async function redirectAction() { + redirect("/?redirected=true"); + return "redirected"; + } + `, + "src/routes/home.client.tsx": js` + "use client"; + import { useState } from "react"; + + export function Counter() { + const [count, setCount] = useState(0); + return ; + } + `, + "src/routes/home.tsx": js` + "use client"; + import {useActionState} from "react"; + import { redirectAction } from "./home.actions"; + import { Counter } from "./home.client"; + + export default function HomeRoute(props) { + const [state, action] = useActionState(redirectAction, null); + return ( +
+
+ +
+ {state &&
{state}
} + +
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Verify initial server render + await page.waitForSelector("[data-count]"); + expect(await page.locator("[data-count]").textContent()).toBe( + "Count: 0" + ); + await page.click("[data-count]"); + expect(await page.locator("[data-count]").textContent()).toBe( + "Count: 1" + ); + + // Submit the form to trigger server function redirect + await page.click("[data-submit]"); + + await expect(page.getByTestId("state")).toHaveText("redirected"); + + await page.waitForURL(`http://localhost:${port}/?redirected=true`); + + expect(await page.locator("[data-count]").textContent()).toBe( + "Count: 1" + ); + // Validate things are still interactive after redirect + await page.click("[data-count]"); + expect(await page.locator("[data-count]").textContent()).toBe( + "Count: 2" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports React Server Function References", async ({ page }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes/home.actions.ts": js` + "use server"; + + export async function incrementCounter({count, ref}: {count: number; ref: unknown}, formData: FormData) { + return {count: count + parseInt(formData.get("by") as string || "1", 10), ref}; + } + `, + "src/routes/home.tsx": js` + export { default } from "./home.client"; + `, + "src/routes/home.client.tsx": js` + "use client"; + + import { useActionState } from "react"; + + import { incrementCounter } from "./home.actions"; + + const ogRef = {}; + export default function HomeRoute() { + const [{count,ref}, incrementCounterAction, incrementing] = useActionState(incrementCounter, {count: 0, ref: ogRef}); + + return ( +
+

Home: ({count})

+

{ref === ogRef ? "good" : "bad"}

+
+ +
+
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Verify initial server render + await page.waitForSelector("[data-home]"); + expect(await page.locator("[data-home]").textContent()).toBe( + "Home: (0)" + ); + await expect(page.locator("[data-home-ref]")).toHaveText("good"); + + // Submit the form to trigger server function + await page.click("[data-submit]"); + + // Verify server function updated the UI + await expect(page.locator("[data-home]")).toHaveText("Home: (1)"); + await expect(page.locator("[data-home-ref]")).toHaveText("good"); + + // Submit again to ensure server functions work repeatedly + await page.click("[data-submit]"); + await expect(page.locator("[data-home]")).toHaveText("Home: (2)"); + await expect(page.locator("[data-home-ref]")).toHaveText("good"); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + }); + + test.describe("Basename", () => { + test("Renders a page with a custom basename", async ({ page }) => { + let port = await getPort(); + let basename = "/custom/basename/"; + stop = await setupRscTest({ + implementation, + port, + files: { + "src/config/basename.ts": js` + // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION + export const basename = ${JSON.stringify(basename)}; + `, + "src/routes/home.tsx": js` + export function loader() { + return { message: "Loader Data" }; + } + export default function HomeRoute({ loaderData }) { + return

Home: {loaderData.message}

; + } + `, + }, + }); + + await page.goto(`http://localhost:${port}${basename}`); + await page.waitForSelector("[data-home]"); + expect(await page.locator("[data-home]").textContent()).toBe( + "Home: Loader Data" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Handles server-side redirects with basename", async ({ page }) => { + let port = await getPort(); + let basename = "/custom/basename/"; + stop = await setupRscTest({ + implementation, + port, + files: { + "src/config/basename.ts": js` + // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION + export const basename = ${JSON.stringify(basename)}; + `, + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + { + id: "redirect", + path: "redirect", + lazy: () => import("./routes/redirect"), + }, + { + id: "target", + path: "target", + lazy: () => import("./routes/target"), + }, + ], + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/home.tsx": js` + import { Link } from "react-router"; + + export default function HomeRoute() { + return ( +
+

Home Route

+ + Go to redirect route + +
+ ); + } + `, + "src/routes/redirect.tsx": js` + import { redirect } from "react-router"; + + export function loader() { + throw redirect("/target"); + } + + export default function RedirectRoute() { + return

This should not be rendered

; + } + `, + "src/routes/target.tsx": js` + export default function TargetRoute() { + return

Target Route

; + } + `, + }, + }); + + // Navigate directly to redirect route with basename + await page.goto(`http://localhost:${port}${basename}redirect`); + + // Should be redirected to target route + await page.waitForURL(`http://localhost:${port}${basename}target`); + await page.waitForSelector("[data-target]"); + expect(await page.locator("[data-target]").textContent()).toBe( + "Target Route" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Handles server-side redirects in route actions with basename", async ({ + page, + }) => { + let port = await getPort(); + let basename = "/custom/basename/"; + stop = await setupRscTest({ + implementation, + port, + files: { + "src/config/basename.ts": js` + // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION + export const basename = ${JSON.stringify(basename)}; + `, + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + { + id: "action-redirect", + path: "action-redirect", + lazy: () => import("./routes/action-redirect"), + }, + { + id: "target", + path: "target", + lazy: () => import("./routes/target"), + }, + ], + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/home.tsx": js` + import { Link } from "react-router"; + + export default function HomeRoute() { + return ( +
+

Home Route

+ Go to action redirect route +
+ ); + } + `, + "src/routes/action-redirect.tsx": js` + import { redirect } from "react-router"; + + export async function action({ request }) { + // Redirect to target when form is submitted + throw redirect("/target"); + } + + export default function ActionRedirectRoute() { + return ( +
+

Action Redirect Route

+
+ +
+
+ ); + } + `, + "src/routes/target.tsx": js` + export default function TargetRoute() { + return

Target Route

; + } + `, + }, + }); + + // Navigate to action redirect route with basename + await page.goto(`http://localhost:${port}${basename}action-redirect`); + await page.waitForSelector("[data-action-redirect]"); + expect(await page.locator("[data-action-redirect]").textContent()).toBe( + "Action Redirect Route" + ); + + // Mutate the window object so we can check if the navigation occurred + // within the same browser context + await page.evaluate(() => { + // @ts-expect-error + window.__isWithinSameBrowserContext = true; + }); + + // Submit the form to trigger the action redirect + await page.click("[data-submit-action]"); + + // Should be redirected to target route + await page.waitForURL(`http://localhost:${port}${basename}target`); + await page.waitForSelector("[data-target]"); + expect(await page.locator("[data-target]").textContent()).toBe( + "Target Route" + ); + + // Ensure a document navigation occurred + expect( + await page.evaluate(() => { + // @ts-expect-error + return window.__isWithinSameBrowserContext; + }) + ).not.toBe(true); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports redirects on client navigations with basename", async ({ + page, + }) => { + let port = await getPort(); + let basename = "/custom/basename/"; + stop = await setupRscTest({ + implementation, + port, + files: { + "src/config/basename.ts": js` + // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION + export const basename = ${JSON.stringify(basename)}; + `, + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + { + id: "redirect", + path: "redirect", + lazy: () => import("./routes/redirect"), + }, + { + id: "target", + path: "target", + lazy: () => import("./routes/target"), + }, + ], + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/home.tsx": js` + import { Link } from "react-router"; + + export default function HomeRoute() { + return ( +
+

Home Route

+ + Go to redirect route + +
+ ); + } + `, + "src/routes/redirect.tsx": js` + import { redirect } from "react-router"; + + export function loader() { + throw redirect("/target"); + } + + export default function RedirectRoute() { + return

This should not be rendered

; + } + `, + "src/routes/target.tsx": js` + export default function TargetRoute() { + return

Target Route

; + } + `, + }, + }); + + // Navigate to home route with basename + await page.goto(`http://localhost:${port}${basename}`); + await page.waitForSelector("[data-home]"); + expect(await page.locator("[data-home]").textContent()).toBe( + "Home Route" + ); + + // Click link to redirect route + await page.click("[data-link-to-redirect]"); + + // Should be redirected to target route + await page.waitForURL(`http://localhost:${port}${basename}target`); + await page.waitForSelector("[data-target]"); + expect(await page.locator("[data-target]").textContent()).toBe( + "Target Route" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports redirects in route actions on client navigations with basename", async ({ + page, + }) => { + let port = await getPort(); + let basename = "/custom/basename/"; + stop = await setupRscTest({ + implementation, + port, + files: { + "src/config/basename.ts": js` + // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION + export const basename = ${JSON.stringify(basename)}; + `, + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + { + id: "action-redirect", + path: "action-redirect", + lazy: () => import("./routes/action-redirect"), + }, + { + id: "target", + path: "target", + lazy: () => import("./routes/target"), + }, + ], + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/home.tsx": js` + import { Link } from "react-router"; + + export default function HomeRoute() { + return ( +
+

Home Route

+ Go to action redirect route +
+ ); + } + `, + "src/routes/action-redirect.tsx": js` + import { Form, redirect } from "react-router"; + + export async function action({ request }) { + // Redirect to target when form is submitted + throw redirect("/target"); + } + + export default function ActionRedirectRoute() { + return ( +
+

Action Redirect Route

+
+ +
+
+ ); + } + `, + "src/routes/target.tsx": js` + export default function TargetRoute() { + return

Target Route

; + } + `, + }, + }); + + // Navigate to action redirect route with basename + await page.goto(`http://localhost:${port}${basename}action-redirect`); + await page.waitForSelector("[data-action-redirect]"); + expect(await page.locator("[data-action-redirect]").textContent()).toBe( + "Action Redirect Route" + ); + + // Mutate the window object so we can check if the navigation occurred + // within the same browser context + await page.evaluate(() => { + // @ts-expect-error + window.__isWithinSameBrowserContext = true; + }); + + // Submit the form to trigger the action redirect + await page.click("[data-submit-action]"); + + // Should be redirected to target route + await page.waitForURL(`http://localhost:${port}${basename}target`); + await page.waitForSelector("[data-target]"); + expect(await page.locator("[data-target]").textContent()).toBe( + "Target Route" + ); + + // Ensure a client-side navigation occurred + expect( + await page.evaluate(() => { + // @ts-expect-error + return window.__isWithinSameBrowserContext; + }) + ).toBe(true); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Supports redirects in server actions with basename", async ({ + page, + }) => { + let port = await getPort(); + let basename = "/custom/basename/"; + stop = await setupRscTest({ + implementation, + port, + files: { + "src/config/basename.ts": js` + // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION + export const basename = ${JSON.stringify(basename)}; + `, + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + { + id: "redirect", + path: "redirect", + lazy: () => import("./routes/redirect"), + }, + { + id: "target", + path: "target", + lazy: () => import("./routes/target"), + }, + ], + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/home.tsx": js` + import { Link } from "react-router"; + + export default function HomeRoute() { + return ( +
+

Home Route

+ + Go to server action redirect route + +
+ ); + } + `, + "src/routes/redirect.actions.ts": js` + "use server"; + import { redirect } from "react-router"; + + export async function redirectAction(formData: FormData) { + throw redirect("/target"); + } + `, + "src/routes/redirect.tsx": js` + export { default } from "./redirect.client"; + `, + "src/routes/redirect.client.tsx": js` + "use client"; + + import { useActionState } from "react"; + import { redirectAction } from "./redirect.actions"; + + export default function RedirectRoute() { + const [state, formAction, isPending] = useActionState(redirectAction, null); + + return ( +
+

Server Action Redirect Route

+
+ +
+
+ ); + } + `, + "src/routes/target.tsx": js` + export default function TargetRoute() { + return

Target Route

; + } + `, + }, + }); + + // Start on home route + await page.goto(`http://localhost:${port}${basename}`); + await page.waitForSelector("[data-home]"); + expect(await page.locator("[data-home]").textContent()).toBe( + "Home Route" + ); + + // Navigate to redirect route via client navigation + await page.click("[data-link-to-redirect]"); + await page.waitForSelector("[data-redirect]"); + expect(await page.locator("[data-redirect]").textContent()).toBe( + "Server Action Redirect Route" + ); + + // Submit the form to trigger server action redirect + await page.click("[data-submit-action]"); + + // Should be redirected to target route + await page.waitForURL(`http://localhost:${port}${basename}target`); + await page.waitForSelector("[data-target]"); + expect(await page.locator("[data-target]").textContent()).toBe( + "Target Route" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test.describe("Without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + test("Supports redirects in server actions without JavaScript with basename", async ({ + page, + }) => { + test.skip(implementation.name === "parcel", "Not working in parcel?"); + + let port = await getPort(); + let basename = "/custom/basename/"; + stop = await setupRscTest({ + implementation, + port, + files: { + "src/config/basename.ts": js` + // THIS FILE OVERRIDES THE DEFAULT IMPLEMENTATION + export const basename = ${JSON.stringify(basename)}; + `, + "src/routes.ts": js` + import type { unstable_RSCRouteConfig as RSCRouteConfig } from "react-router"; + + export const routes = [ + { + id: "root", + path: "", + lazy: () => import("./routes/root"), + children: [ + { + id: "home", + index: true, + lazy: () => import("./routes/home"), + }, + { + id: "redirect", + path: "redirect", + lazy: () => import("./routes/redirect"), + }, + { + id: "target", + path: "target", + lazy: () => import("./routes/target"), + }, + ], + }, + ] satisfies RSCRouteConfig; + `, + "src/routes/home.tsx": js` + import { Link } from "react-router"; + + export default function HomeRoute() { + return ( +
+

Home Route

+ + Go to server action redirect route + +
+ ); + } + `, + "src/routes/redirect.actions.ts": js` + "use server"; + import { redirect } from "react-router"; + + export async function redirectAction(formData: FormData) { + throw redirect("/target"); + } + `, + "src/routes/redirect.tsx": js` + export { default } from "./redirect.client"; + `, + "src/routes/redirect.client.tsx": js` + "use client"; + + import { useActionState } from "react"; + import { redirectAction } from "./redirect.actions"; + + export default function RedirectRoute() { + const [state, formAction, isPending] = useActionState(redirectAction, null); + + return ( +
+

Server Action Redirect Route

+
+ +
+
+ ); + } + `, + "src/routes/target.tsx": js` + export default function TargetRoute() { + return

Target Route

; + } + `, + }, + }); + + // Start on home route + await page.goto(`http://localhost:${port}${basename}`); + await page.waitForSelector("[data-home]"); + expect(await page.locator("[data-home]").textContent()).toBe( + "Home Route" + ); + + // Navigate to redirect route + await page.click("[data-link-to-redirect]"); + await page.waitForSelector("[data-redirect]"); + expect(await page.locator("[data-redirect]").textContent()).toBe( + "Server Action Redirect Route" + ); + + // Submit the form to trigger server action redirect + await page.click("[data-submit-action]"); + + // Should be redirected to target route + await page.waitForURL(`http://localhost:${port}${basename}target`); + await page.waitForSelector("[data-target]"); + expect(await page.locator("[data-target]").textContent()).toBe( + "Target Route" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + }); + }); + + test.describe("Errors", () => { + test("Handles errors in server components correctly", async ({ + page, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + dev: true, + implementation, + port, + files: { + "src/routes/home.tsx": js` + export function loader() { + throw new Error("Intentional error from loader"); + } + + export default function HomeRoute() { + return

This should not be rendered

; + } + + export { ErrorBoundary } from "./home.client"; + `, + "src/routes/home.client.tsx": js` + "use client" + import { useRouteError } from "react-router"; + + export function ErrorBoundary() { + let error = useRouteError(); + return ( + <> +

Error Caught!

+

{error.message}

+ + ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Verify error boundary is shown + await page.waitForSelector("[data-error-title]"); + await page.waitForSelector("[data-error-message]"); + expect(await page.locator("[data-error-message]").textContent()).toBe( + "Intentional error from loader" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Handles sanitized production errors in server components correctly", async ({ + page, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes/home.tsx": js` + export function loader() { + throw new Error("This error should be sanitized"); + } + + export default function HomeRoute() { + return

This should not be rendered

; + } + + export { ErrorBoundary } from "./home.client"; + `, + "src/routes/home.client.tsx": js` + "use client" + import { useRouteError } from "react-router"; + + export function ErrorBoundary() { + let error = useRouteError(); + return ( + <> +

Error Caught!

+

{error.message}

+ + ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Verify error boundary is shown + await page.waitForSelector("[data-error-title]"); + await page.waitForSelector("[data-error-message]"); + expect(await page.locator("[data-error-message]").textContent()).toBe( + "An error occurred in the Server Components render. The specific message is omitted in production builds to avoid leaking sensitive details. A digest property is included on this error instance which may provide additional details about the nature of the error." + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + }); + + test.describe("Route Client Component Props", () => { + test("Passes props to client route component", async ({ page }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes/home.tsx": js` + export { default, clientLoader, clientAction } from "./home.client"; + `, + "src/routes/home.client.tsx": js` + "use client"; + + import { Form } from "react-router"; + + export async function clientLoader() { + return { message: "Hello from client loader!" }; + } + + export async function clientAction({ request }) { + const formData = await request.formData(); + const name = formData.get("name") as string; + return { actionResult: "Hello " + name + " from client action!" }; + } + + export default function HomeRoute({ loaderData, actionData, matches, params }) { + return ( +
+

Home Route

+ {loaderData && ( +

{loaderData.message}

+ )} + {actionData && ( +

{actionData.actionResult}

+ )} + {matches && ( +
+

matches ids: {matches.map(match => match.id).join(", ")}

+
+ )} + {params && ( +
+

typeof params: {typeof params}

+

params count: {Object.keys(params).length}

+
+ )} +
+ + +
+
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Verify loader data is passed + await page.waitForSelector("[data-loader-data]"); + expect(await page.locator("[data-loader-data]").textContent()).toBe( + "Hello from client loader!" + ); + + // Verify params are passed (empty for home route) + await page.waitForSelector("[data-params]"); + await page.waitForSelector("[data-params-type]"); + await page.waitForSelector("[data-params-count]"); + expect(await page.locator("[data-params-type]").textContent()).toBe( + "typeof params: object" + ); + expect(await page.locator("[data-params-count]").textContent()).toBe( + "params count: 0" + ); + + // Verify matches are passed + await page.waitForSelector("[data-matches]"); + await page.waitForSelector("[data-matches-ids]"); + expect(await page.locator("[data-matches-ids]").textContent()).toBe( + "matches ids: root, home" + ); + + // Submit the form to trigger the client action + await page.fill("[data-name-input]", "World"); + await page.click("[data-submit-button]"); + + // Verify the action data is displayed + await page.waitForSelector("[data-action-data]"); + expect(await page.locator("[data-action-data]").textContent()).toBe( + "Hello World from client action!" + ); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Passes props to client ErrorBoundary when error is thrown in client loader", async ({ + page, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes/home.tsx": js` + export { default, clientLoader, ErrorBoundary } from "./home.client"; + `, + "src/routes/home.client.tsx": js` + "use client"; + + export async function clientLoader() { + throw new Error("Intentional error from client loader"); + } + + export function ErrorBoundary({ error, params }) { + return ( +
+

Error Caught!

+

{error.message}

+ {params && ( +
+

typeof params: {typeof params}

+

params count: {Object.keys(params).length}

+
+ )} +
+ ); + } + + export default function HomeRoute() { + return ( +
+

Home Route

+
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Verify error boundary is shown + await page.waitForSelector("[data-error-title]"); + await page.waitForSelector("[data-error-message]"); + expect(await page.locator("[data-error-title]").textContent()).toBe( + "Error Caught!" + ); + expect(await page.locator("[data-error-message]").textContent()).toBe( + "Intentional error from client loader" + ); + + // Verify params are passed to error boundary + await page.waitForSelector("[data-error-params]"); + await page.waitForSelector("[data-error-params-type]"); + await page.waitForSelector("[data-error-params-count]"); + expect( + await page.locator("[data-error-params-type]").textContent() + ).toBe("typeof params: object"); + expect( + await page.locator("[data-error-params-count]").textContent() + ).toBe("params count: 0"); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Passes props to client ErrorBoundary when error is thrown in server loader", async ({ + page, + }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + dev: true, + files: { + "src/routes/home.tsx": js` + export function loader() { + throw new Error("Intentional error from server loader"); + } + + export default function HomeRoute() { + return

This should not be rendered

; + } + + export { ErrorBoundary } from "./home.client"; + `, + "src/routes/home.client.tsx": js` + "use client"; + + export function ErrorBoundary({ error, params }) { + return ( +
+

Error Caught!

+

{error.message}

+ {params && ( +
+

typeof params: {typeof params}

+

params count: {Object.keys(params).length}

+
+ )} +
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Verify error boundary is shown + await page.waitForSelector("[data-error-title]"); + await page.waitForSelector("[data-error-message]"); + expect(await page.locator("[data-error-title]").textContent()).toBe( + "Error Caught!" + ); + expect(await page.locator("[data-error-message]").textContent()).toBe( + "Intentional error from server loader" + ); + + // Verify params are passed to error boundary + await page.waitForSelector("[data-error-params]"); + await page.waitForSelector("[data-error-params-type]"); + await page.waitForSelector("[data-error-params-count]"); + expect( + await page.locator("[data-error-params-type]").textContent() + ).toBe("typeof params: object"); + expect( + await page.locator("[data-error-params-count]").textContent() + ).toBe("params count: 0"); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + + test("Passes props to client HydrateFallback", async ({ page }) => { + let port = await getPort(); + stop = await setupRscTest({ + implementation, + port, + files: { + "src/routes/home.tsx": js` + export { default, clientLoader, HydrateFallback } from "./home.client"; + `, + "src/routes/home.client.tsx": js` + "use client"; + + export async function clientLoader() { + const pollingPromise = (async () => { + while (globalThis.unblockClientLoader !== true) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + })(); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Client loader wasn't unblocked after 5s")), 5000); + }); + await Promise.race([pollingPromise, timeoutPromise]); + return { message: "Hello from client loader!" }; + } + + export function HydrateFallback({ params }) { + return ( +
+

Hydrate Fallback

+ {params && ( +
+

typeof params: {typeof params}

+

params count: {Object.keys(params).length}

+
+ )} +
+ ); + } + + export default function HomeRoute() { + return ( +
+

Home Route

+
+ ); + } + `, + }, + }); + + await page.goto(`http://localhost:${port}/`); + + // Verify the hydrate fallback is shown initially + await page.waitForSelector("[data-hydrate-fallback]"); + expect( + await page.locator("[data-hydrate-fallback]").textContent() + ).toBe("Hydrate Fallback"); + + // Verify params are passed to hydrate fallback + await page.waitForSelector("[data-hydrate-params]"); + await page.waitForSelector("[data-hydrate-params-type]"); + await page.waitForSelector("[data-hydrate-params-count]"); + expect( + await page.locator("[data-hydrate-params-type]").textContent() + ).toBe("typeof params: object"); + expect( + await page.locator("[data-hydrate-params-count]").textContent() + ).toBe("params count: 0"); + + // Unblock the client loader to allow it to complete + await page.evaluate(() => { + (globalThis as any).unblockClientLoader = true; + }); + + await page.waitForSelector("[data-home]"); + + // Ensure this is using RSC + validateRSCHtml(await page.content()); + }); + }); + }); +}); diff --git a/integration/single-fetch-test.ts b/integration/single-fetch-test.ts index dc38fe2764..64fc051751 100644 --- a/integration/single-fetch-test.ts +++ b/integration/single-fetch-test.ts @@ -170,6 +170,24 @@ const files = { ) } `, + + "app/routes/invalid-date.tsx": js` + import { useLoaderData, data } from "react-router"; + + export function loader({ request }) { + return data({ invalidDate: new Date("invalid") }); + } + + export default function InvalidDate() { + let data = useLoaderData(); + return ( + <> +

Invalid Date

+

{data.invalidDate.toISOString()}

+ + ) + } + `, }; test.describe("single-fetch", () => { @@ -216,6 +234,25 @@ test.describe("single-fetch", () => { }, }, }); + + res = await fixture.requestSingleFetchData("/invalid-date.data"); + expect(res.data).toEqual({ + root: { + data: { + message: "ROOT", + }, + }, + "routes/invalid-date": { + data: { + invalidDate: expect.any(Date), + }, + }, + }); + + let date = ( + res.data as { ["routes/invalid-date"]: { data: { invalidDate: Date } } } + )["routes/invalid-date"].data.invalidDate; + expect(isNaN(date.getTime())).toBe(true); }); test("loads proper errors on single fetch loader requests", async () => { @@ -1859,6 +1896,79 @@ test.describe("single-fetch", () => { ); }); + test("Strips Content-Length header from loader/action responses", async () => { + let fixture = await createFixture({ + files: { + ...files, + "app/routes/data-with-response.tsx": js` + import { useActionData, useLoaderData, data } from "react-router"; + + export function headers ({ actionHeaders, loaderHeaders, errorHeaders }) { + if ([...actionHeaders].length > 0) { + return actionHeaders; + } else { + return loaderHeaders; + } + } + + export async function action({ request }) { + let formData = await request.formData(); + return data({ + key: formData.get('key'), + }, { headers: { 'Content-Length': '0' }}); + } + + export function loader({ request }) { + return data({ + message: "DATA", + }, { headers: { 'Content-Length': '0' }}); + } + + export default function DataWithResponse() { + let data = useLoaderData(); + let actionData = useActionData(); + return ( + <> +

Data

+

{data.message}

+

{data.date.toISOString()}

+ {actionData ?

{actionData.key}

: null} + + ) + } + `, + }, + }); + + let res = await fixture.requestSingleFetchData("/data-with-response.data"); + expect(res.headers.get("Content-Length")).toEqual(null); + expect(res.data).toStrictEqual({ + root: { + data: { + message: "ROOT", + }, + }, + "routes/data-with-response": { + data: { + message: "DATA", + }, + }, + }); + + let postBody = new URLSearchParams(); + postBody.set("key", "value"); + res = await fixture.requestSingleFetchData("/data-with-response.data", { + method: "post", + body: postBody, + }); + expect(res.headers.get("Content-Length")).toEqual(null); + expect(res.data).toEqual({ + data: { + key: "value", + }, + }); + }); + test("Action requests do not use _routes and do not call loaders on the server", async ({ page, }) => { diff --git a/integration/tsconfig.json b/integration/tsconfig.json index 50001a6f7f..baa2e3ae2a 100644 --- a/integration/tsconfig.json +++ b/integration/tsconfig.json @@ -1,6 +1,6 @@ { "include": ["**/*.ts"], - "exclude": ["helpers/*-template"], + "exclude": ["helpers/*"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], "target": "ES2022", diff --git a/integration/vite-css-test.ts b/integration/vite-css-test.ts index 866ec4bdfd..15bde97150 100644 --- a/integration/vite-css-test.ts +++ b/integration/vite-css-test.ts @@ -155,16 +155,18 @@ const files = { const VITE_CONFIG = async ({ port, base, + cssCodeSplit, }: { port: number; base?: string; + cssCodeSplit?: boolean; }) => dedent` import { reactRouter } from "@react-router/dev/vite"; import { vanillaExtractPlugin } from "@vanilla-extract/vite-plugin"; export default async () => ({ ${await viteConfig.server({ port })} - ${viteConfig.build()} + ${viteConfig.build({ cssCodeSplit })} ${base ? `base: "${base}",` : ""} plugins: [ reactRouter(), @@ -234,7 +236,7 @@ test.describe("Vite CSS", () => { }, templateName ); - stop = await dev({ cwd, port }); + stop = await dev({ cwd, port, basename: base }); }); test.afterAll(() => stop()); @@ -289,7 +291,7 @@ test.describe("Vite CSS", () => { }); }); - test.describe(async () => { + test.describe("vite build", async () => { let port: number; let cwd: string; let stop: () => void; @@ -327,14 +329,68 @@ test.describe("Vite CSS", () => { test.describe(() => { test.use({ javaScriptEnabled: false }); - test("vite build / without JS", async ({ page }) => { + test("without JS", async ({ page }) => { await pageLoadWorkflow({ page, port }); }); }); test.describe(() => { test.use({ javaScriptEnabled: true }); - test("vite build / with JS", async ({ page }) => { + test("with JS", async ({ page }) => { + await pageLoadWorkflow({ page, port }); + }); + }); + }); + + test.describe("vite build with CSS code splitting disabled", async () => { + let port: number; + let cwd: string; + let stop: () => void; + + test.beforeAll(async () => { + port = await getPort(); + cwd = await createProject( + { + "vite.config.ts": await VITE_CONFIG({ + port, + cssCodeSplit: false, + }), + ...files, + }, + templateName + ); + + let edit = createEditor(cwd); + await edit("package.json", (contents) => + contents.replace( + '"sideEffects": false', + '"sideEffects": ["*.css.ts"]' + ) + ); + + let { stderr, status } = build({ + cwd, + env: { + // Vanilla Extract uses Vite's CJS build which emits a warning to stderr + VITE_CJS_IGNORE_WARNING: "true", + }, + }); + expect(stderr.toString()).toBeFalsy(); + expect(status).toBe(0); + stop = await reactRouterServe({ cwd, port }); + }); + test.afterAll(() => stop()); + + test.describe(() => { + test.use({ javaScriptEnabled: false }); + test("without JS", async ({ page }) => { + await pageLoadWorkflow({ page, port }); + }); + }); + + test.describe(() => { + test.use({ javaScriptEnabled: true }); + test("with JS", async ({ page }) => { await pageLoadWorkflow({ page, port }); }); }); diff --git a/integration/vite-prerender-test.ts b/integration/vite-prerender-test.ts index 73d6bad381..779e885b96 100644 --- a/integration/vite-prerender-test.ts +++ b/integration/vite-prerender-test.ts @@ -2370,6 +2370,86 @@ test.describe("Prerendering", () => { expect(requests).toEqual(["/page.data"]); }); + test("Navigates prerendered multibyte path routes", async ({ page }) => { + fixture = await createFixture({ + prerender: true, + files: { + "react-router.config.ts": reactRouterConfig({ + ssr: false, // turn off fog of war since we're serving with a static server + prerender: ["/", "/page", "/ใƒšใƒผใ‚ธ"], + }), + "vite.config.ts": files["vite.config.ts"], + "app/root.tsx": js` + import * as React from "react"; + import { Link, Outlet, Scripts } from "react-router"; + + export function Layout({ children }) { + return ( + + + + + {children} + + + + ); + } + + export default function Root({ loaderData }) { + return + } + `, + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

+ } + `, + "app/routes/page.tsx": js` + export function loader() { + return "PAGE DATA" + } + export default function Page({ loaderData }) { + return

{loaderData}

; + } + `, + "app/routes/ใƒšใƒผใ‚ธ.tsx": js` + export function loader() { + return "ใƒšใƒผใ‚ธ ใƒ‡ใƒผใ‚ฟ"; + } + export default function Page({ loaderData }) { + return

{loaderData}

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + + let encodedMultibytePath = encodeURIComponent("ใƒšใƒผใ‚ธ"); + let requests = captureRequests(page); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.waitForSelector("[data-index]"); + + await app.clickLink("/page"); + await page.waitForSelector("[data-page]"); + expect(await (await page.$("[data-page]"))?.innerText()).toBe( + "PAGE DATA" + ); + expect(requests).toEqual(["/page.data"]); + clearRequests(requests); + + await app.clickLink("/ใƒšใƒผใ‚ธ"); + await page.waitForSelector("[data-multibyte-page]"); + expect(await (await page.$("[data-multibyte-page]"))?.innerText()).toBe( + "ใƒšใƒผใ‚ธ ใƒ‡ใƒผใ‚ฟ" + ); + expect(requests).toEqual([`/${encodedMultibytePath}.data`]); + }); + test("Returns a 404 if navigating to a non-prerendered param value", async ({ page, }) => { diff --git a/package.json b/package.json index 78b37cc557..92a294ba71 100644 --- a/package.json +++ b/package.json @@ -42,44 +42,28 @@ "resolutions": { "@types/react": "^18.2.18", "@types/react-dom": "^18.2.7", + "@types/react-test-renderer": "^18.3.1", "jsdom": "22.1.0" }, "dependencies": { - "@babel/core": "^7.22.9", - "@babel/plugin-proposal-export-namespace-from": "^7.18.9", - "@babel/plugin-proposal-optional-chaining": "^7.21.0", - "@babel/preset-env": "^7.22.9", - "@babel/preset-modules": "^0.1.6", - "@babel/preset-react": "^7.22.5", - "@babel/preset-typescript": "^7.22.5", + "@babel/core": "^7.27.7", + "@babel/preset-env": "^7.27.2", + "@babel/preset-react": "^7.27.1", + "@babel/preset-typescript": "^7.27.1", "@changesets/cli": "^2.26.2", - "@manypkg/get-packages": "1.1.3", + "@manypkg/get-packages": "^1.1.3", "@mdx-js/rollup": "^3.0.0", - "@octokit/core": "^4.2.4", - "@octokit/graphql": "^4.8.0", - "@octokit/plugin-paginate-rest": "^2.21.3", - "@octokit/rest": "^18.12.0", "@playwright/test": "^1.49.1", "@remix-run/changelog-github": "^0.0.5", - "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^14.5.2", - "@types/cross-spawn": "^6.0.2", - "@types/glob": "7.2.0", "@types/jest": "^29.5.4", "@types/jsdom": "^21.1.1", - "@types/jsonfile": "^6.1.1", - "@types/react": "^18.2.18", - "@types/react-dom": "^18.2.7", - "@types/react-test-renderer": "^18.0.0", - "@types/semver": "^7.5.0", - "@types/shelljs": "^0.8.11", - "@types/wait-on": "^5.3.2", + "@types/react": "^19.0.12", + "@types/react-dom": "^19.0.4", + "@types/react-test-renderer": "^19.0.0", "@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/parser": "^7.5.0", "babel-jest": "^29.7.0", "babel-plugin-dev-expression": "^0.2.3", - "babel-plugin-transform-remove-console": "^6.9.4", "chalk": "^4.1.2", "eslint": "^8.57.0", "eslint-config-react-app": "^7.0.1", @@ -89,30 +73,21 @@ "eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-react": "^7.34.1", "eslint-plugin-react-hooks": "next", - "history": "^5.3.0", "isbot": "^5.1.11", "jest": "^29.6.4", - "jest-environment-jsdom": "^29.6.2", "jsonfile": "^6.1.0", "prettier": "^2.8.8", "prompts": "^2.4.2", - "react": "^19.1.0", - "react-dom": "^19.1.0", - "react-test-renderer": "^19.1.0", - "remark-gfm": "3.0.1", + "react-server-dom-parcel": "^19.0.0", + "remark-gfm": "^3.0.1", "remark-parse": "^10.0.1", "remark-stringify": "^10.0.2", "semver": "^7.5.4", - "tslib": "^2.6.2", - "type-fest": "^2.19.0", "typedoc": "^0.26.11", "typescript": "^5.4.5", - "undici": "^6.10.1", "unified": "^10.1.2", "unist-util-remove": "^3.1.0", - "vite": "^6.1.0", - "vite-env-only": "^3.0.1", - "vite-tsconfig-paths": "^4.2.2" + "vite": "^6.1.0" }, "engines": { "node": ">=20.0.0" @@ -123,8 +98,8 @@ "@changesets/assemble-release-plan": "patches/@changesets__assemble-release-plan.patch" }, "overrides": { - "react-is": "19.1.0", - "workerd": "1.20241230.0" + "workerd": "1.20250705.0", + "react-is": "19.1.0" } } } diff --git a/packages/create-react-router/CHANGELOG.md b/packages/create-react-router/CHANGELOG.md index 0d43397a48..9a4cc1fa46 100644 --- a/packages/create-react-router/CHANGELOG.md +++ b/packages/create-react-router/CHANGELOG.md @@ -1,5 +1,11 @@ # `create-react-router` +## 7.7.0 + +### Minor Changes + +- Add Deno as a supported and detectable package manager. Note that this detection will only work with Deno versions 2.0.5 and above. If you are using an older version version of Deno then you must specify the --package-manager CLI flag set to `deno`. ([#12327](https://github.com/remix-run/react-router/pull/12327)) + ## 7.6.3 _No changes_ diff --git a/packages/create-react-router/__tests__/create-react-router-test.ts b/packages/create-react-router/__tests__/create-react-router-test.ts index 9f434f5247..0322d44b04 100644 --- a/packages/create-react-router/__tests__/create-react-router-test.ts +++ b/packages/create-react-router/__tests__/create-react-router-test.ts @@ -697,6 +697,39 @@ describe("create-react-router CLI", () => { process.env.npm_config_user_agent = originalUserAgent; }); + it("recognizes when Deno was used to run the command", async () => { + let originalUserAgent = process.env.npm_config_user_agent; + process.env.npm_config_user_agent = + "deno/2.0.6 npm/? deno/2.0.6 linux x86_64"; + + let projectDir = getProjectDir("deno-create-from-user-agent"); + + let execa = require("execa"); + execa.mockImplementation(async () => {}); + + // Suppress terminal output + let stdoutMock = jest + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + + await createReactRouter([ + projectDir, + "--template", + path.join(__dirname, "fixtures", "blank"), + "--no-git-init", + "--yes", + ]); + + stdoutMock.mockReset(); + + expect(execa).toHaveBeenCalledWith( + "deno", + expect.arrayContaining(["install"]), + expect.anything() + ); + process.env.npm_config_user_agent = originalUserAgent; + }); + it("supports specifying the package manager, regardless of user agent", async () => { let originalUserAgent = process.env.npm_config_user_agent; process.env.npm_config_user_agent = diff --git a/packages/create-react-router/index.ts b/packages/create-react-router/index.ts index ffaf2a9948..9d257d15a3 100644 --- a/packages/create-react-router/index.ts +++ b/packages/create-react-router/index.ts @@ -151,7 +151,7 @@ async function getContext(argv: string[]): Promise { noMotion, pkgManager: validatePackageManager( pkgManager ?? - // npm, pnpm, Yarn, and Bun set the user agent environment variable that can be used + // npm, pnpm, Yarn, Bun and Deno (v2.0.5+) set the user agent environment variable that can be used // to determine which package manager ran the command. (process.env.npm_config_user_agent ?? "npm").split("/")[0] ), @@ -514,19 +514,11 @@ async function doneStep(ctx: Context) { await sleep(200); } -type PackageManager = "npm" | "yarn" | "pnpm" | "bun"; - -const packageManagerExecScript: Record = { - npm: "npx", - yarn: "yarn", - pnpm: "pnpm exec", - bun: "bunx", -}; +const validPackageManagers = ["npm", "yarn", "pnpm", "bun", "deno"] as const; +type PackageManager = (typeof validPackageManagers)[number]; function validatePackageManager(pkgManager: string): PackageManager { - return packageManagerExecScript.hasOwnProperty(pkgManager) - ? (pkgManager as PackageManager) - : "npm"; + return validPackageManagers.find((name) => pkgManager === name) ?? "npm"; } async function installDependencies({ diff --git a/packages/create-react-router/package.json b/packages/create-react-router/package.json index 7f4d0e0fcf..d28b256f31 100644 --- a/packages/create-react-router/package.json +++ b/packages/create-react-router/package.json @@ -1,6 +1,6 @@ { "name": "create-react-router", - "version": "7.6.3", + "version": "7.7.0", "description": "Create a new React Router app", "homepage": "https://reactrouter.com/", "bugs": { @@ -52,6 +52,7 @@ "devDependencies": { "@types/gunzip-maybe": "^1.4.0", "@types/recursive-readdir": "^2.2.1", + "@types/semver": "^7.7.0", "@types/tar-fs": "^2.0.1", "esbuild": "0.25.0", "esbuild-register": "^3.6.0", diff --git a/packages/react-router-architect/CHANGELOG.md b/packages/react-router-architect/CHANGELOG.md index 711a92256f..0ac358bd39 100644 --- a/packages/react-router-architect/CHANGELOG.md +++ b/packages/react-router-architect/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/architect` +## 7.7.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.7.0` + - `@react-router/node@7.7.0` + ## 7.6.3 ### Patch Changes diff --git a/packages/react-router-architect/package.json b/packages/react-router-architect/package.json index bc0c275f29..464948e252 100644 --- a/packages/react-router-architect/package.json +++ b/packages/react-router-architect/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/architect", - "version": "7.6.3", + "version": "7.7.0", "description": "Architect server request handler for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-cloudflare/CHANGELOG.md b/packages/react-router-cloudflare/CHANGELOG.md index a6e8f71e37..2719ea5f29 100644 --- a/packages/react-router-cloudflare/CHANGELOG.md +++ b/packages/react-router-cloudflare/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/cloudflare` +## 7.7.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.7.0` + ## 7.6.3 ### Patch Changes diff --git a/packages/react-router-cloudflare/package.json b/packages/react-router-cloudflare/package.json index 48cab6839e..8eceb56a00 100644 --- a/packages/react-router-cloudflare/package.json +++ b/packages/react-router-cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/cloudflare", - "version": "7.6.3", + "version": "7.7.0", "description": "Cloudflare platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-dev/CHANGELOG.md b/packages/react-router-dev/CHANGELOG.md index a7ff321a69..37c40fbafc 100644 --- a/packages/react-router-dev/CHANGELOG.md +++ b/packages/react-router-dev/CHANGELOG.md @@ -1,5 +1,21 @@ # `@react-router/dev` +## 7.7.0 + +### Patch Changes + +- Update `vite-node` to `^3.2.2` to support Vite 7 ([#13781](https://github.com/remix-run/react-router/pull/13781)) +- Properly handle `https` protocol in dev mode ([#13746](https://github.com/remix-run/react-router/pull/13746)) +- Fix missing styles when Vite's `build.cssCodeSplit` option is disabled ([#13943](https://github.com/remix-run/react-router/pull/13943)) +- Allow `.mts` and `.mjs` extensions for route config file ([#13931](https://github.com/remix-run/react-router/pull/13931)) +- Fix prerender file locations when `cwd` differs from project root ([#13824](https://github.com/remix-run/react-router/pull/13824)) +- Improve chunk error logging when a chunk cannot be found during the build ([#13799](https://github.com/remix-run/react-router/pull/13799)) +- Fix incorrectly configured `externalConditions` which had enabled `module` condition for externals and broke builds with certain packages, like Emotion. ([#13871](https://github.com/remix-run/react-router/pull/13871)) +- Updated dependencies: + - `react-router@7.7.0` + - `@react-router/node@7.7.0` + - `@react-router/serve@7.7.0` + ## 7.6.3 ### Patch Changes diff --git a/packages/react-router-dev/config/config.ts b/packages/react-router-dev/config/config.ts index 5057b1b8c3..0b4a627573 100644 --- a/packages/react-router-dev/config/config.ts +++ b/packages/react-router-dev/config/config.ts @@ -695,7 +695,7 @@ export async function createConfigLoader({ }, }); - fsWatcher.on("all", async (...args: ChokidarEmitArgs) => { + fsWatcher.on("all", async (...args) => { let [event, rawFilepath] = args; let filepath = Path.normalize(rawFilepath); @@ -914,7 +914,7 @@ function omitRoutes( }; } -const entryExts = [".js", ".jsx", ".ts", ".tsx"]; +const entryExts = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".mts"]; function isEntryFile(entryBasename: string, filename: string) { return entryExts.some((ext) => filename === `${entryBasename}${ext}`); diff --git a/packages/react-router-dev/package.json b/packages/react-router-dev/package.json index e4afe3f012..6009ddc780 100644 --- a/packages/react-router-dev/package.json +++ b/packages/react-router-dev/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/dev", - "version": "7.6.3", + "version": "7.7.0", "description": "Dev tools and CLI for React Router", "homepage": "https://reactrouter.com/", "bugs": { @@ -64,14 +64,14 @@ } }, "dependencies": { - "@babel/core": "^7.21.8", - "@babel/generator": "^7.21.5", - "@babel/parser": "^7.21.8", - "@babel/plugin-syntax-decorators": "^7.22.10", - "@babel/plugin-syntax-jsx": "^7.21.4", - "@babel/preset-typescript": "^7.21.5", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.22.5", + "isbot": "^5.1.11", + "@babel/core": "^7.27.7", + "@babel/generator": "^7.27.5", + "@babel/parser": "^7.27.7", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/preset-typescript": "^7.27.1", + "@babel/traverse": "^7.27.7", + "@babel/types": "^7.27.7", "@npmcli/package-json": "^4.0.1", "@react-router/node": "workspace:*", "arg": "^5.0.1", @@ -90,13 +90,13 @@ "set-cookie-parser": "^2.6.0", "tinyglobby": "^0.2.14", "valibot": "^0.41.0", - "vite-node": "^3.1.4" + "vite-node": "^3.2.2" }, "devDependencies": { "@react-router/serve": "workspace:*", "@types/babel__core": "^7.20.5", - "@types/babel__generator": "^7.6.8", - "@types/babel__traverse": "^7.20.5", + "@types/babel__generator": "^7.27.0", + "@types/babel__traverse": "^7.20.7", "@types/dedent": "^0.7.0", "@types/express": "^4.17.9", "@types/jsesc": "^3.0.1", @@ -105,6 +105,7 @@ "@types/npmcli__package-json": "^4.0.0", "@types/prettier": "^2.7.3", "@types/set-cookie-parser": "^2.4.1", + "@types/semver": "^7.7.0", "esbuild-register": "^3.6.0", "execa": "5.1.1", "express": "^4.19.2", @@ -114,7 +115,7 @@ "typescript": "^5.1.6", "vite": "^6.1.0", "wireit": "0.14.9", - "wrangler": "^4.2.0" + "wrangler": "^4.23.0" }, "peerDependencies": { "@react-router/serve": "workspace:^", diff --git a/packages/react-router-dev/vite/cloudflare-dev-proxy.ts b/packages/react-router-dev/vite/cloudflare-dev-proxy.ts index 9d37690bf1..88139bf606 100644 --- a/packages/react-router-dev/vite/cloudflare-dev-proxy.ts +++ b/packages/react-router-dev/vite/cloudflare-dev-proxy.ts @@ -59,7 +59,6 @@ export const cloudflareDevProxyVitePlugin = ( name: PLUGIN_NAME, config: async (config, configEnv) => { await preloadVite(); - const vite = getVite(); // This is a compatibility layer for Vite 5. Default conditions were // automatically added to any custom conditions in Vite 5, but Vite 6 // removed this behavior. Instead, the default conditions are overridden @@ -68,9 +67,12 @@ export const cloudflareDevProxyVitePlugin = ( // conditions arrays exported from Vite. In Vite 5, these default // conditions arrays do not exist. // https://vite.dev/guide/migration.html#default-value-for-resolve-conditions - const serverConditions: string[] = [ - ...(vite.defaultServerConditions ?? []), - ]; + // + // In addition to that, these are external conditions (do not confuse them + // with server conditions) and there is no helpful export with the default + // external conditions (see https://github.com/vitejs/vite/pull/20279 for + // more details). So, for now, we are hardcording the default here. + const externalConditions: string[] = ["node"]; let configResult = await loadConfig({ rootDirectory: config.root ?? process.cwd(), @@ -86,7 +88,7 @@ export const cloudflareDevProxyVitePlugin = ( return { ssr: { resolve: { - externalConditions: [...workerdConditions, ...serverConditions], + externalConditions: [...workerdConditions, ...externalConditions], }, }, }; diff --git a/packages/react-router-dev/vite/node-adapter.ts b/packages/react-router-dev/vite/node-adapter.ts index 8f2bc29b78..3068348104 100644 --- a/packages/react-router-dev/vite/node-adapter.ts +++ b/packages/react-router-dev/vite/node-adapter.ts @@ -1,5 +1,6 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; import { once } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { TLSSocket } from "node:tls"; import { Readable } from "node:stream"; import { splitCookiesString } from "set-cookie-parser"; import { createReadableStreamFromReadable } from "@react-router/node"; @@ -48,10 +49,14 @@ export function fromNodeRequest( nodeReq: Vite.Connect.IncomingMessage, nodeRes: ServerResponse ): Request { + let protocol = + nodeReq.socket instanceof TLSSocket && nodeReq.socket.encrypted + ? "https" + : "http"; let origin = nodeReq.headers.origin && "null" !== nodeReq.headers.origin ? nodeReq.headers.origin - : `http://${nodeReq.headers.host}`; + : `${protocol}://${nodeReq.headers.host}`; // Use `req.originalUrl` so React Router is aware of the full path invariant( nodeReq.originalUrl, diff --git a/packages/react-router-dev/vite/plugin.ts b/packages/react-router-dev/vite/plugin.ts index 3905414211..501a8a11e1 100644 --- a/packages/react-router-dev/vite/plugin.ts +++ b/packages/react-router-dev/vite/plugin.ts @@ -324,21 +324,45 @@ const getPublicModulePathForEntry = ( return entryChunk ? `${ctx.publicPath}${entryChunk.file}` : undefined; }; +const getCssCodeSplitDisabledFile = ( + ctx: ReactRouterPluginContext, + viteConfig: Vite.ResolvedConfig, + viteManifest: Vite.Manifest +) => { + if (viteConfig.build.cssCodeSplit) { + return null; + } + + let cssFile = viteManifest["style.css"]?.file; + invariant( + cssFile, + "Expected `style.css` to be present in Vite manifest when `build.cssCodeSplit` is disabled" + ); + + return `${ctx.publicPath}${cssFile}`; +}; + +const getClientEntryChunk = ( + ctx: ReactRouterPluginContext, + viteManifest: Vite.Manifest +) => { + let filePath = ctx.entryClientFilePath; + let chunk = resolveChunk(ctx, viteManifest, filePath); + invariant(chunk, `Chunk not found: ${filePath}`); + return chunk; +}; + const getReactRouterManifestBuildAssets = ( ctx: ReactRouterPluginContext, + viteConfig: Vite.ResolvedConfig, viteManifest: Vite.Manifest, entryFilePath: string, - prependedAssetFilePaths: string[] = [] + route: RouteManifestEntry | null ): ReactRouterManifest["entry"] & { css: string[] } => { let entryChunk = resolveChunk(ctx, viteManifest, entryFilePath); - invariant(entryChunk, "Chunk not found"); + invariant(entryChunk, `Chunk not found: ${entryFilePath}`); - // This is here to support prepending client entry assets to the root route - let prependedAssetChunks = prependedAssetFilePaths.map((filePath) => { - let chunk = resolveChunk(ctx, viteManifest, filePath); - invariant(chunk, "Chunk not found"); - return chunk; - }); + let isRootRoute = Boolean(route && route.parentId === undefined); let routeModuleChunks = routeChunkNames .map((routeChunkName) => @@ -350,11 +374,19 @@ const getReactRouterManifestBuildAssets = ( ) .filter(isNonNullable); - let chunks = resolveDependantChunks(viteManifest, [ - ...prependedAssetChunks, - entryChunk, - ...routeModuleChunks, - ]); + let chunks = resolveDependantChunks( + viteManifest, + [ + // If this is the root route, we also need to include assets from the + // client entry file as this is a common way for consumers to import + // global reset styles, etc. + isRootRoute ? getClientEntryChunk(ctx, viteManifest) : null, + entryChunk, + routeModuleChunks, + ] + .flat(1) + .filter(isNonNullable) + ); return { module: `${ctx.publicPath}${entryChunk.file}`, @@ -362,10 +394,21 @@ const getReactRouterManifestBuildAssets = ( dedupe(chunks.flatMap((e) => e.imports ?? [])).map((imported) => { return `${ctx.publicPath}${viteManifest[imported].file}`; }) ?? [], - css: - dedupe(chunks.flatMap((e) => e.css ?? [])).map((href) => { - return `${ctx.publicPath}${href}`; - }) ?? [], + css: dedupe( + [ + // If CSS code splitting is disabled, Vite includes a singular 'style.css' asset + // in the manifest that isn't tied to any route file. If we want to render these + // styles correctly, we need to include them in the root route. + isRootRoute + ? getCssCodeSplitDisabledFile(ctx, viteConfig, viteManifest) + : null, + chunks + .flatMap((e) => e.css ?? []) + .map((href) => `${ctx.publicPath}${href}`), + ] + .flat(1) + .filter(isNonNullable) + ), }; }; @@ -851,8 +894,10 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { }; let generateReactRouterManifestsForBuild = async ({ + viteConfig, routeIds, }: { + viteConfig: Vite.ResolvedConfig; routeIds?: Array; }): Promise<{ reactRouterBrowserManifest: ReactRouterManifest; @@ -866,8 +911,10 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { let entry = getReactRouterManifestBuildAssets( ctx, + viteConfig, viteManifest, - ctx.entryClientFilePath + ctx.entryClientFilePath, + null ); let browserRoutes: ReactRouterManifest["routes"] = {}; @@ -883,7 +930,6 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { for (let route of Object.values(ctx.reactRouterConfig.routes)) { let routeFile = path.join(ctx.reactRouterConfig.appDirectory, route.file); let sourceExports = routeManifestExports[route.id]; - let isRootRoute = route.parentId === undefined; let hasClientAction = sourceExports.includes("clientAction"); let hasClientLoader = sourceExports.includes("clientLoader"); let hasClientMiddleware = sourceExports.includes( @@ -930,12 +976,10 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { hasErrorBoundary: sourceExports.includes("ErrorBoundary"), ...getReactRouterManifestBuildAssets( ctx, + viteConfig, viteManifest, `${routeFile}${BUILD_CLIENT_ROUTE_QUERY_STRING}`, - // If this is the root route, we also need to include assets from the - // client entry file as this is a common way for consumers to import - // global reset styles, etc. - isRootRoute ? [ctx.entryClientFilePath] : [] + route ), clientActionModule: hasRouteChunkByExportName.clientAction ? getPublicModulePathForEntry( @@ -2035,10 +2079,12 @@ export const reactRouterVitePlugin: ReactRouterVitePlugin = () => { } case virtual.serverManifest.resolvedId: { let routeIds = getServerBundleRouteIds(this, ctx); + invariant(viteConfig); let reactRouterManifest = viteCommand === "build" ? ( await generateReactRouterManifestsForBuild({ + viteConfig, routeIds, }) ).reactRouterServerManifest @@ -2661,7 +2707,7 @@ async function handleSpaMode( // Write out the HTML file for the SPA await writeFile(path.join(clientBuildDirectory, filename), html); - let prettyDir = path.relative(process.cwd(), clientBuildDirectory); + let prettyDir = path.relative(viteConfig.root, clientBuildDirectory); let prettyPath = path.join(prettyDir, filename); if (build.prerender.length > 0) { viteConfig.logger.info( @@ -2835,12 +2881,13 @@ async function prerenderData( } // Write out the .data file - let outdir = path.relative(process.cwd(), clientBuildDirectory); - let outfile = path.join(outdir, ...normalizedPath.split("/")); + let outfile = path.join(clientBuildDirectory, ...normalizedPath.split("/")); await mkdir(path.dirname(outfile), { recursive: true }); await writeFile(outfile, data); viteConfig.logger.info( - `Prerender (data): ${prerenderPath} -> ${colors.bold(outfile)}` + `Prerender (data): ${prerenderPath} -> ${colors.bold( + path.relative(viteConfig.root, outfile) + )}` ); return data; } @@ -2894,12 +2941,17 @@ async function prerenderRoute( } // Write out the HTML file - let outdir = path.relative(process.cwd(), clientBuildDirectory); - let outfile = path.join(outdir, ...normalizedPath.split("/"), "index.html"); + let outfile = path.join( + clientBuildDirectory, + ...normalizedPath.split("/"), + "index.html" + ); await mkdir(path.dirname(outfile), { recursive: true }); await writeFile(outfile, html); viteConfig.logger.info( - `Prerender (html): ${prerenderPath} -> ${colors.bold(outfile)}` + `Prerender (html): ${prerenderPath} -> ${colors.bold( + path.relative(viteConfig.root, outfile) + )}` ); } @@ -2927,12 +2979,13 @@ async function prerenderResourceRoute( } // Write out the resource route file - let outdir = path.relative(process.cwd(), clientBuildDirectory); - let outfile = path.join(outdir, ...normalizedPath.split("/")); + let outfile = path.join(clientBuildDirectory, ...normalizedPath.split("/")); await mkdir(path.dirname(outfile), { recursive: true }); await writeFile(outfile, content); viteConfig.logger.info( - `Prerender (resource): ${prerenderPath} -> ${colors.bold(outfile)}` + `Prerender (resource): ${prerenderPath} -> ${colors.bold( + path.relative(viteConfig.root, outfile) + )}` ); } @@ -3462,10 +3515,6 @@ export async function getEnvironmentOptionsResolvers( `file:///${path.join(packageRoot, "module-sync-enabled/index.mjs")}` ); let vite = getVite(); - let viteServerConditions: string[] = [ - ...(vite.defaultServerConditions ?? []), - ...(moduleSyncEnabled ? ["module-sync"] : []), - ]; function getBaseOptions({ viteUserConfig, @@ -3521,10 +3570,35 @@ export async function getEnvironmentOptionsResolvers( }: { viteUserConfig: Vite.UserConfig; }): EnvironmentOptions { - let conditions = - viteCommand === "build" - ? viteServerConditions - : ["development", ...viteServerConditions]; + // We're using the module-sync condition, but Vite + // doesn't support it by default. + // See https://github.com/vitest-dev/vitest/issues/7692 + let maybeModuleSyncConditions: string[] = [ + ...(moduleSyncEnabled ? ["module-sync"] : []), + ]; + + let maybeDevelopmentConditions = + viteCommand === "build" ? [] : ["development"]; + + // This is a compatibility layer for Vite 5. Default conditions were + // automatically added to any custom conditions in Vite 5, but Vite 6 + // removed this behavior. Instead, the default conditions are overridden + // by any custom conditions. If we wish to retain the default + // conditions, we need to manually merge them using the provided default + // conditions arrays exported from Vite. In Vite 5, these default + // conditions arrays do not exist. + // https://vite.dev/guide/migration.html#default-value-for-resolve-conditions + let maybeDefaultServerConditions = vite.defaultServerConditions || []; + + // There is no helpful export with the default external conditions (see + // https://github.com/vitejs/vite/pull/20279 for more details). So, for now, + // we are hardcording the default here. + let defaultExternalConditions = ["node"]; + + let baseConditions = [ + ...maybeDevelopmentConditions, + ...maybeModuleSyncConditions, + ]; return mergeEnvironmentOptions(getBaseOptions({ viteUserConfig }), { resolve: { @@ -3533,8 +3607,8 @@ export async function getEnvironmentOptionsResolvers( ctx.reactRouterConfig.future.unstable_viteEnvironmentApi ? undefined : ssrExternals, - conditions, - externalConditions: conditions, + conditions: [...baseConditions, ...maybeDefaultServerConditions], + externalConditions: [...baseConditions, ...defaultExternalConditions], }, build: { // We move SSR-only assets to client assets. Note that the diff --git a/packages/react-router-dev/vite/route-chunks.ts b/packages/react-router-dev/vite/route-chunks.ts index 3c21e26c8a..23526072dd 100644 --- a/packages/react-router-dev/vite/route-chunks.ts +++ b/packages/react-router-dev/vite/route-chunks.ts @@ -35,7 +35,11 @@ function codeToAst(code: string, cache: Cache, cacheKey: string): Babel.File { } function assertNodePath( - path: NodePath | NodePath[] | null | undefined + path: + | NodePath + | NodePath[] + | null + | undefined ): asserts path is NodePath { invariant( path && !Array.isArray(path), diff --git a/packages/react-router-dev/vite/static/refresh-utils.cjs b/packages/react-router-dev/vite/static/refresh-utils.cjs index 66243e8848..833a17a4d1 100644 --- a/packages/react-router-dev/vite/static/refresh-utils.cjs +++ b/packages/react-router-dev/vite/static/refresh-utils.cjs @@ -158,8 +158,6 @@ const routeUpdates = new Map(); window.__reactRouterRouteModuleUpdates = new Map(); import.meta.hot.on("react-router:hmr", async ({ route }) => { - window.__reactRouterClearCriticalCss(); - if (route) { routeUpdates.set(route.id, route); } diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md index 3211bfcb00..93bd43c05b 100644 --- a/packages/react-router-dom/CHANGELOG.md +++ b/packages/react-router-dom/CHANGELOG.md @@ -1,5 +1,12 @@ # react-router-dom +## 7.7.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.7.0` + ## 7.6.3 ### Patch Changes diff --git a/packages/react-router-dom/package.json b/packages/react-router-dom/package.json index 235857743b..de2df3eee0 100644 --- a/packages/react-router-dom/package.json +++ b/packages/react-router-dom/package.json @@ -1,6 +1,6 @@ { "name": "react-router-dom", - "version": "7.6.3", + "version": "7.7.0", "description": "Declarative routing for React web applications", "keywords": [ "react", diff --git a/packages/react-router-express/CHANGELOG.md b/packages/react-router-express/CHANGELOG.md index f9003c0098..c43e9676a3 100644 --- a/packages/react-router-express/CHANGELOG.md +++ b/packages/react-router-express/CHANGELOG.md @@ -1,5 +1,13 @@ # `@react-router/express` +## 7.7.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.7.0` + - `@react-router/node@7.7.0` + ## 7.6.3 ### Patch Changes diff --git a/packages/react-router-express/package.json b/packages/react-router-express/package.json index c9fab6381e..67743d70ba 100644 --- a/packages/react-router-express/package.json +++ b/packages/react-router-express/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/express", - "version": "7.6.3", + "version": "7.7.0", "description": "Express server request handler for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-fs-routes/CHANGELOG.md b/packages/react-router-fs-routes/CHANGELOG.md index 99b996aa5d..f3f5c0ed4e 100644 --- a/packages/react-router-fs-routes/CHANGELOG.md +++ b/packages/react-router-fs-routes/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/fs-routes` +## 7.7.0 + +### Patch Changes + +- Updated dependencies: + - `@react-router/dev@7.7.0` + ## 7.6.3 ### Patch Changes diff --git a/packages/react-router-fs-routes/package.json b/packages/react-router-fs-routes/package.json index 739b1e1de0..af9557d988 100644 --- a/packages/react-router-fs-routes/package.json +++ b/packages/react-router-fs-routes/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/fs-routes", - "version": "7.6.3", + "version": "7.7.0", "description": "File system routing conventions for React Router, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-node/CHANGELOG.md b/packages/react-router-node/CHANGELOG.md index fbdf183083..c1159aa348 100644 --- a/packages/react-router-node/CHANGELOG.md +++ b/packages/react-router-node/CHANGELOG.md @@ -1,5 +1,12 @@ # `@react-router/node` +## 7.7.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.7.0` + ## 7.6.3 ### Patch Changes diff --git a/packages/react-router-node/package.json b/packages/react-router-node/package.json index 50ba0a54f8..842ef34150 100644 --- a/packages/react-router-node/package.json +++ b/packages/react-router-node/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/node", - "version": "7.6.3", + "version": "7.7.0", "description": "Node.js platform abstractions for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md index 18db23071f..4cb09c7931 100644 --- a/packages/react-router-remix-routes-option-adapter/CHANGELOG.md +++ b/packages/react-router-remix-routes-option-adapter/CHANGELOG.md @@ -1,5 +1,16 @@ # `@react-router/remix-config-routes-adapter` +## 7.7.0 + +### Minor Changes + +- Export `DefineRouteFunction` type alongside `DefineRoutesFunction` ([#13945](https://github.com/remix-run/react-router/pull/13945)) + +### Patch Changes + +- Updated dependencies: + - `@react-router/dev@7.7.0` + ## 7.6.3 ### Patch Changes diff --git a/packages/react-router-remix-routes-option-adapter/defineRoutes.ts b/packages/react-router-remix-routes-option-adapter/defineRoutes.ts index cc77a2086e..2aa4be00d6 100644 --- a/packages/react-router-remix-routes-option-adapter/defineRoutes.ts +++ b/packages/react-router-remix-routes-option-adapter/defineRoutes.ts @@ -28,7 +28,7 @@ interface DefineRouteChildren { (): void; } -interface DefineRouteFunction { +export interface DefineRouteFunction { ( /** * The path this route uses to match the URL pathname. diff --git a/packages/react-router-remix-routes-option-adapter/index.ts b/packages/react-router-remix-routes-option-adapter/index.ts index 0e48db5114..e19a06b058 100644 --- a/packages/react-router-remix-routes-option-adapter/index.ts +++ b/packages/react-router-remix-routes-option-adapter/index.ts @@ -1,9 +1,13 @@ import { type RouteConfigEntry } from "@react-router/dev/routes"; import { routeManifestToRouteConfig } from "./manifest"; -import { defineRoutes, type DefineRoutesFunction } from "./defineRoutes"; +import { + defineRoutes, + type DefineRoutesFunction, + type DefineRouteFunction, +} from "./defineRoutes"; -export type { DefineRoutesFunction }; +export type { DefineRoutesFunction, DefineRouteFunction }; /** * Adapts routes defined using [Remix's `routes` config diff --git a/packages/react-router-remix-routes-option-adapter/package.json b/packages/react-router-remix-routes-option-adapter/package.json index 515edbd807..4ac13534ce 100644 --- a/packages/react-router-remix-routes-option-adapter/package.json +++ b/packages/react-router-remix-routes-option-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/remix-routes-option-adapter", - "version": "7.6.3", + "version": "7.7.0", "description": "Adapter for Remix's \"routes\" config option, for use within routes.ts", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router-serve/CHANGELOG.md b/packages/react-router-serve/CHANGELOG.md index 1e56b236f7..f565fc0e73 100644 --- a/packages/react-router-serve/CHANGELOG.md +++ b/packages/react-router-serve/CHANGELOG.md @@ -1,5 +1,14 @@ # `@react-router/serve` +## 7.7.0 + +### Patch Changes + +- Updated dependencies: + - `react-router@7.7.0` + - `@react-router/node@7.7.0` + - `@react-router/express@7.7.0` + ## 7.6.3 ### Patch Changes diff --git a/packages/react-router-serve/package.json b/packages/react-router-serve/package.json index e259d97a2d..2e51c3b4d5 100644 --- a/packages/react-router-serve/package.json +++ b/packages/react-router-serve/package.json @@ -1,6 +1,6 @@ { "name": "@react-router/serve", - "version": "7.6.3", + "version": "7.7.0", "description": "Production application server for React Router", "bugs": { "url": "https://github.com/remix-run/react-router/issues" diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 29c71685c6..b28fb53629 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -1,5 +1,54 @@ # `react-router` +## 7.7.0 + +### Minor Changes + +- Add unstable RSC support ([#13700](https://github.com/remix-run/react-router/pull/13700)) + + For more information, see the [RSC documentation](https://reactrouter.com/start/rsc/installation). + +### Patch Changes + +- Handle `InvalidCharacterError` when validating cookie signature ([#13847](https://github.com/remix-run/react-router/pull/13847)) + +- Pass a copy of `searchParams` to the `setSearchParams` callback function to avoid muations of the internal `searchParams` instance. This was an issue when navigations were blocked because the internal instance be out of sync with `useLocation().search`. ([#12784](https://github.com/remix-run/react-router/pull/12784)) + +- Support invalid `Date` in `turbo-stream` v2 fork ([#13684](https://github.com/remix-run/react-router/pull/13684)) + +- In Framework Mode, clear critical CSS in development after initial render ([#13872](https://github.com/remix-run/react-router/pull/13872)) + +- Strip search parameters from `patchRoutesOnNavigation` `path` param for fetcher calls ([#13911](https://github.com/remix-run/react-router/pull/13911)) + +- Skip scroll restoration on useRevalidator() calls because they're not new locations ([#13671](https://github.com/remix-run/react-router/pull/13671)) + +- Support unencoded UTF-8 routes in prerender config with `ssr` set to `false` ([#13699](https://github.com/remix-run/react-router/pull/13699)) + +- Do not throw if the url hash is not a valid URI component ([#13247](https://github.com/remix-run/react-router/pull/13247)) + +- Fix a regression in `createRoutesStub` introduced with the middleware feature. ([#13946](https://github.com/remix-run/react-router/pull/13946)) + + As part of that work we altered the signature to align with the new middleware APIs without making it backwards compatible with the prior `AppLoadContext` API. This permitted `createRoutesStub` to work if you were opting into middleware and the updated `context` typings, but broke `createRoutesStub` for users not yet opting into middleware. + + We've reverted this change and re-implemented it in such a way that both sets of users can leverage it. + + ```tsx + // If you have not opted into middleware, the old API should work again + let context: AppLoadContext = { + /*...*/ + }; + let Stub = createRoutesStub(routes, context); + + // If you have opted into middleware, you should now pass an instantiated `unstable_routerContextProvider` instead of a `getContext` factory function. + let context = new unstable_RouterContextProvider(); + context.set(SomeContext, someValue); + let Stub = createRoutesStub(routes, context); + ``` + + โš ๏ธ This may be a breaking bug for if you have adopted the unstable Middleware feature and are using `createRoutesStub` with the updated API. + +- Remove `Content-Length` header from Single Fetch responses ([#13902](https://github.com/remix-run/react-router/pull/13902)) + ## 7.6.3 ### Patch Changes diff --git a/packages/react-router/__tests__/dom/search-params-test.tsx b/packages/react-router/__tests__/dom/search-params-test.tsx index 7860d1a320..38002b2065 100644 --- a/packages/react-router/__tests__/dom/search-params-test.tsx +++ b/packages/react-router/__tests__/dom/search-params-test.tsx @@ -1,7 +1,16 @@ import * as React from "react"; import * as ReactDOM from "react-dom/client"; import { act } from "@testing-library/react"; -import { MemoryRouter, Routes, Route, useSearchParams } from "../../index"; +import { + MemoryRouter, + Routes, + Route, + useSearchParams, + createBrowserRouter, + useBlocker, + RouterProvider, + useLocation, +} from "../../index"; describe("useSearchParams", () => { let node: HTMLDivElement; @@ -182,4 +191,107 @@ describe("useSearchParams", () => { `"

value=initial&a=1&b=2

"` ); }); + + it("does not reflect functional update mutation when navigation is blocked", () => { + let router = createBrowserRouter([ + { + path: "/", + Component() { + let location = useLocation(); + let [searchParams, setSearchParams] = useSearchParams(); + let [shouldBlock, setShouldBlock] = React.useState(false); + let b = useBlocker(shouldBlock); + return ( + <> +
+                {`location.search=${location.search}`}
+                {`searchParams=${searchParams.toString()}`}
+                {`blocked=${b.state}`}
+              
+ + + + + ); + }, + }, + ]); + + act(() => { + ReactDOM.createRoot(node).render(); + }); + + expect(node.querySelector("#output")).toMatchInlineSnapshot(` +
+        location.search=
+        searchParams=
+        blocked=unblocked
+      
+ `); + + act(() => { + node + .querySelector("#navigate1")! + .dispatchEvent(new Event("click", { bubbles: true })); + }); + + expect(node.querySelector("#output")).toMatchInlineSnapshot(` +
+        location.search=?foo=bar
+        searchParams=foo=bar
+        blocked=unblocked
+      
+ `); + + act(() => { + node + .querySelector("#toggle-blocking")! + .dispatchEvent(new Event("click", { bubbles: true })); + }); + + act(() => { + node + .querySelector("#navigate2")! + .dispatchEvent(new Event("click", { bubbles: true })); + }); + + expect(node.querySelector("#output")).toMatchInlineSnapshot(` +
+        location.search=?foo=bar
+        searchParams=foo=bar
+        blocked=blocked
+      
+ `); + }); }); diff --git a/packages/react-router/__tests__/dom/stub-test.tsx b/packages/react-router/__tests__/dom/stub-test.tsx index edb2098691..821f71548d 100644 --- a/packages/react-router/__tests__/dom/stub-test.tsx +++ b/packages/react-router/__tests__/dom/stub-test.tsx @@ -12,7 +12,10 @@ import { type LoaderFunctionArgs, useRouteError, } from "../../index"; -import { unstable_createContext } from "../../lib/router/utils"; +import { + unstable_RouterContextProvider, + unstable_createContext, +} from "../../lib/router/utils"; test("renders a route", () => { let RoutesStub = createRoutesStub([ @@ -236,7 +239,50 @@ test("can pass a predefined loader", () => { ]); }); -test("can pass context values", async () => { +test("can pass context values (w/o middleware)", async () => { + let RoutesStub = createRoutesStub( + [ + { + path: "/", + HydrateFallback: () => null, + Component() { + let data = useLoaderData() as string; + return ( +
+
Context: {data}
+ +
+ ); + }, + loader({ context }) { + return context.message; + }, + children: [ + { + path: "hello", + Component() { + let data = useLoaderData() as string; + return
Context: {data}
; + }, + loader({ context }) { + return context.message; + }, + }, + ], + }, + ], + { message: "hello" } + ); + + render(); + + expect(await screen.findByTestId("root")).toHaveTextContent(/Context: hello/); + expect(await screen.findByTestId("hello")).toHaveTextContent( + /Context: hello/ + ); +}); + +test("can pass context values (w/middleware)", async () => { let helloContext = unstable_createContext(); let RoutesStub = createRoutesStub( [ @@ -269,10 +315,15 @@ test("can pass context values", async () => { ], }, ], - () => new Map([[helloContext, "hello"]]) + new unstable_RouterContextProvider(new Map([[helloContext, "hello"]])) ); - render(); + render( + + ); expect(await screen.findByTestId("root")).toHaveTextContent(/Context: hello/); expect(await screen.findByTestId("hello")).toHaveTextContent( diff --git a/packages/react-router/__tests__/router/fetchers-test.ts b/packages/react-router/__tests__/router/fetchers-test.ts index 739ea8bd46..63fecc377c 100644 --- a/packages/react-router/__tests__/router/fetchers-test.ts +++ b/packages/react-router/__tests__/router/fetchers-test.ts @@ -116,6 +116,50 @@ describe("fetchers", () => { expect(router._internalFetchControllers.size).toBe(0); }); + it("unabstracted loader fetch with fog of war", async () => { + let dfd = createDeferred(); + let router = createRouter({ + history: createMemoryHistory({ initialEntries: ["/"] }), + routes: [ + { + id: "root", + // Note: No path is provided on the root route in this test to + // ensure nothing matches before routes are patched + }, + ], + hydrationData: { + loaderData: { root: "ROOT DATA" }, + }, + async patchRoutesOnNavigation({ path, patch }) { + if (path === "/lazy") { + patch("root", [ + { + id: "lazy", + path: "/lazy", + loader: () => dfd.promise, + }, + ]); + } + }, + }); + let fetcherData = getFetcherData(router); + + let key = "key"; + router.fetch(key, "lazy", "/lazy"); + expect(router.getFetcher(key)).toEqual({ + state: "loading", + formMethod: undefined, + formEncType: undefined, + formData: undefined, + }); + + await dfd.resolve("DATA"); + expect(router.getFetcher(key)).toBe(IDLE_FETCHER); + expect(fetcherData.get(key)).toBe("DATA"); + + expect(router._internalFetchControllers.size).toBe(0); + }); + it("loader fetch", async () => { let t = initializeTest({ url: "/foo", diff --git a/packages/react-router/__tests__/router/lazy-discovery-test.ts b/packages/react-router/__tests__/router/lazy-discovery-test.ts index 13b84c3793..1d750cfe6d 100644 --- a/packages/react-router/__tests__/router/lazy-discovery-test.ts +++ b/packages/react-router/__tests__/router/lazy-discovery-test.ts @@ -2333,5 +2333,92 @@ describe("Lazy Route Discovery (Fog of War)", () => { expect(router.getFetcher(key).state).toBe("idle"); expect(fetcherData.get(key)).toBe("C ACTION"); }); + + it("does not include search params in the `path` (fetcher.load)", async () => { + let capturedPath; + + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "parent", + }, + ], + async patchRoutesOnNavigation({ path, patch }) { + capturedPath = path; + patch("parent", [ + { + id: "child", + path: "child", + loader: () => "CHILD", + }, + ]); + }, + }); + + let key = "key"; + + let data; + router.subscribe((state) => { + if (state.fetchers.has("key")) { + data = state.fetchers.get("key")!.data; + } + }); + + router.fetch(key, "0", "/parent/child?a=b"); + await tick(); + expect(router.getFetcher(key).state).toBe("idle"); + expect(data).toBe("CHILD"); + expect(capturedPath).toBe("/parent/child"); + }); + + it("does not include search params in the `path` (fetcher.submit)", async () => { + let capturedPath; + + router = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: "/", + }, + { + id: "parent", + path: "parent", + }, + ], + async patchRoutesOnNavigation({ path, patch }) { + capturedPath = path; + patch("parent", [ + { + id: "child", + path: "child", + action: () => "CHILD", + }, + ]); + }, + }); + + let key = "key"; + + let data; + router.subscribe((state) => { + if (state.fetchers.has("key")) { + data = state.fetchers.get("key")!.data; + } + }); + + router.fetch(key, "0", "/parent/child?a=b", { + formMethod: "post", + formData: createFormData({}), + }); + await tick(); + expect(router.getFetcher(key).state).toBe("idle"); + expect(data).toBe("CHILD"); + expect(capturedPath).toBe("/parent/child"); + }); }); }); diff --git a/packages/react-router/__tests__/router/scroll-restoration-test.ts b/packages/react-router/__tests__/router/scroll-restoration-test.ts index 0b1dc5838d..edf313c281 100644 --- a/packages/react-router/__tests__/router/scroll-restoration-test.ts +++ b/packages/react-router/__tests__/router/scroll-restoration-test.ts @@ -235,6 +235,46 @@ describe("scroll restoration", () => { expect(t.router.state.restoreScrollPosition).toBe(50); expect(t.router.state.preventScrollReset).toBe(false); }); + + it("does not restore scroll on revalidation", async () => { + let t = setup({ + routes: SCROLL_ROUTES, + initialEntries: ["/"], + }); + + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(false); + + let positions = {}; + + // Simulate scrolling to 100 on / + let activeScrollPosition = 100; + t.router.enableScrollRestoration(positions, () => activeScrollPosition); + + // Revalidate + let R = await t.revalidate(); + await R.loaders.index.resolve("INDEX"); + + expect(t.router.state.restoreScrollPosition).toBe(false); + expect(t.router.state.preventScrollReset).toBe(false); + + // Scroll to 200 + activeScrollPosition = 200; + + // Go to /tasks + let nav1 = await t.navigate("/tasks"); + await nav1.loaders.tasks.resolve("TASKS"); + + expect(t.router.state.restoreScrollPosition).toBe(null); + expect(t.router.state.preventScrollReset).toBe(false); + + // Restore on pop back to / + let nav2 = await t.navigate(-1); + expect(t.router.state.restoreScrollPosition).toBe(null); + await nav2.loaders.index.resolve("INDEX"); + expect(t.router.state.restoreScrollPosition).toBe(200); + expect(t.router.state.preventScrollReset).toBe(false); + }); }); describe("scroll reset", () => { diff --git a/packages/react-router/__tests__/server-runtime/cookies-test.ts b/packages/react-router/__tests__/server-runtime/cookies-test.ts index 3b52844be4..a18cd75e47 100644 --- a/packages/react-router/__tests__/server-runtime/cookies-test.ts +++ b/packages/react-router/__tests__/server-runtime/cookies-test.ts @@ -77,6 +77,20 @@ describe("cookies", () => { expect(value).toBe(null); }); + it("fails to parse signed string values with invalid signature encoding", async () => { + let cookie = createCookie("my-cookie", { + secrets: ["secret1"], + }); + let setCookie = await cookie.serialize("hello michael"); + let cookie2 = createCookie("my-cookie", { + secrets: ["secret2"], + }); + // use characters that are invalid for base64 encoding + let value = await cookie2.parse(getCookieFromSetCookie(setCookie) + "%^&"); + + expect(value).toBe(null); + }); + it("parses/serializes signed object values", async () => { let cookie = createCookie("my-cookie", { secrets: ["secret1"], diff --git a/packages/react-router/__tests__/vendor/turbo-stream-test.ts b/packages/react-router/__tests__/vendor/turbo-stream-test.ts index 0ddc1131dd..54332b1703 100644 --- a/packages/react-router/__tests__/vendor/turbo-stream-test.ts +++ b/packages/react-router/__tests__/vendor/turbo-stream-test.ts @@ -50,6 +50,13 @@ test("should encode and decode Date", async () => { expect(output).toEqual(input); }); +test("should encode and decode invalid Date", async () => { + const input = new Date("invalid"); + const output = await quickDecode(encode(input)); + expect(isNaN(input.getTime())).toBe(true); + expect(isNaN(output.getTime())).toBe(true); +}); + test("should encode and decode NaN", async () => { const input = NaN; const output = await quickDecode(encode(input)); diff --git a/packages/react-router/dom-export.ts b/packages/react-router/dom-export.ts index 4c0d218ed4..127fc64213 100644 --- a/packages/react-router/dom-export.ts +++ b/packages/react-router/dom-export.ts @@ -1,3 +1,5 @@ +"use client"; + export type { RouterProviderProps } from "./lib/dom-export/dom-router-provider"; export { RouterProvider } from "./lib/dom-export/dom-router-provider"; export { HydratedRouter } from "./lib/dom-export/hydrated-router"; diff --git a/packages/react-router/index-react-server-client.ts b/packages/react-router/index-react-server-client.ts new file mode 100644 index 0000000000..bad7e1376c --- /dev/null +++ b/packages/react-router/index-react-server-client.ts @@ -0,0 +1,26 @@ +"use client"; + +export { + Await, + MemoryRouter, + Navigate, + Outlet, + Route, + Router, + RouterProvider, + Routes, + WithComponentProps as UNSAFE_WithComponentProps, + WithErrorBoundaryProps as UNSAFE_WithErrorBoundaryProps, + WithHydrateFallbackProps as UNSAFE_WithHydrateFallbackProps, +} from "./lib/components"; +export { + BrowserRouter, + HashRouter, + Link, + HistoryRouter as unstable_HistoryRouter, + NavLink, + Form, + ScrollRestoration, +} from "./lib/dom/lib"; +export { StaticRouter, StaticRouterProvider } from "./lib/dom/server"; +export { Meta, Links } from "./lib/dom/ssr/components"; diff --git a/packages/react-router/index-react-server.ts b/packages/react-router/index-react-server.ts new file mode 100644 index 0000000000..fe245336e0 --- /dev/null +++ b/packages/react-router/index-react-server.ts @@ -0,0 +1,84 @@ +// RSC APIs +export { matchRSCServerRequest as unstable_matchRSCServerRequest } from "./lib/rsc/server.rsc"; + +export type { + DecodeActionFunction as unstable_DecodeActionFunction, + DecodeFormStateFunction as unstable_DecodeFormStateFunction, + DecodeReplyFunction as unstable_DecodeReplyFunction, + LoadServerActionFunction as unstable_LoadServerActionFunction, + RSCManifestPayload as unstable_RSCManifestPayload, + RSCMatch as unstable_RSCMatch, + RSCPayload as unstable_RSCPayload, + RSCRenderPayload as unstable_RSCRenderPayload, + RSCRouteManifest as unstable_RSCRouteManifest, + RSCRouteMatch as unstable_RSCRouteMatch, + RSCRouteConfigEntry as unstable_RSCRouteConfigEntry, + RSCRouteConfig as unstable_RSCRouteConfig, +} from "./lib/rsc/server.rsc"; + +// RSC implementation of agnostic APIs +export { redirect, redirectDocument, replace } from "./lib/rsc/server.rsc"; + +// Client references +export { + Await, + BrowserRouter, + Form, + HashRouter, + Link, + Links, + MemoryRouter, + Meta, + Navigate, + NavLink, + Outlet, + Route, + Router, + RouterProvider, + Routes, + ScrollRestoration, + StaticRouter, + StaticRouterProvider, + unstable_HistoryRouter, +} from "react-router/internal/react-server-client"; + +// Shared implementation of agnostic APIs +export { createStaticHandler } from "./lib/router/router"; +export { + data, + matchRoutes, + unstable_createContext, + unstable_RouterContextProvider, +} from "./lib/router/utils"; + +export { createCookie, isCookie } from "./lib/server-runtime/cookies"; +export { + createSession, + createSessionStorage, + isSession, +} from "./lib/server-runtime/sessions"; +export { createCookieSessionStorage } from "./lib/server-runtime/sessions/cookieStorage"; +export { createMemorySessionStorage } from "./lib/server-runtime/sessions/memoryStorage"; + +export type { + unstable_MiddlewareFunction, + unstable_MiddlewareNextFunction, + unstable_RouterContext, +} from "./lib/router/utils"; + +export type { + Cookie, + CookieOptions, + CookieParseOptions, + CookieSerializeOptions, + CookieSignatureOptions, + IsCookieFunction, +} from "./lib/server-runtime/cookies"; +export type { + IsSessionFunction, + Session, + SessionData, + SessionIdStorageStrategy, + SessionStorage, + FlashSessionData, +} from "./lib/server-runtime/sessions"; diff --git a/packages/react-router/index.ts b/packages/react-router/index.ts index fddcd940eb..6610e517f5 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -1,3 +1,5 @@ +"use client"; + // Expose old @remix-run/router API export type { InitialEntry, Location, Path, To } from "./lib/router/history"; export type { @@ -280,6 +282,42 @@ export type { unstable_SerializesTo } from "./lib/types/serializes-to.ts"; export type { Register } from "./lib/types/register"; export { href } from "./lib/href"; +// RSC +export type { + BrowserCreateFromReadableStreamFunction as unstable_BrowserCreateFromReadableStreamFunction, + EncodeReplyFunction as unstable_EncodeReplyFunction, +} from "./lib/rsc/browser"; +export { + createCallServer as unstable_createCallServer, + RSCHydratedRouter as unstable_RSCHydratedRouter, +} from "./lib/rsc/browser"; +export type { SSRCreateFromReadableStreamFunction as unstable_SSRCreateFromReadableStreamFunction } from "./lib/rsc/server.ssr"; +export { + routeRSCServerRequest as unstable_routeRSCServerRequest, + RSCStaticRouter as unstable_RSCStaticRouter, +} from "./lib/rsc/server.ssr"; +export { getRSCStream as unstable_getRSCStream } from "./lib/rsc/html-stream/browser"; +export { RSCDefaultRootErrorBoundary as UNSAFE_RSCDefaultRootErrorBoundary } from "./lib/rsc/errorBoundaries"; + +// Re-export of RSC types +import type { matchRSCServerRequest } from "./lib/rsc/server.rsc"; +export declare const unstable_matchRSCServerRequest: typeof matchRSCServerRequest; + +export type { + DecodeActionFunction as unstable_DecodeActionFunction, + DecodeFormStateFunction as unstable_DecodeFormStateFunction, + DecodeReplyFunction as unstable_DecodeReplyFunction, + LoadServerActionFunction as unstable_LoadServerActionFunction, + RSCManifestPayload as unstable_RSCManifestPayload, + RSCMatch as unstable_RSCMatch, + RSCPayload as unstable_RSCPayload, + RSCRenderPayload as unstable_RSCRenderPayload, + RSCRouteManifest as unstable_RSCRouteManifest, + RSCRouteMatch as unstable_RSCRouteMatch, + RSCRouteConfigEntry as unstable_RSCRouteConfigEntry, + RSCRouteConfig as unstable_RSCRouteConfig, +} from "./lib/rsc/server.rsc"; + /////////////////////////////////////////////////////////////////////////////// // DANGER! PLEASE READ ME! // We provide these exports as an escape hatch in the event that you need any @@ -320,8 +358,11 @@ export { export { hydrationRouteProperties as UNSAFE_hydrationRouteProperties, mapRouteProperties as UNSAFE_mapRouteProperties, + WithComponentProps as UNSAFE_WithComponentProps, withComponentProps as UNSAFE_withComponentProps, + WithHydrateFallbackProps as UNSAFE_WithHydrateFallbackProps, withHydrateFallbackProps as UNSAFE_withHydrateFallbackProps, + WithErrorBoundaryProps as UNSAFE_WithErrorBoundaryProps, withErrorBoundaryProps as UNSAFE_withErrorBoundaryProps, } from "./lib/components"; diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 769dc28bf1..a422823662 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -1235,57 +1235,85 @@ export function renderMatches( return _renderMatches(matches); } -export type RouteComponentType = React.ComponentType<{ - params: ReturnType; - loaderData: ReturnType; - actionData: ReturnType; - matches: ReturnType; -}>; +function useRouteComponentProps() { + return { + params: useParams(), + loaderData: useLoaderData(), + actionData: useActionData(), + matches: useMatches(), + }; +} + +export type RouteComponentProps = ReturnType; +export type RouteComponentType = React.ComponentType; + +export function WithComponentProps({ + children, +}: { + children: React.ReactElement; +}) { + const props = useRouteComponentProps(); + return React.cloneElement(children, props); +} export function withComponentProps(Component: RouteComponentType) { return function WithComponentProps() { - const props = { - params: useParams(), - loaderData: useLoaderData(), - actionData: useActionData(), - matches: useMatches(), - }; + const props = useRouteComponentProps(); return React.createElement(Component, props); }; } -export type HydrateFallbackType = React.ComponentType<{ - params: ReturnType; - loaderData: ReturnType; - actionData: ReturnType; -}>; +function useHydrateFallbackProps() { + return { + params: useParams(), + loaderData: useLoaderData(), + actionData: useActionData(), + }; +} + +export type HydrateFallbackProps = ReturnType; +export type HydrateFallbackType = React.ComponentType; + +export function WithHydrateFallbackProps({ + children, +}: { + children: React.ReactElement; +}) { + const props = useHydrateFallbackProps(); + return React.cloneElement(children, props); +} export function withHydrateFallbackProps(HydrateFallback: HydrateFallbackType) { return function WithHydrateFallbackProps() { - const props = { - params: useParams(), - loaderData: useLoaderData(), - actionData: useActionData(), - }; + const props = useHydrateFallbackProps(); return React.createElement(HydrateFallback, props); }; } -export type ErrorBoundaryType = React.ComponentType<{ - params: ReturnType; - loaderData: ReturnType; - actionData: ReturnType; - error: ReturnType; -}>; +function useErrorBoundaryProps() { + return { + params: useParams(), + loaderData: useLoaderData(), + actionData: useActionData(), + error: useRouteError(), + }; +} + +export type ErrorBoundaryProps = ReturnType; +export type ErrorBoundaryType = React.ComponentType; + +export function WithErrorBoundaryProps({ + children, +}: { + children: React.ReactElement; +}) { + const props = useErrorBoundaryProps(); + return React.cloneElement(children, props); +} export function withErrorBoundaryProps(ErrorBoundary: ErrorBoundaryType) { return function WithErrorBoundaryProps() { - const props = { - params: useParams(), - loaderData: useLoaderData(), - actionData: useActionData(), - error: useRouteError(), - }; + const props = useErrorBoundaryProps(); return React.createElement(ErrorBoundary, props); }; } diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index 03587bcf54..9ff5c96891 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -101,6 +101,12 @@ export const DataRouterStateContext = React.createContext< >(null); DataRouterStateContext.displayName = "DataRouterState"; +export const RSCRouterContext = React.createContext(false); + +export function useIsRSCRouterContext(): boolean { + return React.useContext(RSCRouterContext); +} + export type ViewTransitionContextObject = | { isTransitioning: false; diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx index e4faad01b8..949af934b2 100644 --- a/packages/react-router/lib/dom-export/hydrated-router.tsx +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -24,6 +24,7 @@ import { UNSAFE_hydrationRouteProperties as hydrationRouteProperties, UNSAFE_createClientRoutesWithHMRRevalidationOptOut as createClientRoutesWithHMRRevalidationOptOut, } from "react-router"; +import { CRITICAL_CSS_DATA_ATTRIBUTE } from "../dom/ssr/components"; import { RouterProvider } from "./dom-router-provider"; type SSRInfo = { @@ -229,19 +230,35 @@ export function HydratedRouter(props: HydratedRouterProps) { }); } - // Critical CSS can become stale after code changes, e.g. styles might be - // removed from a component, but the styles will still be present in the - // server HTML. This allows our HMR logic to clear the critical CSS state. + // We only want to show critical CSS in dev for the initial server render to + // avoid a flash of unstyled content. Once the client-side JS kicks in, we can + // clear it to avoid duplicate styles. let [criticalCss, setCriticalCss] = React.useState( process.env.NODE_ENV === "development" ? ssrInfo?.context.criticalCss : undefined ); - if (process.env.NODE_ENV === "development") { - if (ssrInfo) { - window.__reactRouterClearCriticalCss = () => setCriticalCss(undefined); + React.useEffect(() => { + if (process.env.NODE_ENV === "development") { + setCriticalCss(undefined); } - } + }, []); + React.useEffect(() => { + if (process.env.NODE_ENV === "development" && criticalCss === undefined) { + // When there's a hydration mismatch, React 19 ignores the server HTML and + // re-renders from the root, but it doesn't remove any head tags that + // aren't present in the virtual DOM. This means that the original + // critical CSS elements are still in the document even though we cleared + // them in the effect above. To fix this, this effect is designed to clean + // up any leftover elements. If `criticalCss` is undefined in this effect, + // this means that React is no longer managing the critical CSS elements, + // so if there are any left in the document, these are stale elements from + // the original SSR pass and we can safely remove them. + document + .querySelectorAll(`[${CRITICAL_CSS_DATA_ATTRIBUTE}]`) + .forEach((element) => element.remove()); + } + }, [criticalCss]); let [location, setLocation] = React.useState(router.state.location); diff --git a/packages/react-router/lib/dom/global.ts b/packages/react-router/lib/dom/global.ts index d781dce03d..d844a32289 100644 --- a/packages/react-router/lib/dom/global.ts +++ b/packages/react-router/lib/dom/global.ts @@ -29,7 +29,6 @@ declare global { var __reactRouterRouteModules: RouteModules | undefined; var __reactRouterDataRouter: DataRouter | undefined; var __reactRouterHdrActive: boolean; - var __reactRouterClearCriticalCss: (() => void) | undefined; var $RefreshRuntime$: | { performReactRefresh: () => void; diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index e280d79691..7147e3a8d1 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -114,12 +114,11 @@ const isBrowser = // Core Web Vitals Technology Report. This way they can configure the `wappalyzer` // to detect and properly classify live websites as being built with React Router: // https://github.com/HTTPArchive/wappalyzer/blob/main/src/technologies/r.json -declare global { - const REACT_ROUTER_VERSION: string; -} try { if (isBrowser) { - window.__reactRouterVersion = REACT_ROUTER_VERSION; + window.__reactRouterVersion = + // @ts-expect-error + REACT_ROUTER_VERSION; } } catch (e) { // no-op @@ -1425,7 +1424,9 @@ export function useSearchParams( let setSearchParams = React.useCallback( (nextInit, navigateOptions) => { const newSearchParams = createSearchParams( - typeof nextInit === "function" ? nextInit(searchParams) : nextInit + typeof nextInit === "function" + ? nextInit(new URLSearchParams(searchParams)) + : nextInit ); hasSetSearchParamsRef.current = true; navigate("?" + newSearchParams, navigateOptions); @@ -2077,7 +2078,7 @@ export function useScrollRestoration({ // Restore scrolling when state.restoreScrollPosition changes // eslint-disable-next-line react-hooks/rules-of-hooks React.useLayoutEffect(() => { - // Explicit false means don't do anything (used for submissions) + // Explicit false means don't do anything (used for submissions or revalidations) if (restoreScrollPosition === false) { return; } @@ -2089,14 +2090,23 @@ export function useScrollRestoration({ } // try to scroll to the hash - if (location.hash) { - let el = document.getElementById( - decodeURIComponent(location.hash.slice(1)) - ); - if (el) { - el.scrollIntoView(); - return; + try { + if (location.hash) { + let el = document.getElementById( + decodeURIComponent(location.hash.slice(1)) + ); + if (el) { + el.scrollIntoView(); + return; + } } + } catch { + warning( + false, + `"${location.hash.slice( + 1 + )}" is not a decodable element ID. The view will not scroll to it.` + ); } // Don't reset if this navigation opted out diff --git a/packages/react-router/lib/dom/server.tsx b/packages/react-router/lib/dom/server.tsx index f24d9cb85e..3c03a9034f 100644 --- a/packages/react-router/lib/dom/server.tsx +++ b/packages/react-router/lib/dom/server.tsx @@ -374,6 +374,9 @@ export function createStaticRouter( _internalSetRoutes() { throw msg("_internalSetRoutes"); }, + _internalSetStateDoNotUseOrYouWillBreakYourApp() { + throw msg("_internalSetStateDoNotUseOrYouWillBreakYourApp"); + }, }; } diff --git a/packages/react-router/lib/dom/ssr/components.tsx b/packages/react-router/lib/dom/ssr/components.tsx index e2ae70d049..76c4ee747a 100644 --- a/packages/react-router/lib/dom/ssr/components.tsx +++ b/packages/react-router/lib/dom/ssr/components.tsx @@ -27,7 +27,12 @@ import type { MetaMatches, } from "./routeModules"; import { singleFetchUrl } from "./single-fetch"; -import { DataRouterContext, DataRouterStateContext } from "../../context"; +import { + DataRouterContext, + DataRouterStateContext, + useIsRSCRouterContext, +} from "../../context"; +import { warnOnce } from "../../server-runtime/warnings"; import { useLocation } from "../../hooks"; import { getPartialManifest, isFogOfWarEnabled } from "./fog-of-war"; import type { PageLinkDescriptor } from "../../router/links"; @@ -207,6 +212,8 @@ function getActiveMatches( return matches; } +export const CRITICAL_CSS_DATA_ATTRIBUTE = "data-react-router-critical-css"; + /** Renders all of the `` tags created by route module {@link LinksFunction} export. You should render it inside the `` of your document. @@ -242,10 +249,17 @@ export function Links() { return ( <> {typeof criticalCss === "string" ? ( -