diff --git a/.changeset/config.json b/.changeset/config.json index 8e1dec4247..19a0281431 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,10 +7,17 @@ "commit": false, "fixed": [ [ + "create-react-router", "react-router", "react-router-dom", - "react-router-dom-v5-compat", - "react-router-native" + "@react-router/architect", + "@react-router/cloudflare", + "@react-router/dev", + "@react-router/fs-routes", + "@react-router/express", + "@react-router/node", + "@react-router/remix-routes-option-adapter", + "@react-router/serve" ] ], "linked": [], @@ -18,5 +25,8 @@ "baseBranch": "dev", "updateInternalDependencies": "patch", "bumpVersionsWithWorkspaceProtocolOnly": true, - "ignore": [] + "ignore": ["integration", "integration-*", "@playground/*"], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/.eslintignore b/.eslintignore index fb8eac2f82..d20bd96fff 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,6 +3,8 @@ node_modules/ pnpm-lock.yaml /docs/api examples/**/dist/ +/playground/ +/playground-local/ packages/**/dist/ packages/react-router-dom/server.d.ts packages/react-router-dom/server.js diff --git a/.eslintrc b/.eslintrc index 728a4ef663..61400497f8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,16 @@ "files": ["**/__tests__/**"], "plugins": ["jest"], "extends": ["plugin:jest/recommended"] + }, + { + "files": ["integration/**/*.*"], + "rules": { + "react-hooks/rules-of-hooks": "off" + }, + "env": { + "jest/globals": false + } } - ] + ], + "reportUnusedDisableDirectives": true } diff --git a/.github/workflows/deduplicate-lock-file.yml b/.github/workflows/deduplicate-lock-file.yml index 81b6d08d01..929df876e0 100644 --- a/.github/workflows/deduplicate-lock-file.yml +++ b/.github/workflows/deduplicate-lock-file.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v4 - name: ๐Ÿ“ฆ Setup pnpm - uses: pnpm/action-setup@v3.0.0 + uses: pnpm/action-setup@v4 - name: โŽ” Setup node uses: actions/setup-node@v4 diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 7e6b410c39..cb7b558534 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -22,13 +22,13 @@ jobs: token: ${{ secrets.FORMAT_PAT }} - name: ๐Ÿ“ฆ Setup pnpm - uses: pnpm/action-setup@v3.0.0 + uses: pnpm/action-setup@v4 - name: โŽ” Setup node uses: actions/setup-node@v4 with: - cache: pnpm node-version-file: ".nvmrc" + cache: pnpm - name: ๐Ÿ“ฅ Install deps run: pnpm install --frozen-lockfile diff --git a/.github/workflows/integration-full.yml b/.github/workflows/integration-full.yml new file mode 100644 index 0000000000..3f79331816 --- /dev/null +++ b/.github/workflows/integration-full.yml @@ -0,0 +1,56 @@ +name: Branch + +# main/dev/release-* branches will get the full run across +# all OS/browsers for multiple node versions + +on: + push: + branches: + - main + - dev + - release-* + tags: + - "v0.0.0-nightly-*" + paths-ignore: + - ".changeset/**" + - "decisions/**" + - "docs/**" + - "examples/**" + - "jest/**" + - "scripts/**" + - "tutorial/**" + - "contributors.yml" + - "**/*.md" + +jobs: + build: + name: "โš™๏ธ Build" + if: github.repository == 'remix-run/react-router' + uses: ./.github/workflows/shared-build.yml + + integration-ubuntu: + name: "๐Ÿ‘€ Integration Test" + if: github.repository == 'remix-run/react-router' + uses: ./.github/workflows/shared-integration.yml + with: + os: "ubuntu-latest" + node_version: "[20, 22]" + browser: '["chromium", "firefox"]' + + integration-windows: + name: "๐Ÿ‘€ Integration Test" + if: github.repository == 'remix-run/react-router' + uses: ./.github/workflows/shared-integration.yml + with: + os: "windows-latest" + node_version: "[20, 22]" + browser: '["msedge"]' + + integration-macos: + name: "๐Ÿ‘€ Integration Test" + if: github.repository == 'remix-run/react-router' + uses: ./.github/workflows/shared-integration.yml + with: + os: "macos-latest" + node_version: "[20, 22]" + browser: '["webkit"]' diff --git a/.github/workflows/integration-pr-ubuntu.yml b/.github/workflows/integration-pr-ubuntu.yml new file mode 100644 index 0000000000..ac25713e08 --- /dev/null +++ b/.github/workflows/integration-pr-ubuntu.yml @@ -0,0 +1,35 @@ +name: PR (Base) + +# All PRs touching code will run tests on ubuntu/node/chromium + +on: + pull_request: + paths-ignore: + - ".changeset/**" + - "decisions/**" + - "docs/**" + - "examples/**" + - "jest/**" + - "scripts/**" + - "tutorial/**" + - "contributors.yml" + - "**/*.md" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: "โš™๏ธ Build" + if: github.repository == 'remix-run/react-router' + uses: ./.github/workflows/shared-build.yml + + integration-chromium: + name: "๐Ÿ‘€ Integration Test" + if: github.repository == 'remix-run/react-router' + uses: ./.github/workflows/shared-integration.yml + with: + os: "ubuntu-latest" + node_version: "[22]" + browser: '["chromium"]' diff --git a/.github/workflows/integration-pr-windows-macos.yml b/.github/workflows/integration-pr-windows-macos.yml new file mode 100644 index 0000000000..780a81f289 --- /dev/null +++ b/.github/workflows/integration-pr-windows-macos.yml @@ -0,0 +1,43 @@ +name: PR (Full) + +# PRs touching react-router-dev will also run on Windows and OSX + +on: + pull_request: + paths: + - "pnpm-lock.yaml" + - "integration/**" + - "packages/react-router-dev/**" + - "!**/*.md" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + integration-firefox: + name: "๐Ÿ‘€ Integration Test" + if: github.repository == 'remix-run/react-router' + uses: ./.github/workflows/shared-integration.yml + with: + os: "ubuntu-latest" + node_version: "[22]" + browser: '["firefox"]' + + integration-msedge: + name: "๐Ÿ‘€ Integration Test" + if: github.repository == 'remix-run/react-router' + uses: ./.github/workflows/shared-integration.yml + with: + os: "windows-latest" + node_version: "[22]" + browser: '["msedge"]' + + integration-webkit: + name: "๐Ÿ‘€ Integration Test" + if: github.repository == 'remix-run/react-router' + uses: ./.github/workflows/shared-integration.yml + with: + os: "macos-latest" + node_version: "[22]" + browser: '["webkit"]' diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml index 45a3848fa2..c4da9dac7e 100644 --- a/.github/workflows/no-response.yml +++ b/.github/workflows/no-response.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: ๐Ÿฅบ Handle Ghosting - uses: actions/stale@v8 + uses: actions/stale@v9 with: days-before-close: 10 close-issue-message: > diff --git a/.github/workflows/release-experimental.yml b/.github/workflows/release-experimental.yml index f69d4dd500..0a9437dc89 100644 --- a/.github/workflows/release-experimental.yml +++ b/.github/workflows/release-experimental.yml @@ -26,7 +26,7 @@ jobs: fetch-depth: 0 - name: ๐Ÿ“ฆ Setup pnpm - uses: pnpm/action-setup@v3.0.0 + uses: pnpm/action-setup@v4 - name: โŽ” Setup node uses: actions/setup-node@v4 diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml index 72c0aae27a..62d5db855b 100644 --- a/.github/workflows/release-nightly.yml +++ b/.github/workflows/release-nightly.yml @@ -37,7 +37,7 @@ jobs: fetch-depth: 0 - name: ๐Ÿ“ฆ Setup pnpm - uses: pnpm/action-setup@v3.0.0 + uses: pnpm/action-setup@v4 - name: โŽ” Setup node uses: actions/setup-node@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d2329e622..7301ec56f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: fetch-depth: 0 - name: ๐Ÿ“ฆ Setup pnpm - uses: pnpm/action-setup@v3.0.0 + uses: pnpm/action-setup@v4 - name: โŽ” Setup node uses: actions/setup-node@v4 @@ -74,12 +74,12 @@ jobs: uses: actions/checkout@v4 - name: ๐Ÿ“ฆ Setup pnpm - uses: pnpm/action-setup@v3.0.0 + uses: pnpm/action-setup@v4 - name: โŽ” Setup node uses: actions/setup-node@v4 with: - node-version: 16 + node-version-file: ".nvmrc" cache: "pnpm" - id: find_package_version diff --git a/.github/workflows/shared-build.yml b/.github/workflows/shared-build.yml new file mode 100644 index 0000000000..50bec16d99 --- /dev/null +++ b/.github/workflows/shared-build.yml @@ -0,0 +1,37 @@ +name: ๐Ÿ› ๏ธ Build + +on: + workflow_call: + +env: + CI: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v4 + + - name: ๐Ÿ“ฆ Setup pnpm + uses: pnpm/action-setup@v4 + + - name: โŽ” Setup node + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - uses: google/wireit@setup-github-actions-caching/v2 + + - name: Disable GitHub Actions Annotations + run: | + echo "::remove-matcher owner=tsc::" + echo "::remove-matcher owner=eslint-compact::" + echo "::remove-matcher owner=eslint-stylish::" + + - name: ๐Ÿ“ฅ Install deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ— Build + run: pnpm build diff --git a/.github/workflows/shared-integration.yml b/.github/workflows/shared-integration.yml new file mode 100644 index 0000000000..ee0d86a340 --- /dev/null +++ b/.github/workflows/shared-integration.yml @@ -0,0 +1,64 @@ +name: ๐Ÿงช Test (Integration) + +on: + workflow_call: + inputs: + os: + required: true + type: string + node_version: + required: true + # this is limited to string | boolean | number (https://github.community/t/can-action-inputs-be-arrays/16457) + # but we want to pass an array (node_version: "[20, 22]"), + # so we'll need to manually stringify it for now + type: string + browser: + required: true + # this is limited to string | boolean | number (https://github.community/t/can-action-inputs-be-arrays/16457) + # but we want to pass an array (browser: "['chromium', 'firefox']"), + # so we'll need to manually stringify it for now + type: string + +env: + CI: true + +jobs: + integration: + name: "${{ inputs.os }} / node@${{ matrix.node }} / ${{ matrix.browser }}" + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(inputs.node_version) }} + browser: ${{ fromJSON(inputs.browser) }} + + runs-on: ${{ inputs.os }} + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@v4 + + - name: ๐Ÿ“ฆ Setup pnpm + uses: pnpm/action-setup@v4 + + - name: โŽ” Setup node ${{ matrix.node }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: "pnpm" + + - uses: google/wireit@setup-github-actions-caching/v2 + + - name: Disable GitHub Actions Annotations + run: | + echo "::remove-matcher owner=tsc::" + echo "::remove-matcher owner=eslint-compact::" + echo "::remove-matcher owner=eslint-stylish::" + + - name: ๐Ÿ“ฅ Install deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“ฅ Install Playwright + run: npx playwright install --with-deps ${{ matrix.browser }} + + - name: ๐Ÿ‘€ Run Integration Tests ${{ matrix.browser }} + run: "pnpm test:integration --project=${{ matrix.browser }}" + timeout-minutes: 40 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3fbb8d9cfa..7a364dd55c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,8 +26,8 @@ jobs: fail-fast: false matrix: node: - - 16 - - 18 + - 20 + - 22 runs-on: ubuntu-latest @@ -36,14 +36,16 @@ jobs: uses: actions/checkout@v4 - name: ๐Ÿ“ฆ Setup pnpm - uses: pnpm/action-setup@v3.0.0 + uses: pnpm/action-setup@v4 - name: โŽ” Setup node uses: actions/setup-node@v4 with: + node-version: ${{ matrix.node }} cache: pnpm check-latest: true - node-version: ${{ matrix.node }} + + - uses: google/wireit@setup-github-actions-caching/v2 - name: Disable GitHub Actions Annotations run: | @@ -57,11 +59,11 @@ jobs: - name: ๐Ÿ— Build run: pnpm build + - name: ๐Ÿ” Typecheck + run: pnpm typecheck + - name: ๐Ÿ”ฌ Lint run: pnpm lint - name: ๐Ÿงช Run tests run: pnpm test - - - name: Check bundle size - run: pnpm size diff --git a/.gitignore b/.gitignore index 373afd7082..70fce75324 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ node_modules/ /examples/*/pnpm-lock.yaml /examples/*/dist /tutorial/dist +/playground-local/ +/integration/playwright-report # v5 build files /packages/*/cjs/ @@ -20,9 +22,14 @@ node_modules/ /packages/*/dist/ /packages/*/LICENSE.md -# compat module copies -/packages/react-router-dom-v5-compat/react-router-dom +# v7 build files +.react-router +.wireit .eslintcache +.tmp /.env /NOTES.md + +# v7 reference docs +/public \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2a02e689..1259307b45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,193 +13,218 @@ We manage release notes in this file instead of the paginated Github Releases Pa Table of Contents - [React Router Releases](#react-router-releases) - - [v6.28.0](#v6280) - - [What's Changed](#whats-changed) + - [v7.0.0](#v700) + - [Breaking Changes](#breaking-changes) + - [Package Restructuring](#package-restructuring) + - [Removed Adapter Re-exports](#removed-adapter-re-exports) + - [Removed APIs](#removed-apis) + - [Minimum Versions](#minimum-versions) + - [Adopted Future Flag Behaviors](#adopted-future-flag-behaviors) + - [Vite Compiler](#vite-compiler) + - [Exposed Router Promises](#exposed-router-promises) + - [Other Notable Changes](#other-notable-changes) + - [`routes.ts`](#routests) + - [Typesafety improvements](#typesafety-improvements) + - [Setup](#setup) + - [`typegen` command](#typegen-command) + - [TypeScript plugin](#typescript-plugin) + - [VSCode](#vscode) + - [Troubleshooting](#troubleshooting) + - [Prerendering](#prerendering) + - [Major Changes (`react-router`)](#major-changes-react-router) + - [Major Changes (`@react-router/*`)](#major-changes-react-router-1) - [Minor Changes](#minor-changes) - [Patch Changes](#patch-changes) + - [Changes by Package](#changes-by-package) +- [**Full Changelog**: `v6.28.0...v7.0.0`](#full-changelog-v6280v700) + - [v6.28.0](#v6280) + - [What's Changed](#whats-changed) + - [Minor Changes](#minor-changes-1) + - [Patch Changes](#patch-changes-1) +- [\<\<\<\<\<\<\< HEAD](#-head) - [v6.27.0](#v6270) - [What's Changed](#whats-changed-1) - [Stabilized APIs](#stabilized-apis) - - [Minor Changes](#minor-changes-1) - - [Patch Changes](#patch-changes-1) - - [v6.26.2](#v6262) + - [Minor Changes](#minor-changes-2) - [Patch Changes](#patch-changes-2) - - [v6.26.1](#v6261) + - [v6.26.2](#v6262) - [Patch Changes](#patch-changes-3) - - [v6.26.0](#v6260) - - [Minor Changes](#minor-changes-2) + - [v6.26.1](#v6261) - [Patch Changes](#patch-changes-4) - - [v6.25.1](#v6251) + - [v6.26.0](#v6260) + - [Minor Changes](#minor-changes-3) - [Patch Changes](#patch-changes-5) + - [v6.25.1](#v6251) + - [Patch Changes](#patch-changes-6) - [v6.25.0](#v6250) - [What's Changed](#whats-changed-2) - [Stabilized `v7_skipActionErrorRevalidation`](#stabilized-v7_skipactionerrorrevalidation) - - [Minor Changes](#minor-changes-3) - - [Patch Changes](#patch-changes-6) - - [v6.24.1](#v6241) + - [Minor Changes](#minor-changes-4) - [Patch Changes](#patch-changes-7) + - [v6.24.1](#v6241) + - [Patch Changes](#patch-changes-8) - [v6.24.0](#v6240) - [What's Changed](#whats-changed-3) - [Lazy Route Discovery (a.k.a. "Fog of War")](#lazy-route-discovery-aka-fog-of-war) - - [Minor Changes](#minor-changes-4) - - [Patch Changes](#patch-changes-8) - - [v6.23.1](#v6231) + - [Minor Changes](#minor-changes-5) - [Patch Changes](#patch-changes-9) + - [v6.23.1](#v6231) + - [Patch Changes](#patch-changes-10) - [v6.23.0](#v6230) - [What's Changed](#whats-changed-4) - [Data Strategy (unstable)](#data-strategy-unstable) - [Skip Action Error Revalidation (unstable)](#skip-action-error-revalidation-unstable) - - [Minor Changes](#minor-changes-5) + - [Minor Changes](#minor-changes-6) - [v6.22.3](#v6223) - - [Patch Changes](#patch-changes-10) - - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-11) - - [v6.22.1](#v6221) + - [v6.22.2](#v6222) - [Patch Changes](#patch-changes-12) + - [v6.22.1](#v6221) + - [Patch Changes](#patch-changes-13) - [v6.22.0](#v6220) - [What's Changed](#whats-changed-5) - [Core Web Vitals Technology Report Flag](#core-web-vitals-technology-report-flag) - - [Minor Changes](#minor-changes-6) - - [Patch Changes](#patch-changes-13) - - [v6.21.3](#v6213) + - [Minor Changes](#minor-changes-7) - [Patch Changes](#patch-changes-14) - - [v6.21.2](#v6212) + - [v6.21.3](#v6213) - [Patch Changes](#patch-changes-15) - - [v6.21.1](#v6211) + - [v6.21.2](#v6212) - [Patch Changes](#patch-changes-16) + - [v6.21.1](#v6211) + - [Patch Changes](#patch-changes-17) - [v6.21.0](#v6210) - [What's Changed](#whats-changed-6) - [`future.v7_relativeSplatPath`](#futurev7_relativesplatpath) - [Partial Hydration](#partial-hydration) - - [Minor Changes](#minor-changes-7) - - [Patch Changes](#patch-changes-17) - - [v6.20.1](#v6201) - - [Patch Changes](#patch-changes-18) - - [v6.20.0](#v6200) - [Minor Changes](#minor-changes-8) + - [Patch Changes](#patch-changes-18) + - [v6.20.1](#v6201) - [Patch Changes](#patch-changes-19) + - [v6.20.0](#v6200) + - [Minor Changes](#minor-changes-9) + - [Patch Changes](#patch-changes-20) - [v6.19.0](#v6190) - [What's Changed](#whats-changed-7) - [`unstable_flushSync` API](#unstable_flushsync-api) - - [Minor Changes](#minor-changes-9) - - [Patch Changes](#patch-changes-20) + - [Minor Changes](#minor-changes-10) + - [Patch Changes](#patch-changes-21) - [v6.18.0](#v6180) - [What's Changed](#whats-changed-8) - [New Fetcher APIs](#new-fetcher-apis) - [Persistence Future Flag (`future.v7_fetcherPersist`)](#persistence-future-flag-futurev7_fetcherpersist) - - [Minor Changes](#minor-changes-10) - - [Patch Changes](#patch-changes-21) + - [Minor Changes](#minor-changes-11) + - [Patch Changes](#patch-changes-22) - [v6.17.0](#v6170) - [What's Changed](#whats-changed-9) - [View Transitions ๐Ÿš€](#view-transitions-) - - [Minor Changes](#minor-changes-11) - - [Patch Changes](#patch-changes-22) - - [v6.16.0](#v6160) - [Minor Changes](#minor-changes-12) - [Patch Changes](#patch-changes-23) - - [v6.15.0](#v6150) + - [v6.16.0](#v6160) - [Minor Changes](#minor-changes-13) - [Patch Changes](#patch-changes-24) - - [v6.14.2](#v6142) + - [v6.15.0](#v6150) + - [Minor Changes](#minor-changes-14) - [Patch Changes](#patch-changes-25) - - [v6.14.1](#v6141) + - [v6.14.2](#v6142) - [Patch Changes](#patch-changes-26) + - [v6.14.1](#v6141) + - [Patch Changes](#patch-changes-27) - [v6.14.0](#v6140) - [What's Changed](#whats-changed-10) - [JSON/Text Submissions](#jsontext-submissions) - - [Minor Changes](#minor-changes-14) - - [Patch Changes](#patch-changes-27) + - [Minor Changes](#minor-changes-15) + - [Patch Changes](#patch-changes-28) - [v6.13.0](#v6130) - [What's Changed](#whats-changed-11) - [`future.v7_startTransition`](#futurev7_starttransition) - - [Minor Changes](#minor-changes-15) - - [Patch Changes](#patch-changes-28) - - [v6.12.1](#v6121) + - [Minor Changes](#minor-changes-16) - [Patch Changes](#patch-changes-29) + - [v6.12.1](#v6121) + - [Patch Changes](#patch-changes-30) - [v6.12.0](#v6120) - [What's Changed](#whats-changed-12) - [`React.startTransition` support](#reactstarttransition-support) - - [Minor Changes](#minor-changes-16) - - [Patch Changes](#patch-changes-30) - - [v6.11.2](#v6112) + - [Minor Changes](#minor-changes-17) - [Patch Changes](#patch-changes-31) - - [v6.11.1](#v6111) + - [v6.11.2](#v6112) - [Patch Changes](#patch-changes-32) - - [v6.11.0](#v6110) - - [Minor Changes](#minor-changes-17) + - [v6.11.1](#v6111) - [Patch Changes](#patch-changes-33) + - [v6.11.0](#v6110) + - [Minor Changes](#minor-changes-18) + - [Patch Changes](#patch-changes-34) - [v6.10.0](#v6100) - [What's Changed](#whats-changed-13) - - [Minor Changes](#minor-changes-18) + - [Minor Changes](#minor-changes-19) - [`future.v7_normalizeFormMethod`](#futurev7_normalizeformmethod) - - [Patch Changes](#patch-changes-34) + - [Patch Changes](#patch-changes-35) - [v6.9.0](#v690) - [What's Changed](#whats-changed-14) - [`Component`/`ErrorBoundary` route properties](#componenterrorboundary-route-properties) - [Introducing Lazy Route Modules](#introducing-lazy-route-modules) - - [Minor Changes](#minor-changes-19) - - [Patch Changes](#patch-changes-35) - - [v6.8.2](#v682) + - [Minor Changes](#minor-changes-20) - [Patch Changes](#patch-changes-36) - - [v6.8.1](#v681) + - [v6.8.2](#v682) - [Patch Changes](#patch-changes-37) - - [v6.8.0](#v680) - - [Minor Changes](#minor-changes-20) + - [v6.8.1](#v681) - [Patch Changes](#patch-changes-38) - - [v6.7.0](#v670) + - [v6.8.0](#v680) - [Minor Changes](#minor-changes-21) - [Patch Changes](#patch-changes-39) - - [v6.6.2](#v662) + - [v6.7.0](#v670) + - [Minor Changes](#minor-changes-22) - [Patch Changes](#patch-changes-40) - - [v6.6.1](#v661) + - [v6.6.2](#v662) - [Patch Changes](#patch-changes-41) + - [v6.6.1](#v661) + - [Patch Changes](#patch-changes-42) - [v6.6.0](#v660) - [What's Changed](#whats-changed-15) - - [Minor Changes](#minor-changes-22) - - [Patch Changes](#patch-changes-42) - - [v6.5.0](#v650) - - [What's Changed](#whats-changed-16) - [Minor Changes](#minor-changes-23) - [Patch Changes](#patch-changes-43) - - [v6.4.5](#v645) + - [v6.5.0](#v650) + - [What's Changed](#whats-changed-16) + - [Minor Changes](#minor-changes-24) - [Patch Changes](#patch-changes-44) - - [v6.4.4](#v644) + - [v6.4.5](#v645) - [Patch Changes](#patch-changes-45) - - [v6.4.3](#v643) + - [v6.4.4](#v644) - [Patch Changes](#patch-changes-46) - - [v6.4.2](#v642) + - [v6.4.3](#v643) - [Patch Changes](#patch-changes-47) - - [v6.4.1](#v641) + - [v6.4.2](#v642) - [Patch Changes](#patch-changes-48) + - [v6.4.1](#v641) + - [Patch Changes](#patch-changes-49) - [v6.4.0](#v640) - [What's Changed](#whats-changed-17) - [Remix Data APIs](#remix-data-apis) - - [Patch Changes](#patch-changes-49) + - [Patch Changes](#patch-changes-50) - [v6.3.0](#v630) - - [Minor Changes](#minor-changes-24) + - [Minor Changes](#minor-changes-25) - [v6.2.2](#v622) - - [Patch Changes](#patch-changes-50) - - [v6.2.1](#v621) - [Patch Changes](#patch-changes-51) - - [v6.2.0](#v620) - - [Minor Changes](#minor-changes-25) + - [v6.2.1](#v621) - [Patch Changes](#patch-changes-52) - - [v6.1.1](#v611) - - [Patch Changes](#patch-changes-53) - - [v6.1.0](#v610) + - [v6.2.0](#v620) - [Minor Changes](#minor-changes-26) + - [Patch Changes](#patch-changes-53) + - [v6.1.1](#v611) - [Patch Changes](#patch-changes-54) - - [v6.0.2](#v602) + - [v6.1.0](#v610) + - [Minor Changes](#minor-changes-27) - [Patch Changes](#patch-changes-55) - - [v6.0.1](#v601) + - [v6.0.2](#v602) - [Patch Changes](#patch-changes-56) + - [v6.0.1](#v601) + - [Patch Changes](#patch-changes-57) - [v6.0.0](#v600) +## v7.0.0 + +Date: 2024-11-21 + +### Breaking Changes + +#### Package Restructuring + +- The `react-router-dom`, `@remix-run/react`, `@remix-run/server-runtime`, and `@remix-run/router` have been collapsed into the `react-router` package + - To ease migration, `react-router-dom` is still published in v7 as a re-export of everything from `react-router` +- The `@remix-run/cloudflare-pages` and `@remix-run/cloudflare-workers` have been collapsed into `@react-router/cloudflare` package` +- The `react-router-dom-v5-compat` and `react-router-native` packages are removed starting with v7 + +#### Removed Adapter Re-exports + +Remix v2 used to re-export all common `@remix-run/server-runtime` APIs through the various runtime packages (`node`, `cloudflare`, `deno`) so that you wouldn't need an additional `@remix-run/server-runtime` dependency in your `package.json`. With the collapsing of packages into `react-router`, these common APIs are now no longer re-exported through the runtime adapters. You should import all common APIs from `react-router`, and only import runtime-specific APIs from the runtime packages: + +```jsx +// Runtime-specific APIs +import { createFileSessionStorage } from "@react-router/node"; +// Runtime-agnostic APIs +import { redirect, useLoaderData } from "react-router"; +``` + +#### Removed APIs + +The following APIs have been removed in React Router v7: + +- `json` +- `defer` +- `unstable_composeUploadHandlers` +- `unstable_createMemoryUploadHandler` +- `unstable_parseMultipartFormData` + +#### Minimum Versions + +React Router v7 requires the following minimum versions: + +- `node@20` + - React Router no longer provides an `installGlobals` method to [polyfill](https://reactrouter.com/dev/guides/deploying/custom-node#polyfilling-fetch) the `fetch` API +- `react@18`, `react-dom@18` + +#### Adopted Future Flag Behaviors + +Remix and React Router follow an [API Development Strategy](https://reactrouter.com/en/main/guides/api-development-strategy) leveraging "Future Flags" to avoid introducing a slew of breaking changes in a major release. Instead, breaking changes are introduce din minor releases behind a flag, allowing users to opt-in at their convenience. In the next major release, all future flag behaviors become the default behavior. + +The following previously flagged behaviors are now the default in React Router v7: + +- [React Router v6 flags](https://reactrouter.com/en/v6/upgrading/future) + - `future.v7_relativeSplatPath` + - `future.v7_startTransition` + - `future.v7_fetcherPersist` + - `future.v7_normalizeFormMethod` + - `future.v7_partialHydration` + - `future.v7_skipActionStatusRevalidation` +- [Remix v2 flags](https://remix.run/docs/en/v2/start/future-flags) + - `future.v3_fetcherPersist` + - `future.v3_relativeSplatPath` + - `future.v3_throwAbortReason` + - `future.v3_singleFetch` + - `future.v3_lazyRouteDiscovery` + - `future.v3_optimizeDeps` + +#### Vite Compiler + +The [Remix Vite plugin](https://remix.run/docs/en/2.12.1/start/future-flags#vite-plugin) is the proper way to build full-stack SSR apps using React Router v7. The former `esbuild`-based compiler is no longer available. + +**Renamed `vitePlugin` and `cloudflareDevProxyVitePlugin`** + +For Remix consumers migrating to React Router, the `vitePlugin` and `cloudflareDevProxyVitePlugin` exports have been renamed and moved ([#11904](https://github.com/remix-run/react-router/pull/11904)) + +```diff +-import { +- vitePlugin as remix, +- cloudflareDevProxyVitePlugin, +-} from "@remix/dev"; + ++import { reactRouter } from "@react-router/dev/vite"; ++import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare"; +``` + +**Removed Vite Plugin `manifest` option** + +For Remix consumers migrating to React Router, the Vite plugin's `manifest` option has been removed. The `manifest` option been superseded by the more powerful `buildEnd` hook since it's passed the `buildManifest` argument. You can still write the build manifest to disk if needed, but you'll most likely find it more convenient to write any logic depending on the build manifest within the `buildEnd` hook itself. ([#11573](https://github.com/remix-run/react-router/pull/11573)) + +If you were using the `manifest` option, you can replace it with a `buildEnd` hook that writes the manifest to disk like this: + +```js +import { reactRouter } from "@react-router/dev/vite"; +import { writeFile } from "node:fs/promises"; + +export default { + plugins: [ + reactRouter({ + async buildEnd({ buildManifest }) { + await writeFile( + "build/manifest.json", + JSON.stringify(buildManifest, null, 2), + "utf-8" + ); + }, + }), + ], +}; +``` + +#### Exposed Router Promises + +Because React 19 will have first-class support for handling promises in the render pass (via `React.use` and `useAction`), we are now comfortable exposing the promises for the APIs that previously returned `undefined`: + +- `useNavigate()` +- `useSubmit()` +- `useFetcher().load` +- `useFetcher().submit` +- `useRevalidator().revalidate()` + +### Other Notable Changes + +#### `routes.ts` + +When using the React Router Vite plugin, routes are defined in `app/routes.ts`. Route config is exported via the `routes` export, conforming to the `RouteConfig` type. Route helper functions `route`, `index`, and `layout` are provided to make declarative type-safe route definitions easier. + +```ts +// app/routes.ts +import { + type RouteConfig, + route, + index, + layout, +} from "@react-router/dev/routes"; + +export const routes: RouteConfig = [ + index("./home.tsx"), + route("about", "./about.tsx"), + + layout("./auth/layout.tsx", [ + route("login", "./auth/login.tsx"), + route("register", "./auth/register.tsx"), + ]), + + route("concerts", [ + index("./concerts/home.tsx"), + route(":city", "./concerts/city.tsx"), + route("trending", "./concerts/trending.tsx"), + ]), +]; +``` + +For Remix consumers migrating to React Router, you can still configure file system routing within `routes.ts` using the `@react-router/fs-routes` package. A minimal route config that reproduces the default Remix setup looks like this: + +```ts +// app/routes.ts +import { type RouteConfig } from "@react-router/dev/routes"; +import { flatRoutes } from "@react-router/fs-routes"; + +export const routes: RouteConfig = flatRoutes(); +``` + +If you want to migrate from file system routing to config-based routes, you can mix and match approaches by spreading the results of the async `flatRoutes` function into the array of config-based routes. + +```ts +// app/routes.ts +import { type RouteConfig, route } from "@react-router/dev/routes"; +import { flatRoutes } from "@react-router/fs-routes"; + +export const routes: RouteConfig = [ + // Example config-based route: + route("/hello", "./routes/hello.tsx"), + + // File system routes scoped to a different directory: + ...(await flatRoutes({ + rootDirectory: "fs-routes", + })), +]; +``` + +If you were using Remix's `routes` option to use alternative file system routing conventions, you can adapt these to the new `RouteConfig` format using `@react-router/remix-config-routes-adapter`. + +For example, if you were using [Remix v1 route conventions](https://remix.run/docs/en/1.19.3/file-conventions/routes-files) in Remix v2, you can combine `@react-router/remix-config-routes-adapter` with `@remix-run/v1-route-convention` to adapt this to React Router: + +```ts +// app/routes.ts +import { type RouteConfig } from "@react-router/dev/routes"; +import { remixConfigRoutes } from "@react-router/remix-config-routes-adapter"; +import { createRoutesFromFolders } from "@remix-run/v1-route-convention"; + +export const routes: RouteConfig = remixConfigRoutes(async (defineRoutes) => { + return createRoutesFromFolders(defineRoutes, { + ignoredFilePatterns: ["**/.*", "**/*.css"], + }); +}); +``` + +Also note that, if you were using Remix's `routes` option to define config-based routes, you can also adapt these to the new `RouteConfig` format using `@react-router/remix-config-routes-adapter` with minimal code changes. While this makes for a fast migration path, we recommend migrating any config-based routes from Remix to the new `RouteConfig` format since it's a fairly straightforward migration. + +```diff +// app/routes.ts +-import { type RouteConfig } from "@react-router/dev/routes"; ++import { type RouteConfig, route } from "@react-router/dev/routes"; +-import { remixConfigRoutes } from "@react-router/remix-config-routes-adapter"; + +-export const routes: RouteConfig = remixConfigRoutes(async (defineRoutes) => { +- defineRoutes((route) => { +- route("/parent", "./routes/parent.tsx", () => [ +- route("/child", "./routes/child.tsx"), +- ]); +- }); +-}); ++export const routes: RouteConfig = [ ++ route("/parent", "./routes/parent.tsx", [ ++ route("/child", "./routes/child.tsx"), ++ ]), ++]; +``` + +#### Typesafety improvements + +React Router now generates types for each of your route modules and passes typed props to route module component exports ([#11961](https://github.com/remix-run/react-router/pull/11961), [#12019](https://github.com/remix-run/react-router/pull/12019)). You can access those types by importing them from `./+types.`. + +For example: + +```ts +// app/routes/product.tsx +import type * as Route from "./+types.product"; + +export function loader({ params }: Route.LoaderArgs) {} + +export default function Component({ loaderData }: Route.ComponentProps) {} +``` + +This initial implementation targets type inference for: + +- `Params` : Path parameters from your routing config in `routes.ts` including file-based routing +- `LoaderData` : Loader data from `loader` and/or `clientLoader` within your route module +- `ActionData` : Action data from `action` and/or `clientAction` within your route module + +These types are then used to create types for route export args and props: + +- `LoaderArgs` +- `ClientLoaderArgs` +- `ActionArgs` +- `ClientActionArgs` +- `HydrateFallbackProps` +- `ComponentProps` (for the `default` export) +- `ErrorBoundaryProps` + +In the future, we plan to add types for the rest of the route module exports: `meta`, `links`, `headers`, `shouldRevalidate`, etc. + +We also plan to generate types for typesafe `Link`s: + +```tsx + +// ^^^^^^^^^^^^^ ^^^^^^^^^ +// typesafe `to` and `params` based on the available routes in your app +``` + +##### Setup + +React Router will generate types into a `.react-router/` directory at the root of your app. This directory is fully managed by React Router and is derived based on your route config (`routes.ts`). + +๐Ÿ‘‰ **Add `.react-router/` to `.gitignore`** + +```txt +.react-router +``` + +You should also ensure that generated types for routes are always present before running typechecking, especially for running typechecking in CI. + +๐Ÿ‘‰ **Add `react-router typegen` to your `typecheck` command in `package.json`** + +```json +{ + "scripts": { + "typecheck": "react-router typegen && tsc" + } +} +``` + +To get TypeScript to use those generated types, you'll need to add them to `include` in `tsconfig.json`. And to be able to import them as if they files next to your route modules, you'll also need to configure `rootDirs`. + +๐Ÿ‘‰ **Configure `tsconfig.json` for generated types** + +```json +{ + "include": [".react-router/types/**/*"], + "compilerOptions": { + "rootDirs": [".", "./.react-router/types"] + } +} +``` + +##### `typegen` command + +You can manually generate types with the new `typegen` command: + +```sh +react-router typegen +``` + +However, manual type generation is tedious and types can get out of sync quickly if you ever forget to run `typegen`. Instead, we recommend that you setup our new TypeScript plugin which will automatically generate fresh types whenever routes change. That way, you'll always have up-to-date types. + +##### TypeScript plugin + +To get automatic type generation, you can use our new TypeScript plugin. + +๐Ÿ‘‰ **Add the TypeScript plugin to `tsconfig.json`** + +```json +{ + "compilerOptions": { + "plugins": [{ "name": "@react-router/dev" }] + } +} +``` + +We plan to add some other goodies to our TypeScript plugin soon, including: + +- Automatic `jsdoc` for route exports that include links to official docs +- Autocomplete for route exports +- Warnings for non-HMR compliant exports + +###### VSCode + +TypeScript looks for plugins registered in `tsconfig.json` in the local `node_modules/`, +but VSCode ships with its own copy of TypeScript that is installed outside of your project. +For TypeScript plugins to work, you'll need to tell VSCode to use the local workspace version of TypeScript. + +๐Ÿ‘‰ **Ensure that VSCode is using the workspace version of TypeScript** + +This should already be set up for you by a `.vscode/settings.json`: + +```json +{ + "typescript.tsdk": "node_modules/typescript/lib" +} +``` + +Alternatively, you can open up any TypeScript file and use CMD+SHIFT+P to find `Select TypeScript Version` and then select `Use Workspace Version`. You may need to quit VSCode and reopen it for this setting to take effect. + +###### Troubleshooting + +In VSCode, open up any TypeScript file in your project and then use CMD+SHIFT+P to select `Open TS Server log`. There should be a log for `[react-router] setup` that indicates that the plugin was resolved correctly. Then look for any errors in the log. + +#### Prerendering + +React Router v7 includes a new `prerender` config in the vite plugin to support SSG use-cases. This will pre-render your `.html` and `.data` files at build time and so you can serve them statically at runtime from a running server or a CDN ([#11539](https://github.com/remix-run/react-router/pull/11539)) + +```ts +export default defineConfig({ + plugins: [ + reactRouter({ + async prerender({ getStaticPaths }) { + let slugs = await fakeGetSlugsFromCms(); + return [ + ...getStaticPaths(), + ...slugs.map((slug) => `/product/${slug}`), + ]; + }, + }), + tsconfigPaths(), + ], +}); + +async function fakeGetSlugsFromCms() { + await new Promise((r) => setTimeout(r, 1000)); + return ["shirt", "hat"]; +} +``` + +### Major Changes (`react-router`) + +- Remove the original `defer` implementation in favor of using raw promises via single fetch and `turbo-stream` ([#11744](https://github.com/remix-run/react-router/pull/11744)) + - This removes these exports from React Router: + - `defer` + - `AbortedDeferredError` + - `type TypedDeferredData` + - `UNSAFE_DeferredData` + - `UNSAFE_DEFERRED_SYMBOL` +- Collapse packages into `react-router`([#11505](https://github.com/remix-run/react-router/pull/11505)) + - `@remix-run/router` + - `react-router-dom` + - `@remix-run/server-runtime` + - `@remix-run/testing` + - As a note, the `react-router-dom` package is maintained to ease adoption but it simply re-exports all APIs from `react-router` +- Drop support for Node 16, React Router SSR now requires Node 18 or higher ([#11391](https://github.com/remix-run/react-router/pull/11391), [#11690](https://github.com/remix-run/react-router/pull/11690)) +- Remove `future.v7_startTransition` flag ([#11696](https://github.com/remix-run/react-router/pull/11696)) +- Expose the underlying router promises from the following APIs for composition in React 19 APIs: ([#11521](https://github.com/remix-run/react-router/pull/11521)) +- Remove `future.v7_normalizeFormMethod` future flag ([#11697](https://github.com/remix-run/react-router/pull/11697)) +- Imports/Exports cleanup ([#11840](https://github.com/remix-run/react-router/pull/11840)) + - Removed the following exports that were previously public API from `@remix-run/router` + - types + - `AgnosticDataIndexRouteObject` + - `AgnosticDataNonIndexRouteObject` + - `AgnosticDataRouteMatch` + - `AgnosticDataRouteObject` + - `AgnosticIndexRouteObject` + - `AgnosticNonIndexRouteObject` + - `AgnosticRouteMatch` + - `AgnosticRouteObject` + - `TrackedPromise` + - `unstable_AgnosticPatchRoutesOnMissFunction` + - `Action` -> exported as `NavigationType` via `react-router` + - `Router` exported as `RemixRouter` to differentiate from RR's `` + - API + - `getToPathname` (`@private`) + - `joinPaths` (`@private`) + - `normalizePathname` (`@private`) + - `resolveTo` (`@private`) + - `stripBasename` (`@private`) + - `createBrowserHistory` -> in favor of `createBrowserRouter` + - `createHashHistory` -> in favor of `createHashRouter` + - `createMemoryHistory` -> in favor of `createMemoryRouter` + - `createRouter` + - `createStaticHandler` -> in favor of wrapper `createStaticHandler` in RR Dom + - `getStaticContextFromError` + - Removed the following exports that were previously public API from `react-router` + - `Hash` + - `Pathname` + - `Search` +- Remove `future.v7_prependBasename` from the internalized `@remix-run/router` package ([#11726](https://github.com/remix-run/react-router/pull/11726)) +- Remove `future.v7_throwAbortReason` from internalized `@remix-run/router` package ([#11728](https://github.com/remix-run/react-router/pull/11728)) +- Add `exports` field to all packages ([#11675](https://github.com/remix-run/react-router/pull/11675)) +- Renamed `RemixContext` to `FrameworkContext` ([#11705](https://github.com/remix-run/react-router/pull/11705)) +- Update the minimum React version to 18 ([#11689](https://github.com/remix-run/react-router/pull/11689)) +- `PrefetchPageDescriptor` replaced by `PageLinkDescriptor` ([#11960](https://github.com/remix-run/react-router/pull/11960)) +- Remove the `future.v7_partialHydration` flag ([#11725](https://github.com/remix-run/react-router/pull/11725)) + - This also removes the `` prop + - To migrate, move the `fallbackElement` to a `hydrateFallbackElement`/`HydrateFallback` on your root route + - Also worth nothing there is a related breaking changer with this future flag: + - Without `future.v7_partialHydration` (when using `fallbackElement`), `state.navigation` was populated during the initial load + - With `future.v7_partialHydration`, `state.navigation` remains in an `"idle"` state during the initial load +- Remove `future.v7_relativeSplatPath` future flag ([#11695](https://github.com/remix-run/react-router/pull/11695)) +- Remove remaining future flags ([#11820](https://github.com/remix-run/react-router/pull/11820)) + - React Router `v7_skipActionErrorRevalidation` + - Remix `v3_fetcherPersist`, `v3_relativeSplatPath`, `v3_throwAbortReason` +- Rename `createRemixStub` to `createRoutesStub` ([#11692](https://github.com/remix-run/react-router/pull/11692)) +- Remove `@remix-run/router` deprecated `detectErrorBoundary` option in favor of `mapRouteProperties` ([#11751](https://github.com/remix-run/react-router/pull/11751)) +- Add `react-router/dom` subpath export to properly enable `react-dom` as an optional `peerDependency` ([#11851](https://github.com/remix-run/react-router/pull/11851)) + - This ensures that we don't blindly `import ReactDOM from "react-dom"` in `` in order to access `ReactDOM.flushSync()`, since that would break `createMemoryRouter` use cases in non-DOM environments + - DOM environments should import from `react-router/dom` to get the proper component that makes `ReactDOM.flushSync()` available: + - If you are using the Vite plugin, use this in your `entry.client.tsx`: + - `import { HydratedRouter } from 'react-router/dom'` + - If you are not using the Vite plugin and are manually calling `createBrowserRouter`/`createHashRouter`: + - `import { RouterProvider } from "react-router/dom"` +- Remove `future.v7_fetcherPersist` flag ([#11731](https://github.com/remix-run/react-router/pull/11731)) +- Allow returning `undefined` from loaders and actions ([#11680](https://github.com/remix-run/react-router/pull/11680), [#12057]([https://github.com/remix-run/react-router/pull/1205)) +- Use `createRemixRouter`/`RouterProvider` in `entry.client` instead of `RemixBrowser` ([#11469](https://github.com/remix-run/react-router/pull/11469)) +- Remove the deprecated `json` utility ([#12146](https://github.com/remix-run/react-router/pull/12146)) + - You can use [`Response.json`](https://developer.mozilla.org/en-US/docs/Web/API/Response/json_static) if you still need to construct JSON responses in your app + +### Major Changes (`@react-router/*`) + +- Remove `future.v3_singleFetch` flag ([#11522](https://github.com/remix-run/react-router/pull/11522)) +- Drop support for Node 16 and 18, update minimum Node version to 20 ([#11690](https://github.com/remix-run/react-router/pull/11690), [#12171](https://github.com/remix-run/react-router/pull/12171)) + - Remove `installGlobals()` as this should no longer be necessary +- Add `exports` field to all packages ([#11675](https://github.com/remix-run/react-router/pull/11675)) +- No longer re-export APIs from `react-router` through different runtime/adapter packages ([#11702](https://github.com/remix-run/react-router/pull/11702)) +- For Remix consumers migrating to React Router, the `crypto` global from the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) is now required when using cookie and session APIs + - This means that the following APIs are provided from `react-router` rather than platform-specific packages: ([#11837](https://github.com/remix-run/react-router/pull/11837)) + - `createCookie` + - `createCookieSessionStorage` + - `createMemorySessionStorage` + - `createSessionStorage` + - For consumers running older versions of Node, the `installGlobals` function from `@remix-run/node` has been updated to define `globalThis.crypto`, using [Node's `require('node:crypto').webcrypto` implementation](https://nodejs.org/api/webcrypto.html) + - Since platform-specific packages no longer need to implement this API, the following low-level APIs have been removed: + - `createCookieFactory` + - `createSessionStorageFactory` + - `createCookieSessionStorageFactory` + - `createMemorySessionStorageFactory` +- Consolidate types previously duplicated across `@remix-run/router`, `@remix-run/server-runtime`, and `@remix-run/react` now that they all live in `react-router` ([#12177](https://github.com/remix-run/react-router/pull/12177)) + - Examples: `LoaderFunction`, `LoaderFunctionArgs`, `ActionFunction`, `ActionFunctionArgs`, `DataFunctionArgs`, `RouteManifest`, `LinksFunction`, `Route`, `EntryRoute` + - The `RouteManifest` type used by the "remix" code is now slightly stricter because it is using the former `@remix-run/router` `RouteManifest` + - `Record -> Record` + - Removed `AppData` type in favor of inlining `unknown` in the few locations it was used + - Removed `ServerRuntimeMeta*` types in favor of the `Meta*` types they were duplicated from +- Migrate Remix v2 type generics to React Router ([#12180](https://github.com/remix-run/react-router/pull/12180)) + - These generics are provided for Remix v2 migration purposes + - These generics and the APIs they exist on should be considered informally deprecated in favor of the new `Route.*` types + - Anyone migrating from React Router v6 should probably not leverage these new generics and should migrate straight to the `Route.*` types + - For React Router v6 users, these generics are new and should not impact your app, with one exception + - `useFetcher` previously had an optional generic (used primarily by Remix v2) that expected the data type + - This has been updated in v7 to expect the type of the function that generates the data (i.e., `typeof loader`/`typeof action`) + - Therefore, you should update your usages: + - โŒ `useFetcher()` + - โœ… `useFetcher()` +- Update `cookie` dependency to `^1.0.1` - please see the [release notes](https://github.com/jshttp/cookie/releases) for any breaking changes ([#12172](https://github.com/remix-run/react-router/pull/12172)) +- `@react-router/cloudflare` - For Remix consumers migrating to React Router, all exports from `@remix-run/cloudflare-pages` are now provided for React Router consumers in the `@react-router/cloudflare` package. There is no longer a separate package for Cloudflare Pages. ([#11801](https://github.com/remix-run/react-router/pull/11801)) +- `@react-router/cloudflare` - The `@remix-run/cloudflare-workers` package has been deprecated. Remix consumers migrating to React Router should use the `@react-router/cloudflare` package directly. For guidance on how to use `@react-router/cloudflare` within a Cloudflare Workers context, refer to the Cloudflare Workers template. ([#11801](https://github.com/remix-run/react-router/pull/11801)) +- `@react-router/dev` - For Remix consumers migrating to React Router, the `vitePlugin` and `cloudflareDevProxyVitePlugin` exports have been renamed and moved. ([#11904](https://github.com/remix-run/react-router/pull/11904)) +- `@react-router/dev` - For Remix consumers migrating to React Router who used the Vite plugin's `buildEnd` hook, the resolved `reactRouterConfig` object no longer contains a `publicPath` property since this belongs to Vite, not React Router ([#11575](https://github.com/remix-run/react-router/pull/11575)) +- `@react-router/dev` - For Remix consumers migrating to React Router, the Vite plugin's `manifest` option has been removed ([#11573](https://github.com/remix-run/react-router/pull/11573)) +- `@react-router/dev` - Update default `isbot` version to v5 and drop support for `isbot@3` ([#11770](https://github.com/remix-run/react-router/pull/11770)) + - If you have `isbot@4` or `isbot@5` in your `package.json`: + - You do not need to make any changes + - If you have `isbot@3` in your `package.json` and you have your own `entry.server.tsx` file in your repo + - You do not need to make any changes + - You can upgrade to `isbot@5` independent of the React Router v7 upgrade + - If you have `isbot@3` in your `package.json` and you do not have your own `entry.server.tsx` file in your repo + - You are using the internal default entry provided by React Router v7 and you will need to upgrade to `isbot@5` in your `package.json` +- `@react-router/dev` - For Remix consumers migrating to React Router, Vite manifests (i.e. `.vite/manifest.json`) are now written within each build subdirectory, e.g. `build/client/.vite/manifest.json` and `build/server/.vite/manifest.json` instead of `build/.vite/client-manifest.json` and `build/.vite/server-manifest.json`. This means that the build output is now much closer to what you'd expect from a typical Vite project. ([#11573](https://github.com/remix-run/react-router/pull/11573)) + - Originally the Remix Vite plugin moved all Vite manifests to a root-level `build/.vite` directory to avoid accidentally serving them in production, particularly from the client build. This was later improved with additional logic that deleted these Vite manifest files at the end of the build process unless Vite's `build.manifest` had been enabled within the app's Vite config. This greatly reduced the risk of accidentally serving the Vite manifests in production since they're only present when explicitly asked for. As a result, we can now assume that consumers will know that they need to manage these additional files themselves, and React Router can safely generate a more standard Vite build output. + +### Minor Changes + +- `react-router` - Params, loader data, and action data as props for route component exports ([#11961](https://github.com/remix-run/react-router/pull/11961)) +- `react-router` - Add route module type generation ([#12019](https://github.com/remix-run/react-router/pull/12019)) +- `react-router` - Remove duplicate `RouterProvider` implementations ([#11679](https://github.com/remix-run/react-router/pull/11679)) +- `react-router` - Stabilize `unstable_dataStrategy` ([#11969](https://github.com/remix-run/react-router/pull/11969)) +- `react-router` - Stabilize `unstable_patchRoutesOnNavigation` ([#11970](https://github.com/remix-run/react-router/pull/11970)) +- `react-router` - Add prefetching support to `Link`/`NavLink` when using Remix SSR ([#11402](https://github.com/remix-run/react-router/pull/11402)) +- `react-router` - Enhance `ScrollRestoration` so it can restore properly on an SSR'd document load ([#11401](https://github.com/remix-run/react-router/pull/11401)) +- `@react-router/dev` - Add support for the `prerender` config in the React Router vite plugin, to support existing SSG use-cases ([#11539](https://github.com/remix-run/react-router/pull/11539)) +- `@react-router/dev` - Remove internal `entry.server.spa.tsx` implementation which was not compatible with the Single Fetch async hydration approach ([#11681](https://github.com/remix-run/react-router/pull/11681)) +- `@react-router/serve`: Update `express.static` configurations to support new `prerender` API ([#11547](https://github.com/remix-run/react-router/pull/11547)) + - Assets in the `build/client/assets` folder are served as before, with a 1-year immutable `Cache-Control` header + - Static files outside of assets, such as pre-rendered `.html` and `.data` files are not served with a specific `Cache-Control` header + - `.data` files are served with `Content-Type: text/x-turbo` + - For some reason, when adding this via `express.static`, it seems to also add a `Cache-Control: public, max-age=0` to `.data` files + +### Patch Changes + +- Replace `substr` with `substring` ([#12080](https://github.com/remix-run/react-router/pull/12080)) +- `react-router` - Fix redirects returned from loaders/actions using `data()` ([#12021](https://github.com/remix-run/react-router/pull/12021)) +- `@react-router/dev` - Enable prerendering for resource routes ([#12200](https://github.com/remix-run/react-router/pull/12200)) +- `@react-router/dev` - resolve config directory relative to flat output file structure ([#12187](https://github.com/remix-run/react-router/pull/12187)) + +### Changes by Package + +- [`react-router`](https://github.com/remix-run/react-router/blob/react-router%407.0.0/packages/react-router/CHANGELOG.md#700) +- [`@react-router/architect`](https://github.com/remix-run/react-router/blob/react-router%407.0.0/packages/react-router-architect/CHANGELOG.md#700) +- [`@react-router/cloudflare`](https://github.com/remix-run/react-router/blob/react-router%407.0.0/packages/react-router-cloudflare/CHANGELOG.md#700) +- [`@react-router/dev`](https://github.com/remix-run/react-router/blob/react-router%407.0.0/packages/react-router-dev/CHANGELOG.md#700) +- [`@react-router/express`](https://github.com/remix-run/react-router/blob/react-router%407.0.0/packages/react-router-express/CHANGELOG.md#700) +- [`@react-router/fs-routes`](https://github.com/remix-run/react-router/blob/react-router%407.0.0/packages/react-router-fs-routes/CHANGELOG.md#700) +- [`@react-router/node`](https://github.com/remix-run/react-router/blob/react-router%407.0.0/packages/react-router-node/CHANGELOG.md#700) +- [`@react-router/remix-config-routes-adapter`](https://github.com/remix-run/react-router/blob/react-router%407.0.0/packages/react-router-remix-config-routes-adapter/CHANGELOG.md#700) +- [`@react-router/serve`](https://github.com/remix-run/react-router/blob/react-router%407.0.0/packages/react-router-serve/CHANGELOG.md#700) + +# **Full Changelog**: [`v6.28.0...v7.0.0`](https://github.com/remix-run/react-router/compare/react-router@6.28.0...react-router@7.0.0) + +> > > > > > > release-next + ## v6.28.0 Date: 2024-11-06 @@ -236,6 +815,12 @@ Date: 2024-11-06 **Full Changelog**: [`v6.27.0...v6.28.0`](https://github.com/remix-run/react-router/compare/react-router@6.27.0...react-router@6.28.0) +# <<<<<<< HEAD + +> > > > > > > dev + +> > > > > > > release-next + ## v6.27.0 Date: 2024-10-11 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6e637d603..0bfb0d0d60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1 +1 @@ -Please see [our guide to contributing](docs/guides/contributing.md). +Please see [our guide to contributing](docs/community/contributing.md). diff --git a/README.md b/README.md index fa131202e2..ebc5397c2b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [build-badge]: https://img.shields.io/github/actions/workflow/status/remix-run/react-router/test.yml?branch=dev&style=square [build]: https://github.com/remix-run/react-router/actions/workflows/test.yml -React Router is a lightweight, fully-featured routing library for the [React](https://reactjs.org) JavaScript library. React Router runs everywhere that React runs; on the web, on the server (using node.js), and on React Native. +React Router is a lightweight, fully-featured routing library for the [React](https://reactjs.org) JavaScript library. React Router runs anywhere React runs; on the web, on the server with node.js, or on any other Javascript platform that supports the [Web Fetch API][fetch-api]. If you're new to React Router, we recommend you start with [the tutorial](https://reactrouter.com/en/main/start/tutorial). @@ -21,9 +21,12 @@ There are many different ways to contribute to React Router's development. If yo This repository is a monorepo containing the following packages: +- [`@react-router/dev`](/packages/react-router-dev) +- [`@react-router/express`](/packages/react-router-express) +- [`@react-router/node`](/packages/react-router-node) +- [`@react-router/serve`](/packages/react-router-serve) - [`react-router`](/packages/react-router) - [`react-router-dom`](/packages/react-router-dom) -- [`react-router-native`](/packages/react-router-native) ## Changes @@ -36,3 +39,5 @@ You may provide financial support for this project by donating [via Open Collect ## About React Router is developed and maintained by [Remix Software](https://remix.run) and many [amazing contributors](https://github.com/remix-run/react-router/graphs/contributors). + +[fetch-api]: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API diff --git a/build.utils.ts b/build.utils.ts new file mode 100644 index 0000000000..83cb783cc2 --- /dev/null +++ b/build.utils.ts @@ -0,0 +1,12 @@ +export function createBanner(packageName: string, version: string) { + return `/** + * ${packageName} v${version} + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */`; +} diff --git a/contributors.yml b/contributors.yml index c7bd8beddc..a75046113d 100644 --- a/contributors.yml +++ b/contributors.yml @@ -15,12 +15,15 @@ - alany411 - alberto - alexandernanberg +- alexanderson1993 - alexlbr - AmRo045 - amsal +- andreasottosson-polestar - andreiduca - antonmontrezor - appden +- apple-yagi - arjunyel - arka1002 - Armanio @@ -190,6 +193,7 @@ - mikib0 - minami-minami - minthulim +- mjackson - mlewando - modex98 - morleytatro @@ -207,6 +211,7 @@ - OlegDev1 - omahs - omar-moquete +- OnurGvnc - p13i - parched - parveen232 @@ -218,6 +223,7 @@ - promet99 - pyitphyoaung - refusado +- rifaidev - rimian - robbtraister - RobHannay @@ -226,6 +232,7 @@ - rubeonline - ryanflorence - ryanhiebert +- saengmotmi - sanketshah19 - saul-atomrigs - sbolel @@ -241,16 +248,20 @@ - SkayuX - skratchdot - smithki +- soartec-lab +- sorrycc - souzasmatheus - srmagura - SsongQ-92 - stasundr - stmtk1 +- sukvvon - swalker326 - tanayv - thecode00 - theostavrides - thepedroferrari +- thethmuu - thisiskartik - thomasverleye - ThornWu @@ -266,6 +277,7 @@ - triangularcube - trungpv1601 - ttys026 +- Tumas2 - turansky - tyankatsu0105 - underager diff --git a/decisions/0001-use-npm-to-manage-npm-dependencies-for-deno-projects.md b/decisions/0001-use-npm-to-manage-npm-dependencies-for-deno-projects.md new file mode 100644 index 0000000000..35a6ec0a36 --- /dev/null +++ b/decisions/0001-use-npm-to-manage-npm-dependencies-for-deno-projects.md @@ -0,0 +1,113 @@ +# Use `npm` to manage NPM dependencies for Deno projects + +Date: 2022-05-10 + +Status: accepted + +## Context + +Deno has three ways to manage dependencies: + +1. Inlined URL imports: `import {...} from "https://deno.land/x/blah"` +2. [deps.ts](https://deno.land/manual/examples/manage_dependencies) +3. [Import maps](https://deno.land/manual/linking_to_external_code/import_maps) + +Additionally, NPM packages can be accessed as Deno modules via [Deno-friendly CDNs](https://deno.land/manual/node/cdns#deno-friendly-cdns) like https://esm.sh. + +Remix has some requirements around dependencies: + +- Remix treeshakes dependencies that are free of side-effects. +- Remix sets the environment (dev/prod/test) across all code, including dependencies, at runtime via the `NODE_ENV` environment variable. +- Remix depends on some NPM packages that should be specified as peer dependencies (notably, `react` and `react-dom`). + +### Treeshaking + +To optimize bundle size, Remix [treeshakes](https://esbuild.github.io/api/#tree-shaking) your app's code and dependencies. +This also helps to separate browser code and server code. + +Under the hood, the Remix compiler uses [esbuild](https://esbuild.github.io). +Like other bundlers, `esbuild` uses [`sideEffects` in `package.json` to determine when it is safe to eliminate unused imports](https://esbuild.github.io/api/#conditionally-injecting-a-file). + +Unfortunately, URL imports do not have a standard mechanism for marking packages as side-effect free. + +### Setting dev/prod/test environment + +Deno-friendly CDNs set the environment via a query parameter (e.g. `?dev`), not via an environment variable. +That means changing environment requires changing the URL import in the source code. +While you could use multiple import maps (`dev.json`, `prod.json`, etc...) to workaround this, import maps have other limitations: + +- standard tooling for managing import maps is not available +- import maps are not composeable, so any dependencies that use import maps must be manually accounted for + +### Specifying peer dependencies + +Even if import maps were perfected, CDNs compile each dependency in isolation. +That means that specifying peer dependencies becomes tedious and error-prone as the user needs to: + +- determine which dependencies themselves depend on `react` (or other similar peer dependency), even if indirectly. +- manually figure out which `react` version works across _all_ of these dependencies +- set that version for `react` as a query parameter in _all_ of the URLs for the identified dependencies + +If any dependencies change (added, removed, version change), +the user must repeat all of these steps again. + +## Decision + +### Use `npm` to manage NPM dependencies for Deno + +Do not use Deno-friendly CDNs for NPM dependencies in Remix projects using Deno. + +Use `npm` and `node_modules/` to manage NPM dependencies like `react` for Remix projects, even when using Deno with Remix. + +Deno module dependencies (e.g. from `https://deno.land`) can still be managed via URL imports. + +### Allow URL imports + +Remix will preserve any URL imports in the built bundles as external dependencies, +letting your browser runtime and server runtime handle them accordingly. +That means that you may: + +- use URL imports for the browser +- use URL imports for the server, if your server runtime supports it + +For example, Node will throw errors for URL imports, while Deno will resolve URL imports as normal. + +### Do not support import maps + +Remix will not yet support import maps. + +## Consequences + +- URL imports will not be treeshaken. +- Users can specify environment via the `NODE_ENV` environment variable at runtime. +- Users won't have to do error-prone, manual dependency resolution. + +### VS Code type hints + +Users may configure an import map for the [Deno extension for VS Code](denoland.vscode-deno) to enable type hints for NPM-managed dependencies within their Deno editor: + +`.vscode/resolve_npm_imports_in_deno.json` + +```json +{ + "// This import map is used solely for the denoland.vscode-deno extension.": "", + "// Remix does not support import maps.": "", + "// Dependency management is done through `npm` and `node_modules/` instead.": "", + "// Deno-only dependencies may be imported via URL imports (without using import maps).": "", + + "imports": { + "react": "https://esm.sh/react@18.0.0", + "react-dom": "https://esm.sh/react-dom@18.0.0", + "react-dom/server": "https://esm.sh/react-dom@18.0.0/server" + } +} +``` + +`.vscode/settings.json` + +```json +{ + "deno.enable": true, + "deno.importMap": "./.vscode/resolve_npm_imports_in_deno.json" +} +``` diff --git a/decisions/0002-do-not-clone-request.md b/decisions/0002-do-not-clone-request.md new file mode 100644 index 0000000000..30f599f7f0 --- /dev/null +++ b/decisions/0002-do-not-clone-request.md @@ -0,0 +1,19 @@ +# Do not clone request + +Date: 2022-05-13 + +Status: accepted + +## Context + +To allow multiple loaders / actions to read the body of a request, we have been cloning the request before forwarding it to user-code. This is not the best thing to do as some runtimes will begin buffering the body to allow for multiple consumers. It also goes against "the platform" that states a request body should only be consumed once. + +## Decision + +Do not clone requests before they are passed to user-code (actions, handleDocumentRequest, handleDataRequest), and remove body from request passed to loaders. Loaders should be thought of as a "GET" / "HEAD" request handler. These request methods are not allowed to have a body, therefore you should not be reading it in your Remix loader function. + +## Consequences + +Loaders always receive a null body for the request. + +If you are reading the request body in both an action and handleDocumentRequest or handleDataRequest this will now fail as the body will have already been read. If you wish to continue reading the request body in multiple places for a single request against recommendations, consider using `.clone()` before reading it; just know this comes with tradeoffs. diff --git a/decisions/0003-infer-types-for-useloaderdata-and-useactiondata-from-loader-and-action-via-generics.md b/decisions/0003-infer-types-for-useloaderdata-and-useactiondata-from-loader-and-action-via-generics.md new file mode 100644 index 0000000000..c86a9c818f --- /dev/null +++ b/decisions/0003-infer-types-for-useloaderdata-and-useactiondata-from-loader-and-action-via-generics.md @@ -0,0 +1,230 @@ +# Infer types for `useLoaderData` and `useActionData` from `loader` and `action` via generics + +Date: 2022-07-11 + +Status: Superseded by [#0012](./0012-type-inference.md) + +## Context + +Goal: End-to-end type safety for `useLoaderData` and `useActionData` with great Developer Experience (DX) + +Related discussions: + +- [remix-run/remix#1254](https://github.com/remix-run/remix/pull/1254) +- [remix-run/remix#3276](https://github.com/remix-run/remix/pull/3276) + +--- + +In Remix v1.6.4, types for both `useLoaderData` and `useActionData` are parameterized with a generic: + +```tsx +type MyLoaderData = { + /* ... */ +}; +type MyActionData = { + /* ... */ +}; + +export default function Route() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + return
{/* ... */}
; +} +``` + +For end-to-end type safety, it is then the user's responsability to make sure that `loader` and `action` also use the same type in the `json` generic: + +```ts +export const loader: LoaderFunction = () => { + return json({ + /* ... */ + }); +}; + +export const action: ActionFunction = () => { + return json({ + /* ... */ + }); +}; +``` + +### Diving into `useLoaderData`'s and `useActionData`'s generics + +Tracing through the `@remix-run/react` source code (v1.6.4), you'll find that `useLoaderData` returns an `any` type that is implicitly type cast to whatever type gets passed into the `useLoaderData` generic: + +```ts +// https://github.com/remix-run/remix/blob/v1.6.4/packages/remix-react/components.tsx#L1370 +export function useLoaderData(): T { + return useRemixRouteContext().data; // +} + +// https://github.com/remix-run/remix/blob/v1.6.4/packages/remix-react/components.tsx#L73 +function useRemixRouteContext(): RemixRouteContextType { + /* ... */ +} + +// https://github.com/remix-run/remix/blob/v1.6.4/packages/remix-react/components.tsx#L56 +interface RemixRouteContextType { + data: AppData; + id: string; +} + +// https://github.com/remix-run/remix/blob/v1.6.4/packages/remix-react/data.ts#L4 +export type AppData = any; +``` + +Boiling this down, the code looks like: + +```ts +let data: any; + +// somewhere else, `loader` gets called an sets `data` to some value + +function useLoaderData(): T { + return data; // <-- Typescript casts this `any` to `T` +} +``` + +`useLoaderData` isn't basing its return type on how `data` was set (i.e. the return value of `loader`) nor is it validating the data. +It's just blindly casting `data` to whatever the user passed in for the generic `T`. + +### Issues with current approach + +The developer experience is subpar. +Users are required to write redundant code for the data types that could have been inferred from the arguments to `json`. +Changes to the data shape require changing _both_ the declared `type` or `interface` as well as the argument to `json`. + +Additionally, the current approach encourages users to pass the same type to `json` with the `loader` and to `useLoaderData`, but **this is a footgun**! +`json` can accept data types like `Date` that are JSON serializable, but `useLoaderData` will return the _serialized_ type: + +```ts +type MyLoaderData = { + birthday: Date; +}; + +export const loader: LoaderFunction = () => { + return json({ birthday: new Date("February 15, 1992") }); +}; + +export default function Route() { + const { birthday } = useLoaderData(); + // ^ `useLoaderData` tricks Typescript into thinking this is a `Date`, when in fact its a `string`! +} +``` + +Again, the same goes for `useActionData`. + +### Solution criteria + +- Return type of `useLoaderData` and `useActionData` should somehow be inferred from `loader` and `action`, not blindly type cast +- Return type of `loader` and `action` should be inferred + - Necessarily, return type of `json` should be inferred from its input +- No module side-effects (so higher-order functions like `makeLoader` is definitely a no). +- `json` should allow everything that `JSON.stringify` allows. +- `json` should allow only what `JSON.stringify` allows. +- `useLoaderData` should not return anything that `JSON.parse` can't return. + +### Key insight: `loader` and `action` are an _implicit_ inputs + +While there's been interest in inferring the types for `useLoaderData` based on `loader`, there was [hesitance to use a Typescript generic to do so](https://github.com/remix-run/remix/pull/3276#issuecomment-1164764821). +Typescript generics are apt for specifying or inferring types for _inputs_, not for blindly type casting output types. + +A key factor in the decision was identifying that `loader` and `action` are _implicit_ inputs of `useLoaderData` and `useActionData`. + +In other words, if `loader` and `useLoaderData` were guaranteed to run in the same process (and not cross the network), then we could write `useLoaderData(loader)`, specifying `loader` as an explicit input for `useLoaderData`. + +```ts +// _conceptually_ `loader` is an input for `useLoaderData` +function useLoaderData(loader: Loader) { + /*...*/ +} +``` + +Though `loader` and `useLoaderData` exist together in the same file at development-time, `loader` does not exist at runtime in the browser. +Without the `loader` argument to infer types from, `useLoaderData` needs a way to learn about `loader`'s type at compile-time. + +Additionally, `loader` and `useLoaderData` are both managed by Remix across the network. +While its true that Remix doesn't "own" the network in the strictest sense, having `useLoaderData` return data that does not correspond to its `loader` is an exceedingly rare edge-case. + +Same goes for `useActionData`. + +--- + +A similar case is how [Prisma](https://www.prisma.io/) infers types from database schemas available at runtime, even though there are (exceedingly rare) edge-cases where that database schema _could_ be mutated after compile-time but before run-time. + +## Decision + +Explicitly provide type of the implicit `loader` input for `useLoaderData` and then infer the return type for `useLoaderData`. +Do the same for `action` and `useActionData`. + +```ts +export const loader = async (args: LoaderArgs) => { + // ... + return json(/*...*/); +}; + +export default function Route() { + const data = useLoaderData(); + // ... +} +``` + +Additionally, the inferred return type for `useLoaderData` will only include serializable (JSON) types. + +### Return `unknown` when generic is omitted + +Omitting the generic for `useLoaderData` or `useActionData` results in `any` being returned. +This hides potential type errors from the user. +Instead, we'll change the return type to `unknown`. + +```ts +type MyLoaderData = { + /*...*/ +}; + +export default function Route() { + const data = useLoaderData(); + // ^? unknown +} +``` + +Note: Since this would be a breaking change, changing the return type to `unknown` will be slated for v2. + +### Deprecate non-inferred types via generics + +Passing in a non-inferred type for `useLoaderData` is hiding an unsafe type cast. +Using the `useLoaderData` in this way will be deprecated in favor of an explicit type cast that clearly communicates the assumptions being made: + +```ts +type MyLoaderData = { + /*...*/ +}; + +export default function Route() { + const dataGeneric = useLoaderData(); // <-- will be deprecated + const dataCast = useLoaderData() as MyLoaderData; // <- use this instead +} +``` + +## Consequences + +- Users can continue to provide non-inferred types by type casting the result of `useLoaderData` or `useActionData` +- Users can opt-in to inferred types by using `typeof loader` or `typeof action` at the generic for `useLoaderData` or `useActionData`. +- Return types for `loader` and `action` will be the sources-of-truth for the types inferred for `useLoaderData` and `useActionData`. +- Users do not need to write redundant code to align types across the network +- Return type of `useLoaderData` and `useActionData` will correspond to the JSON _serialized_ types from `json` calls in `loader` and `action`, eliminating a class of errors. +- `LoaderFunction` and `ActionFunction` should not be used when opting into type inference as they override the inferred return types.[^1] + +๐Ÿšจ Users who opt-in to inferred types **MUST** return a `TypedResponse` from `json` and **MUST NOT** return a bare object: + +```ts +const loader = () => { + // NO + return { hello: "world" }; + + // YES + return json({ hello: "world" }); +}; +``` + +[^1]: The proposed `satisfies` operator for Typescript would let `LoaderFunction` and `ActionFunction` enforce function types while preserving the narrower inferred return type: https://github.com/microsoft/TypeScript/issues/47920 diff --git a/decisions/0004-streaming-apis.md b/decisions/0004-streaming-apis.md new file mode 100644 index 0000000000..5677a48e36 --- /dev/null +++ b/decisions/0004-streaming-apis.md @@ -0,0 +1,193 @@ +--- +title: Remix (and React Router) Streaming APIs +--- + +# Title + +Date: 2022-07-27 + +Status: accepted + +## Context + +Remix aims to provide first-class support for React 18's streaming capabilities. Throughout the development process we went through many iterations and naming schemes around the APIs we plan to build into Remix to support streaming, so this document aims to lay out the final names we chose and the reasons behind it. + +It's also worth nothing that even in a single-page-application without SSR-streaming, the same concepts still apply so these decisions were made with React Router 6.4.0 in mind as well - which will support the same Data APIs from Remix. + +## Decision + +Streaming in Remix can be thought of as having 3 touch points with corresponding APIs: + +1. _Initiating_ a streamed response in your `loader` can be done by returning a `defer(object)` call from your `loader` in which some of the keys on `object` are `Promise` instances +2. _Accessing_ a streamed response from `useLoaderData` + 1. No new APIs here - when you return a `defer()` response from your loader, you'll get `Promise` values inside your `useLoaderData` object ๐Ÿ‘Œ +3. _Rendering_ a streamed value (with fallback and error handling) in your component + 1. You can render a `Promise` from `useLoaderData()` with the `` component + 2. `` accepts an `errorElement` prop to handle error UI + 3. `` should be wrapped with a `` component to handle your loading UI + +## Details + +In the spirit of `#useThePlatform` we've chosen to leverage the `Promise` API to represent these "eventually available" values. When Remix receives a `defer()` response back from a `loader`, it needs to serialize that `Promise` over the network to the client application (prompting Jacob to coin the phrase [_"promise teleportation over the network"_][promise teleportation] ๐Ÿ”ฅ). + +### Initiating + +In order to initiate a streamed response in your `loader`, you can use the `defer()` utility which accepts a JSON object with `Promise` values from your `loader`. + +```tsx +export async function loader() { + return defer({ + // Await this, don't stream + critical: await fetchCriticalData(), + // Don't await this - stream it! + lazy: fetchLazyData(), + }); +} +``` + +By not using `await` on `fetchLazyData()` Remix knows that this value is not ready yet _but eventually will be_ and therefore Remix will leverage a streamed HTTP response allowing it to send up the resolved/rejected value when available. Essentially serializing/teleporting that Promise over the network via a streamed HTTP response. + +Just like `json()`, the `defer()` will accept a second optional `responseInit` param that lets you customize the resulting `Response` (i.e., in case you need to set custom headers). + +The name `defer` was settled on as a corollary to ` + + +``` + +You would move that markup into `src/root.tsx` and delete `index.html`: + +```tsx filename=src/root.tsx +import { + Scripts, + Outlet, + ScrollRestoration, +} from "react-router"; + +export default function Root() { + return ( + + + + + My App + + + + + + + + ); +} +``` + +## 3. Add client entry module + +In the typical Vite app setup the `index.html` file points to `src/main.tsx` as the client entry point. React Router uses a file named `src/entry.client.tsx` instead. + +If your current `src/main.tsx` looks like this: + +```tsx filename=src/main.tsx +import "./index.css"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.tsx"; + +ReactDOM.createRoot( + document.getElementById("root")! +).render( + + + +); +``` + +You would rename it to `entry.client.tsx` and have it look like this: + +```tsx filename=src/entry.client.tsx +import "./index.css"; +import React from "react"; +import ReactDOM from "react-dom/client"; +import { HydratedRouter } from "react-router/dom"; + +ReactDOM.hydrateRoot( + document, + + + +); +``` + +- Use `hydrateRoot` instead of `createRoot` +- Render a `` instead of your `` component +- Note that we stopped rendering the `` component, it'll come back in a later step, for now we want to simply get the app booting with the new entry points. + +## 4. Shuffle stuff around + +Between `root.tsx` and `entry.client.tsx`, you may want to shuffle some stuff around between them. + +In general: + +- `root.tsx` contains any rendering things like context providers, layouts, styles, etc. +- `entry.client.tsx` should be as minimal as possible +- Remember to _not_ try to render your existing `` component so isolate steps + +Note that your `root.tsx` file will be statically generated and served as the entry point of your app, so just that module will need to be compatible with server rendering. This is where most of your trouble will come. + +## 5. Boot the app + +At this point you should be able to to boot the app and see the root layout. + +```shellscript +npx react-router dev +``` + +- Search the [Upgrading Discussion](#TODO) category +- Reach out for help on [Twitter](https://x.com/remix_run) or [Discord](https://rmx.as/discord) + +Make sure you can boot your app at this point before moving on. + +## 6. Configure Catchall Route + +To get back to rendering your app, we'll configure a "catchall" route that matches all URLs so that your existing `` get a chance to render. + +Create a file at `src/routes.ts` and add this: + +```ts filename=src/routes.ts +import { type RouteConfig } from "@react-router/dev/routes"; + +export default [ + { + path: "*", + file: "src/catchall.tsx", + }, +] satisfies RouteConfig; +``` + +And then create the catchall route module and render your existing root App component within it. + +```tsx filename=src/catchall.tsx +import App from "./App"; + +export default function Component() { + return ; +} +``` + +Your app should be back on the screen and working as usual! + +## 6. Migrate a route to a Route Module + +You can now incrementally migrate your routes to route modules. + +Given an existing route like this: + +```tsx filename=src/App.tsx +// ... +import Page from "./containers/page"; + +export default function App() { + return ( + + } /> + + ); +} +``` + +You can move the definition to a `routes.ts` file: + +```tsx filename=src/routes.ts +import { type RouteConfig } from "@react-router/dev/routes"; + +export default [ + { + path: "/pages/:id", + file: "./containers/page.tsx", + }, + { + path: "*", + file: "src/catchall.tsx", + }, +] satisfies RouteConfig; +``` + +And then edit the route module to use the Route Module API: + +```tsx filename=src/pages/about.tsx +import { useLoaderData } from "react-router"; + +export async function clientLoader({ params }) { + let page = await getPage(params.id); + return page; +} + +export default function Component() { + let data = useLoaderData(); + return

{data.title}

; +} +``` + +You'll now get inferred type safety with params, loader data, and more. + +The first few routes you migrate are the hardest because you often have to access various abstractions a bit differently than before (like in a loader instead of from a hook or context). But once the trickiest bits get dealt with, you get into an incremental groove. + +## Enable SSR and Pre-rendering + +If you want to enable server rendering and static pre-rendering, you can do so with the `ssr` and `prerender` options in the bundler plugin. + +```ts filename=vite.config.ts +import type { Config } from "@react-router/dev/config"; + +export default { + ssr: true, + async prerender() { + return ["/", "/about", "/contact"]; + }, +} satisfies Config; +``` + +See [Deploying][deploying] for more information on deploying a server. + +[deploying]: ../start/deploying diff --git a/docs/upgrading/index.md b/docs/upgrading/index.md index 6f43e67dce..be6b16490e 100644 --- a/docs/upgrading/index.md +++ b/docs/upgrading/index.md @@ -1,4 +1,4 @@ --- title: Upgrading -order: 1 +order: 2 --- diff --git a/docs/upgrading/reach.md b/docs/upgrading/reach.md deleted file mode 100644 index e1382b1b4f..0000000000 --- a/docs/upgrading/reach.md +++ /dev/null @@ -1,510 +0,0 @@ ---- -title: Migrating from @reach/router ---- - -# Migrating from Reach Router to React Router v6 - -This page is a work-in-progress. Please let us know where it -lacks so we can make the migration as smooth as possible! - -## Introduction - -When we set out to build React Router v6, from the perspective of `@reach/router` users, we had these goals: - -- Keep the bundle size low (turns out we got it smaller than `@reach/router`) -- Keep the best parts of `@reach/router` (nested routes, and a simplified API via ranked path matching and `navigate`) -- Update the API to be idiomatic with modern React (AKA hooks). -- Provide better support for Concurrent Mode and Suspense. -- Stop doing not-good-enough focus management by default. - -If we were to make a `@reach/router` v2, it would look pretty much exactly like React Router v6. So, the next version of `@reach/router` _is_ React Router v6. In other words, there will be no `@reach/router` v2, because it would be the same as React Router v6. - -A lot of the API is actually identical between `@reach/router` 1.3 and React Router v6: - -- Routes are ranked and matched -- The nested route config is there -- `navigate` has the same signature -- `Link` has the same signature -- All the hooks in 1.3 are identical (or nearly identical) - -Most of the changes are just some renames. If you happen to write a codemod, please share it with us and we'll add it to this guide! - -## Upgrading Overview - -In this guide we'll show you how to upgrade each piece of your routing code. We'll do it incrementally so you can make some changes, ship, and then get back to migrating again when it's convenient. We'll also discuss a little bit about "why" the changes were made, what might look like a simple rename actually has bigger reasons behind it. - -### First: Non-breaking Updates - -We highly encourage you to do the following updates to your code before migrating to React Router v6. These changes don't have to be done all at once across your app, you can simply update one line, commit, and ship. Doing this will greatly reduce the effort when you get to the breaking changes in React Router v6. - -1. Upgrade to React v16.8 or greater -2. Upgrade to `@reach/router` v1.3 -3. Update route components to access data from hooks -4. Add a `` to the top of the app - -### Second: Breaking Updates - -The following changes need to be done all at once across your app. - - - -1. Upgrade to React Router v6 -2. Update all `` elements to `` -3. Change `` to `` -4. Fix `` -5. Implement `` with hooks -6. Update `useMatch`, params are on `match.params` -7. Change `ServerLocation` to `StaticRouter` - -## Non-Breaking Updates - -### Upgrade to React v16.8 - -React Router v6 makes heavy use of [React -hooks](https://reactjs.org/docs/hooks-intro.html), so you'll need to be on -React 16.8 or greater before attempting the upgrade to React Router v6. - -Once you've upgraded to React 16.8, you should deploy your app. Then you can -come back later and pick up where you left off. - -### Upgrade to `@reach/router` v1.3.3 - -You should be able to simply install v1.3.3 and then deploy your app. - -```sh -npm install @reach/router@latest -``` - -### Update route components to use hooks - -You can do this step one route component at a time, commit, and deploy. You don't need to update the entire app at once. - -In `@reach/router` v1.3 we added hooks to access route data in preparation for React Router v6. If you do this first you'll have a lot less to do when you upgrade to React Router v6. - -```jsx -// @reach/router v1.2 - - -; - -function User(props) { - let { - // route params were accessed from props - userId, - assignmentId, - - // as well as location and navigate - location, - navigate, - } = props; - - // ... -} - -// @reach/router v1.3 and React Router v6 -import { - useParams, - useLocation, - useNavigate, -} from "@reach/router"; - -function User() { - // everything comes from a specific hook now - let { userId, assignmentId } = useParams(); - let location = useLocation(); - let navigate = useNavigate(); - // ... -} -``` - -#### Justification - -All of this data lives on context already, but accessing it from there was awkward for application code so we dumped it into your props. Hooks made accessing data from context simple so we no longer need to pollute your props with route information. - -Not polluting props also helps with TypeScript a bit and also prevents you from wondering where a prop came from when looking at a component. If you're using data from the router, it's completely clear now. - -Also, as a page grows, you naturally break it into multiple components and end up "prop drilling" that data all the way down the tree. Now you can access the route data anywhere in the tree. Not only is it more convenient, but it makes creating router-centric composable abstractions possible. If a custom hook needs the location, it can now simply ask for it with `useLocation()` etc.. - -### Add a LocationProvider - -While `@reach/router` doesn't require a location provider at the top of the application tree, React Router v6 does, so might as well get ready for that now. - -```jsx -// before -ReactDOM.render(, el); - -// after -import { LocationProvider } from "@reach/router"; - -ReactDOM.render( - - - , - el -); -``` - -#### Justification: - -`@reach/router` uses a global, default history instance that has side effects in the module, which prevents the ability to tree-shake the module whether you use the global or not. Additionally, React Router provides other history types (like hash history) that `@reach/router` doesn't, so it always requires a top-level location provider (in React Router these are `` and friends). - -Also, various modules like `Router`, `Link` and `useLocation` rendered outside a `` set up their own URL listener. It's generally not a problem, but every little bit counts. Putting a `` at the top allows the app to have a single URL listener. - -## Breaking updates - -This next group of updates need to be done all at once. Fortunately most of it is just a simple rename. - -You can pull a trick though and use both routers at the same time as you migrate, but you should absolutely not ship your app in this state because they are not interoperable. Your links from one won't work for the other. However, it is nice to be able to make a change and refresh the page to see that you did that one step correctly. - -### Install React Router v6 - -```sh -npm install react-router@6 react-router-dom@6 -``` - -### Update `LocationProvider` to `BrowserRouter` - -```jsx -// @reach/router -import { LocationProvider } from "@reach/router"; - -ReactDOM.render( - - - , - el -); - -// React Router v6 -import { BrowserRouter } from "react-router-dom"; - -ReactDOM.render( - - - , - el -); -``` - -### Update `Router` to `Routes` - -You may have more than one, but usually there's just one somewhere near the top of your app. If you have multiple, go ahead and do this for each one. - -```jsx -// @reach/router -import { Router } from "@reach/router"; - - - - {/* ... */} -; - -// React Router v6 -import { Routes, Route } from "react-router-dom"; - - - } /> - {/* ... */} -; -``` - -### Update `default` route prop - -The `default` prop told `@reach/router` to use that route if no other routes matched. In React Router v6 you can explain this behavior with a wildcard path. - -```jsx -// @reach/router - - - - - -// React Router v6 - - } /> - } /> - -``` - -### ``, `redirectTo`, `isRedirect` - -Whew ... buckle up for this one. And please save your tomatoes for a homemade margherita pizza instead of throwing them at us. - -We have removed the ability to redirect from React Router. So this means there is no ``, `redirectTo`, or `isRedirect`, and no replacement APIs either. Please keep reading ๐Ÿ˜… - -Don't confuse redirects with navigating while the user interacts with your app. Navigating in response to user interactions is still supported. When we talk about redirects, we're talking about redirecting while matching: - -```jsx - - - - - -``` - -The way redirects work in `@reach/router` was a bit of an experiment. It "throws" redirects and catches it with `componentDidCatch`. This was cool because it caused the entire render tree to stop, and then start over with the new location. Discussions with the React team years ago when we first shipped this project led us to give it a shot. - -After bumping into issues (like app level `componentDidCatch`'s needing to rethrow the redirect), we've decided not to do that anymore in React Router v6. - -But we've gone a step farther and concluded that redirects are not even the job of React Router. Your dynamic web server or static file server should be handling this and sending an appropriate response status code like 301 or 302. - -Having the ability to redirect while matching in React Router at best requires you to configure the redirects in two places (your server and your routes) and at worst encouraged people to only do it in React Router--which doesn't send a status code at all. - -We use firebase hosting a lot, so as an example here's how we'd update one of our apps: - -```jsx -// @reach/router - - - - - -``` - -```jsx -// React Router v6 -// firebase.json config file -{ - // ... - "hosting": { - "redirects": [ - { - "source": "/dashboard", - "destination": "/events", - "type": 301 - } - ] - } -} -``` - -This works whether we're server rendering with a serverless function, or if we're using it as a static file server only. All web hosting services provide a way to configure this. - -#### What about clicking Links that aren't updated? - -If your app has a `` still hanging around and the user -clicks it, the server isn't involved since you're using a client-side router. -You'll need to be more diligent about updating your links ๐Ÿ˜ฌ. - -Alternatively, if you want to allow for outdated links, _and you realize you need to configure your redirects on both the client and the server_, go ahead and copy and paste the `Redirect` component we were about to ship but then deleted. - -```jsx -import { useEffect } from "react"; -import { useNavigate } from "react-router-dom"; - -function Redirect({ to }) { - let navigate = useNavigate(); - useEffect(() => { - navigate(to); - }); - return null; -} - -// usage - - } /> - } /> - } - /> -; -``` - -#### Justification - -We figured by not providing any redirect API at all, people will be more likely to configure them correctly. We've been accidentally encouraging bad practice for years now and would like to stop ๐Ÿ™ˆ. - -### `` - -This prop getter was useful for styling links as "active". Deciding if a link is active is kind of subjective. Sometimes you want it to be active if the URL matches exactly, sometimes you want it active if it matches partially, and there are even more edge cases involving search params and location state. - -```jsx -// @reach/router -function SomeCustomLink() { - return ( - { - let { - isCurrent, - isPartiallyCurrent, - href, - location, - } = obj; - // do what you will - }} - /> - ); -} - -// React Router -import { useLocation, useMatch } from "react-router-dom"; - -function SomeCustomLink() { - let to = "/some/where/cool"; - let match = useMatch(to); - let { isExact } = useMatch(to); - let location = useLocation(); - return ; -} -``` - -Let's look at some less general examples. - -```jsx -// A custom nav link that is active when the URL matches the link's href exactly - -// @reach/router -function ExactNavLink(props) { - const isActive = ({ isCurrent }) => { - return isCurrent ? { className: "active" } : {}; - }; - return ; -} - -// React Router v6 -function ExactNavLink(props) { - return ( - - isActive ? "active" : "" - } - {...props} - /> - ); -} - -// A link that is active when itself or deeper routes are current - -// @reach/router -function PartialNavLink(props) { - const isPartiallyActive = ({ isPartiallyCurrent }) => { - return isPartiallyCurrent - ? { className: "active" } - : {}; - }; - return ; -} - -// React Router v6 -function PartialNavLink(props) { - // add the wild card to match deeper URLs - let match = useMatch(props.to + "/*"); - return ( - - ); -} -``` - -#### Justification - -"Prop getters" are clunky and can almost always be replaced with a hook. This also allows you to use the other hooks, like `useLocation`, and do even more custom things, like making a link active with a search string: - -```jsx -function RecentPostsLink(props) { - let match = useMatch("/posts"); - let location = useLocation(); - let isActive = - match && location.search === "?view=recent"; - return ( - Recent - ); -} -``` - -### `useMatch` - -The signature of `useMatch` is slightly different in React Router v6. - -```jsx -// @reach/router -let { - uri, - path, - - // params are merged into the object with uri and path - eventId, -} = useMatch("/events/:eventId"); - -// React Router v6 -let { - url, - path, - - // params get their own key on the match - params: { eventId }, -} = useMatch("/events/:eventId"); -``` - -Also note the change from `uri -> url`. - -#### Justification - -Just feels cleaner to have the params be separate from URL and path. - -Also, nobody knows the difference between URL and URI, so we didn't want to start a bunch of pedantic arguments about it. React Router always called it URL, and it's got more production apps, so we used URL instead of URI. - -### `` - -There is no `` component in React Router v6. It used render props to compose behavior, but we've got hooks now. - -If you like it, or just don't want to update your code, it's easy to backport: - -```jsx -function Match({ path, children }) { - let match = useMatch(path); - let location = useLocation(); - let navigate = useNavigate(); - return children({ match, location, navigate }); -} -``` - -#### Justification - -Render props are kinda gross (ew!) now that we have hooks. - -### `` - -Really simple rename here: - -```jsx -// @reach/router -import { ServerLocation } from "@reach/router"; - -createServer((req, res) => { - let markup = ReactDOMServer.renderToString( - - - - ); - req.send(markup); -}); - -// React Router v6 -// note the import path from react-router-dom/server! -import { StaticRouter } from "react-router-dom/server"; - -createServer((req, res) => { - let markup = ReactDOMServer.renderToString( - - - - ); - req.send(markup); -}); -``` - -## Feedback! - -Please let us know if this guide helped: - -_Open a Pull Request_: Please add any migration we missed that you needed. - -_General Feedback_: [@remix_run](https://twitter.com/remix_run) on Twitter, or email [hello@remix.run](mailto:hello@remix.run). - -Thanks! diff --git a/docs/upgrading/remix.md b/docs/upgrading/remix.md new file mode 100644 index 0000000000..3e43df5a79 --- /dev/null +++ b/docs/upgrading/remix.md @@ -0,0 +1,352 @@ +--- +title: Upgrading from Remix +order: 2 +--- + +# Upgrading from Remix + +React Router v7 is the next major version of Remix after v2 (see our ["Incremental Path to React 19" blog post][incremental-path-to-react-19]) for more information). + +The Remix v2 -> React Router v7 upgrade requires mostly updates to dependencies if you are caught up on all [Remix v2 future flags][v2-future-flags] (step 1). + + + +The majority of steps 2-8 can be automatically updated using a [codemod][codemod] created by community member [James Restall][jrestall]. + + + +## 1. Adopt future flags + +**๐Ÿ‘‰ Adopt future flags** + +Adopt all existing [future flags][v2-future-flags] in your Remix v2 application. + +## 2. Update dependencies + +Most of the "shared" APIs that used to be re-exported through the runtime-specific packages (`@remix-run/node`, `@remix-run/cloudflare`, etc.) have all been collapsed into `react-router` in v7. So instead of importing from `@react-router/node` or `@react-router/cloudflare`, you'll import those directly from `react-router`. + +```diff +-import { redirect } from "@react-router/node"; ++import { redirect } from "react-router"; +``` + +The only APIs you should be importing from the runtime-specific packages in v7 are APIs that are specific to that runtime, such as `createFileSessionStorage` for Node and `createWorkersKVSessionStorage` for Cloudflare. + +**๐Ÿ‘‰ Run the codemod (automated)** + +You can automatically update your packages and imports with the following [codemod][codemod]. This codemod updates all of your packages and imports. Be sure to commit any pending changes before running the codemod, in case you need to revert. + +```shellscript nonumber +npx codemod remix/2/react-router/upgrade +``` + +**๐Ÿ‘‰ Install the new dependencies** + +After the codemod updates your dependencies, you need to install the dependencies to remove Remix packages and add the new React Router packages. + + + +While still in prerelease, you need to update your `package.json` to point to the prerelease versions of the `react-router` packages. + + + +```shellscript nonumber +npm install +``` + +**๐Ÿ‘‰ Update your dependencies (manual)** + +If you prefer not to use the codemod, you can manually update your dependencies. + +
+Expand to see a table of package name changes in alphabetical order + +| Remix v2 Package | | React Router v7 Package | +| ---------------------------------- | --- | ------------------------------------------- | +| `@remix-run/architect` | โžก๏ธ | `@react-router/architect` | +| `@remix-run/cloudflare` | โžก๏ธ | `@react-router/cloudflare` | +| `@remix-run/dev` | โžก๏ธ | `@react-router/dev` | +| `@remix-run/express` | โžก๏ธ | `@react-router/express` | +| `@remix-run/fs-routes` | โžก๏ธ | `@react-router/fs-routes` | +| `@remix-run/node` | โžก๏ธ | `@react-router/node` | +| `@remix-run/react` | โžก๏ธ | `react-router` | +| `@remix-run/route-config` | โžก๏ธ | `@react-router/dev` | +| `@remix-run/routes-option-adapter` | โžก๏ธ | `@react-router/remix-routes-option-adapter` | +| `@remix-run/serve` | โžก๏ธ | `@react-router/serve` | +| `@remix-run/server-runtime` | โžก๏ธ | `react-router` | +| `@remix-run/testing` | โžก๏ธ | `react-router` | + +
+ +## 3. Change `scripts` in `package.json` + + + +If you used the codemod you can skip this step as it was automatically completed. + + + +**๐Ÿ‘‰ Update the scripts in your `package.json`** + +| Script | Remix v2 | | React Router v7 | +| ----------- | ----------------------------------- | --- | ------------------------------------------ | +| `dev` | `remix vite:dev` | โžก๏ธ | `react-router dev` | +| `build` | `remix vite:build` | โžก๏ธ | `react-router build` | +| `start` | `remix-serve build/server/index.js` | โžก๏ธ | `react-router-serve build/server/index.js` | +| `typecheck` | `tsc` | โžก๏ธ | `react-router typegen && tsc` | + +## 4. Add a `routes.ts` file + + + +If you used the codemod _and_ Remix v2 `unstable_routeConfig` flag, you can skip this step as it was automatically completed. + + + +In React Router v7 you define your routes using the `app/routes.ts` file. View the [routing documentation][routing] for more information. + +**๐Ÿ‘‰ Update dependencies (if using Remix v2 `unstable_routeConfig` flag)** + +```diff +// 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"; ++import { type RouteConfig } from "@react-router/dev/routes"; ++import { flatRoutes } from "@react-router/fs-routes"; ++import { remixRoutesOptionAdapter } from "@react-router/remix-routes-option-adapter"; + +export default [ + // however your routes are defined +] satisfies RouteConfig; + +``` + + + +**๐Ÿ‘‰ Add a `routes.ts` file (if _not_ using Remix v2 `unstable_routeConfig` flag)** + +```shellscript nonumber +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(), + ], +}); +``` + +## 5. Add a React Router config + +**๐Ÿ‘‰ Add `react-router.config.ts` your project** + +The config that was previously passed to the `remix` plugin in `vite.config.ts` is now exported from `react-router.config.ts`. + +Note: At this point you should remove the v3 future flags you added in step 1. + +```shellscript nonumber +touch react-router.config.ts +``` + +```diff +// vite.config.ts +export default defineConfig({ + plugins: [ +- remix({ +- ssr: true, +- future: {/* all the v3 flags */} +- }), ++ remix(), + tsconfigPaths(), + ], +}); + +// react-router.config.ts ++import type { Config } from "@react-router/dev/config"; ++export default { ++ ssr: true, ++} satisfies Config; +``` + +## 6. Add React Router plugin to `vite.config` + + + +If you used the codemod you can skip this step as it was automatically completed. + + + +**๐Ÿ‘‰ Add `reactRouter` plugin to `vite.config`** + +Change `vite.config.ts` to import and use the new `reactRouter` plugin from `@react-router/dev/vite`: + +```diff +-import { vitePlugin as remix } from "@remix-run/dev"; ++import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [ +- remix(), ++ reactRouter(), + tsconfigPaths(), + ], +}); +``` + +## 7. Enable type safety + + + +If you're not using TypeScript, you can skip this step. + + + +React Router automatically generates types for your route modules into a `.react-router/` directory at the root of your app. This directory is fully managed by React Router and should be gitignore'd. Learn more about the [new type safety features][type-safety]. + +**๐Ÿ‘‰ Add `.react-router/` to `.gitignore`** + +```txt +.react-router/ +``` + +**๐Ÿ‘‰ Update `tsconfig.json`** + +Update the `types` field in your `tsconfig.json` to include: + +- `.react-router/types/**/*` path in the `include` field +- The appropriate `@react-router/*` package in the `types` field +- `rootDirs` for simplified relative imports + +```diff +{ + "include": [ + /* ... */ ++ ".react-router/types/**/*" + ], + "compilerOptions": { +- "types": ["@remix-run/node", "vite/client"], ++ "types": ["@react-router/node", "vite/client"], + /* ... */ ++ "rootDirs": [".", "./.react-router/types"] + } +} +``` + +## 8. Rename components in entry files + + + +If you used the codemod you can skip this step as it was automatically completed. + + + +If you have an `entry.server.tsx` and/or an `entry.client.tsx` file in your application, you will need to update the main components in these files: + +```diff filename=app/entry.server.tsx +-import { RemixServer } from "@remix-run/react"; ++import { ServerRouter } from "react-router"; + +-, ++, +``` + +```diff filename=app/entry.client.tsx +-import { RemixBrowser } from "@remix-run/react"; ++import { HydratedRouter } from "react-router/dom"; + +hydrateRoot( + document, + +- ++ + , +); +``` + +## 9. Update types for `AppLoadContext` + + + +If you were using `remix-serve` you can skip this step. This is only applicable if you were using a custom server in Remix v2. + + + +If you were using `getLoadContext` in your Remix app, then you'll notice that the `LoaderFunctionArgs`/`ActionFunctionArgs` types now type the `context` parameter incorrectly (optional and typed as `any`). These types accept a generic for the `context` type but even that still leaves the property as optional because it does not exist in React Router SPA apps. + +The proper long term fix is to move to the new [`Route.LoaderArgs`][server-loaders]/[`Route.ActionArgs`][server-actions] types from the [new typegen in React Router v7][type-safety]. + +However, the short-term solution to ease the upgrade is to use TypeScript's [module augmentation][ts-module-augmentation] feature to override the built in `LoaderFunctionArgs`/`ActionFunctionArgs` interfaces. + +You can do this with the following code in `react-router.config.ts` (just make sure it gets picked up by your `tsconfig.json`'s `include` paths): + +```ts filename=react-router.config.ts +// Your AppLoadContext used in v2 +interface AppLoadContext { + whatever: string; +} + +// Tell v7 the type of the context and that it is non-optional +declare module "react-router" { + interface LoaderFunctionArgs { + context: AppLoadContext; + } +} +``` + +This should allow you to upgrade and ship your application on React Router v7, and then you can incrementally migrate routes to the [new typegen approach][type-safety]. + +Congratulations! You are now on React Router v7. Go ahead and run your application to make sure everything is working as expected. + +[incremental-path-to-react-19]: https://remix.run/blog/incremental-path-to-react-19 +[v2-future-flags]: https://remix.run/docs/start/future-flags +[routing]: ../start/framework/routing +[fs-routing]: ../how-to/file-route-conventions +[v7-changelog-types]: https://github.com/remix-run/react-router/blob/release-next/CHANGELOG.md#typesafety-improvements +[server-loaders]: ../start/framework/data-loading#server-data-loading +[server-actions]: ../start/framework/actions#server-actions +[ts-module-augmentation]: https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation +[type-safety]: ../explanation/type-safety +[codemod]: https://codemod.com/registry/remix-2-react-router-upgrade +[jrestall]: https://github.com/jrestall diff --git a/docs/upgrading/router-provider.md b/docs/upgrading/router-provider.md new file mode 100644 index 0000000000..95b2a21407 --- /dev/null +++ b/docs/upgrading/router-provider.md @@ -0,0 +1,253 @@ +--- +title: Framework Adoption from RouterProvider +--- + + + +This guide is mostly a stub and in active development, it will be wrong about many things before the final v7 release + + + +Checkout this example repo and livestream for a walkthrough of what an upgrade process might look like. + + + +# Adopting Vite (RouterProvider) + +If you are not using `` please see [Adopting Route Modules from Component Routes](./vite-component-routes) instead. + +The React Router vite plugin adds framework features to React Router. This document wil help you adopt the plugin in your app if you'd like. + +## Features + +The Vite plugin adds: + +- Route loaders, actions, and automatic data revalidation +- Typesafe Routes Modules +- Typesafe Route paths across your app +- Automatic route code-splitting +- Automatic scroll restoration across navigations +- Optional Static pre-rendering +- Optional Server rendering +- Optional React Server Components + +The initial setup will likely be a bit of a pain, but once complete, adopting the new features is incremental, you can do one route at a time. + +## 1. Install Vite + +First install the React Router vite plugin: + +```shellscript nonumber +npm install -D @react-router/dev +``` + +Then swap out the React plugin for React Router. + +```diff filename=vite.config.ts +-import react from '@vitejs/plugin-react' ++import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig } from "vite"; + + +export default defineConfig({ + plugins: [ +- react() ++ reactRouter() + ], +}); +``` + +## 2. Add the Root entry point + +In a typical Vite app, the `index.html` file is the entry point for bundling. The React Router Vite plugin uses `root.tsx`. This let's you use React to render the shell instead of static HTML. + +If your current `index.html` looks like this: + +```html filename=index.html + + + + + + My App + + +
+ + + +``` + +You would move that markup into `src/root.tsx` and delete `index.html`: + +```tsx filename=src/root.tsx +import { + Scripts, + Outlet, + ScrollRestoration, +} from "react-router"; + +export default function Root() { + return ( + + + + + My App + + + + + + + + ); +} +``` + +## 3. Add client entry module + +In the typical Vite app setup the `index.html` file points to `src/main.tsx` as the client entry point. React Router uses a file named `src/entry.client.tsx`. + +If your current `src/main.tsx` looks like this: + +```tsx filename=src/main.tsx +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { + createBrowserRouter, + RouterProvider, +} from "react-router-dom"; + +const router = createBrowserRouter(YOUR_ROUTES); + +ReactDOM.createRoot(document.getElementById("root")).render( + +); +``` + +You would rename it to `entry.client.tsx` and change it to this: + +```tsx filename=src/entry.client.tsx +import * as React from "react"; +import * as ReactDOM from "react-dom"; +import { HydratedRouter } from "react-router-dom"; + +ReactDOM.hydrateRoot( + document, + + + +); +``` + +- Use `hydrateRoot` instead of `createRoot` +- Use `` instead of `` +- Pass your routes to `` + +## 4. Shuffle stuff around + +Between `root.tsx` and `entry.client.tsx`, you may want to shuffle some stuff around between them. + +In general: + +- `root.tsx` contains any rendering things like context providers, layouts, styles, etc. +- `entry.client.tsx` should be as minimal as possible + +Note that your `root.tsx` file will be statically generated and served as the entry point of your app, so just that module will need to be compatible with server rendering. This is where most of your trouble will come. + +## 5. Boot the app + +At this point you should be able to to boot the app. + +```shellscript +npx react-router dev +``` + +If you're having trouble + +- Comment out the `routes` prop to `` to isolate the problem to the new entry points +- Search the [Upgrading Discussion](#TODO) category +- Reach out for help on [Twitter](https://x.com/remix_run) or [Discord](https://rmx.as/discord) + +Make sure you can boot your app at this point before moving on. + +## 6. Migrate a route to a Route Module + +You can now incrementally migrate your routes to route modules. First create a `routes.ts` file that exports your routes. + +Given an existing route like this: + +```tsx filename=src/entry.client.tsx +// ... +import Page from "./containers/page"; + +ReactDOM.hydrateRoot( + document, + + , + }, + ]} + /> + +); +``` + +You can move the definition to a `routes.ts` file: + +```tsx filename=src/routes.ts +import { type RouteConfig } from "@react-router/dev/routes"; + +export default [ + { + path: "/pages/:id", + file: "./containers/page.tsx", + }, +] satisfies RouteConfig; +``` + +And then edit the route module to use the Route Module API: + +```tsx filename=src/pages/about.tsx +import { useLoaderData } from "react-router"; + +export async function clientLoader({ params }) { + let page = await getPage(params.id); + return page; +} + +export default function Component() { + let data = useLoaderData(); + return

{data.title}

; +} +``` + +You'll now get inferred type safety with params, loader data, and more. + +The first few routes you migrate are the hardest because you often have to access the same abstractions a bit differently than before (like in a loader instead of from a hook or context). But once the trickiest bits get dealt with, you get into an incremental groove. + +## Enable SSR and/or Pre-rendering + +If you want to enable server rendering and static pre-rendering, you can do so with the `ssr` and `prerender` options in the bundler plugin. For SSR you'll need to also deploy the server build to a server. See [Deploying](../start/deploying) for more information. + +```ts filename=vite.config.ts +import type { Config } from "@react-router/dev/config"; + +export default { + ssr: true, + async prerender() { + return ["/", "/pages/about"]; + }, +} satisfies Config; +``` diff --git a/docs/upgrading/v5.md b/docs/upgrading/v5.md deleted file mode 100644 index aa0fb7239b..0000000000 --- a/docs/upgrading/v5.md +++ /dev/null @@ -1,959 +0,0 @@ ---- -title: Upgrading from v5 -order: 2 ---- - -# Upgrading from v5 - -## Backwards Compatibility Package - -Instead of upgrading and updating all of your code at once (which is incredibly difficult and prone to bugs), the backwards compatibility package enables you to upgrade one component, one hook, and one route at a time by running both v5 and v6 in parallel. Any code you haven't touched is still running the very same code it was before. Once all components are exclusively using the v6 APIs, your app no longer needs the compatibility package and is running on v6. The official guide can be found [here](https://github.com/remix-run/react-router/discussions/8753). - -We recommend using the backwards compatibility package to upgrade apps that have more than a few routes. Otherwise, we hope this guide will help you do the upgrade all at once! - -## Introduction - -React Router version 6 introduces several powerful new features, as well as improved compatibility with the latest versions of React. It also introduces a few breaking changes from version 5. This document is a comprehensive guide on how to upgrade your v4/5 app to v6 while hopefully being able to ship as often as possible as you go. - -The examples in this guide will show code samples of how you might have built something in a v5 app, followed by how you would accomplish the same thing in v6. There will also be an explanation of why we made this change and how it's going to improve both your code and the overall user experience of people who are using your app. - -In general, the process looks like this: - -1. [Upgrade to React v16.8 or greater](#upgrade-to-react-v168) -2. [Upgrade to React Router v5.1](#upgrade-to-react-router-v51) - - [Remove ``s inside ``](#remove-redirects-inside-switch) - - [Refactor custom ``s](#refactor-custom-routes) -3. [Upgrade to React Router v6](#upgrade-to-react-router-v6) - -The following is a detailed breakdown of each step that should help you migrate quickly and with confidence to v6. - -## Upgrade to React v16.8 - -React Router v6 makes heavy use of [React hooks](https://reactjs.org/docs/hooks-intro.html), so you'll need to be on React 16.8 or greater before attempting the upgrade to React Router v6. The good news is that React Router v5 is compatible with React >= 15, so if you're on v5 (or v4) you should be able to upgrade React without touching any of your router code. - -Once you've upgraded to React 16.8, **you should deploy your app**. Then you can come back later and pick up where you left off. - -## Upgrade to React Router v5.1 - -It will be easier to make the switch to React Router v6 if you upgrade to v5.1 first. In v5.1, we released an enhancement to the handling of `` elements that will help smooth the transition to v6. Instead of using `` and `` props, just use regular element `` everywhere and use hooks to access the router's internal state. - -```js -// v4 and v5 before 5.1 -function User({ id }) { - // ... -} - -function App() { - return ( - - - - ( - - )} - /> - - ); -} - -// v5.1 preferred style -function User() { - let { id } = useParams(); - // ... -} - -function App() { - return ( - - - - - - - - {/* Can also use a named `children` prop */} - } /> - - ); -} -``` - -You can read more about v5.1's hooks API and the rationale behind the move to regular elements [on our blog](https://reacttraining.com/blog/react-router-v5-1/). - -In general, React Router v5.1 (and v6) favors elements over components (or "element types"). There are a few reasons for this, but we'll discuss more further down when we discuss v6's `` API. - -When you use regular React elements you get to pass the props explicitly. This helps with code readability and maintenance over time. If you were using `` to get a hold of the params, you can just -`useParams` inside your route component instead. - -Along with the upgrade to v5.1, you should replace any usage of `withRouter` with hooks. You should also get rid of any "floating" `` elements that are not inside a ``. Again, [the blog post about v5.1](https://reacttraining.com/blog/react-router-v5-1/) explains how to do this in greater detail. - -In summary, to upgrade from v4/5 to v5.1, you should: - -- Use `` instead of `` and/or `` - props -- Use [our hooks API](https://reacttraining.com/react-router/web/api/Hooks) to - access router state like the current location and params -- Replace all uses of `withRouter` with hooks -- Replace any ``s that are not inside a `` with `useRouteMatch`, - or wrap them in a `` - -### Remove ``s inside `` - -Remove any `` elements that are directly inside a ``. - -If you want to redirect on the initial render, you should move the redirect logic to your server (we [wrote more about this here](https://gist.github.com/mjackson/b5748add2795ce7448a366ae8f8ae3bb)). - -If you want to redirect client-side, move your `` into a `` prop. - -```tsx -// Change this: - - - - -// to this: - - } /> - -``` - -Normal `` elements that are not inside a `` are ok to remain. They will become `` elements in v6. - -### Refactor custom ``s - -Replace any elements inside a `` that are not plain `` elements with a regular ``. This includes any ``-style custom components. - -You can [read more about the rationale behind this here](https://gist.github.com/mjackson/d54b40a094277b7afdd6b81f51a0393f), including some tips about how to use a `` prop in v5 to achieve the same effect. - -### Ship it! - -Again, **once your app is upgraded to v5.1 you should test and deploy it**, and pick this guide back up when you're ready to continue. - -## Upgrade to React Router v6 - -**Heads up:** This is the biggest step in the migration and will probably take the most time and effort. - -For this step, you'll need to install React Router v6. If you're managing dependencies via npm: - -```bash -$ npm install react-router-dom -# or, for a React Native app -$ npm install react-router-native -``` - -You'll also want to remove the `history` dependency from your package.json. The `history` library is a direct dependency of v6 (not a peer dep), so you won't ever import or use it directly. Instead, you'll use the `useNavigate()` hook for all navigation (see below). - -### Upgrade all `` elements to `` - -React Router v6 introduces a `Routes` component that is kind of like `Switch`, but a lot more powerful. The main advantages of `Routes` over `Switch` are: - -- All ``s and ``s inside a `` are relative. This leads to - leaner and more predictable code in `` and `` -- Routes are chosen based on the best match instead of being traversed in order. - This avoids bugs due to unreachable routes because they were defined later - in your `` -- Routes may be nested in one place instead of being spread out in different - components. In small to medium-sized apps, this lets you easily see all your - routes at once. In large apps, you can still nest routes in bundles that you - load dynamically via `React.lazy` - -In order to use v6, you'll need to convert all your `` elements to ``. If you already made the upgrade to v5.1, you're halfway there. - -First, let's talk about relative routes and links in v6. - -### Relative Routes and Links - -In v5, you had to be very explicit about how you wanted to nest your routes and links. In both cases, if you wanted nested routes and links you had to build the `` and `` props from the parent route's `match.url` and `match.path` properties. Additionally, if you wanted to nest routes, you had to put them in the child route's component. - -```js -// This is a React Router v5 app -import { - BrowserRouter, - Switch, - Route, - Link, - useRouteMatch, -} from "react-router-dom"; - -function App() { - return ( - - - - - - - - - - - ); -} - -function Users() { - // In v5, nested routes are rendered by the child component, so - // you have elements all over your app for nested UI. - // You build nested routes and links using match.url and match.path. - let match = useRouteMatch(); - - return ( -
- - - - - - - - - - -
- ); -} -``` - -This is the same app in v6: - -```js -// This is a React Router v6 app -import { - BrowserRouter, - Routes, - Route, - Link, -} from "react-router-dom"; - -function App() { - return ( - - - } /> - } /> - - - ); -} - -function Users() { - return ( -
- - - - } /> - } /> - -
- ); -} -``` - -A few important things to notice about v6 in this example: - -- `` and `` are relative. This means that they - automatically build on the parent route's path and URL so you don't have to - manually interpolate `match.url` or `match.path` -- `` is gone. Instead, routes with descendant routes (defined in - other components) use a trailing `*` in their path to indicate they match - deeply -- You may put your routes in whatever order you wish and the router will - automatically detect the best route for the current URL. This prevents bugs - due to manually putting routes in the wrong order in a `` - -You may have also noticed that all `` from the v5 app changed to `` in v6. Assuming you followed the upgrade steps to v5.1, this should be as simple as moving your route element from the child position to a named `element` prop. - - - -### Advantages of `` - -In the section about upgrading to v5.1, we promised that we'd discuss the advantages of using regular elements instead of components (or element types) for rendering. Let's take a quick break from upgrading and talk about that now. - -For starters, we see React itself taking the lead here with the `}>` API. The `fallback` prop takes a React element, not a component. This lets you easily pass whatever props you want to your `` from the component that renders it. - -Using elements instead of components means we don't have to provide a `passProps`-style API so you can get the props you need to your elements. For example, in a component-based API there is no good way to pass props to the `` element that is rendered when `` matches. Most React libraries who take this approach end up with either an API like `` or use a render prop or higher-order component. - -Also, in case you didn't notice, in v4 and v5 `Route`'s rendering API became rather large. It went something like this: - -```js -// Ah, this is nice and simple! - - -// But wait, how do I pass custom props to the element?? -// Hmm, maybe we can use a render prop in those situations? - ( - - )} -/> - -// Ok, now we have two ways to render something with a route. :/ - -// But wait, what if we want to render something when a route -// *doesn't* match the URL, like a Not Found page? Maybe we -// can use another render prop with slightly different semantics? - ( - match ? ( - - ) : ( - - ) - )} -/> - -// What if I want to get access to the route match, or I need -// to redirect deeper in the tree? -function DeepComponent(routeStuff) { - // got routeStuff, phew! -} -export default withRouter(DeepComponent); - -// Well hey, now at least we've covered all our use cases! -// ... *facepalm* -``` - -At least part of the reason for this API sprawl was that React did not provide any way for us to get the information from the `` to your route element, so we had to invent clever ways to get both the route data **and** your own custom props through to your elements: `component`, render props, `passProps` higher-order-components ... until **hooks** came along! - -Now, the conversation above goes like this: - -```js -// Ah, nice and simple API. And it's just like the API! -// Nothing more to learn here. -} /> - -// But wait, how do I pass custom props to the -// element? Oh ya, it's just an element. Easy. -} /> - -// Ok, but how do I access the router's data, like the URL params -// or the current location? -function Profile({ animate }) { - let params = useParams(); - let location = useLocation(); -} - -// But what about components deep in the tree? -function DeepComponent() { - // oh right, same as anywhere else - let navigate = useNavigate(); -} - -// Aaaaaaaaand we're done here. -``` - -Another important reason for using the `element` prop in v6 is that `` is reserved for nesting routes. This is one of people's favorite features from v3 and `@reach/router`, and we're bringing it back in v6. Taking the code in the previous example one step further, we can hoist all `` elements into a single route config: - -```js -// This is a React Router v6 app -import { - BrowserRouter, - Routes, - Route, - Link, - Outlet, -} from "react-router-dom"; - -function App() { - return ( - - - } /> - }> - } /> - } /> - - - - ); -} - -function Users() { - return ( -
- - - -
- ); -} -``` - -This step is optional of course, but it's really nice for small to medium sized apps that don't have thousands of routes. - -Notice how `` elements nest naturally inside a `` element. Nested routes build their path by adding to the parent route's path. We didn't need a trailing `*` on `` this time because when the routes are defined in one spot the router is able to see all your nested routes. - -You'll only need the trailing `*` when there is another `` somewhere in that route's descendant tree. In that case, the descendant `` will match on the portion of the pathname that remains (see the previous example for what this looks like in practice). - -When using a nested config, routes with `children` should render an `` in order to render their child routes. This makes it easy to render layouts with nested UI. - -### Note on `` patterns - -React Router v6 uses a simplified path format. `` in v6 supports only 2 kinds of placeholders: dynamic `:id`-style params and `*` wildcards. A `*` wildcard may be used only at the end of a path, not in the middle. - -All of the following are valid route paths in v6: - -``` -/groups -/groups/admin -/users/:id -/users/:id/messages -/files/* -/files/:id/* -``` - -The following RegExp-style route paths are **not valid** in v6: - -``` -/users/:id? -/tweets/:id(\d+) -/files/*/cat.jpg -/files-* -``` - -We added the dependency on path-to-regexp in v4 to enable more advanced pattern matching. In v6 we are using a simpler syntax that allows us to predictably parse the path for ranking purposes. It also means we can stop depending on path-to-regexp, which is nice for bundle size. - -If you were using any of path-to-regexp's more advanced syntax, you'll have to remove it and simplify your route paths. If you were using the RegExp syntax to do URL param validation (e.g. to ensure an id is all numeric characters) please know that we plan to add some more advanced param validation in v6 at some point. For now, you'll need to move that logic to the component the route renders, and let it branch its rendered tree after you parse the params. - -If you were using `` you should move it to its containing `` prop. Either all routes in a `` element are case-sensitive or they are not. - -One other thing to notice is that all path matching in v6 ignores the trailing slash on the URL. In fact, `` has been removed and has no effect in v6. **This does not mean that you can't use trailing slashes if you need to.** Your app can decide to use trailing slashes or not, you just can't render two different UIs _client-side_ at `` and ``. You can still render two different UIs at those URLs (though we wouldn't recommend it), but you'll have to do it server-side. - -### Note on `` values - -In v5, a `` value that does not begin with `/` was ambiguous; it depends on what the current URL is. For example, if the current URL is `/users`, a v5 `` would render a ``. However, if the current URL has a trailing slash, like `/users/`, the same `` would render ``. This makes it difficult to predict how links will behave, so in v5 we recommended that you build links from the root URL (using `match.url`) and not use relative `` values. - -React Router v6 fixes this ambiguity. In v6, a `` will always render the same ``, regardless of the current URL. - -For example, a `` that is rendered inside a `` will always render a link to `/users/me`, regardless of whether or not the current URL has a trailing slash. - -When you'd like to link back "up" to parent routes, use a leading `..` segment in your `` value, similar to what you'd do in a ``. - -```tsx -function App() { - return ( - - }> - } /> - - - ); -} - -function Users() { - return ( -
-

- {/* This links to /users - the current route */} - Users -

- -
    - {users.map((user) => ( -
  • - {/* This links to /users/:id - the child route */} - {user.name} -
  • - ))} -
-
- ); -} - -function UserProfile() { - return ( -
-

- {/* This links to /users - the parent route */} - All Users -

- -

- {/* This links to /users/:id - the current route */} - User Profile -

- -

- {/* This links to /users/mj - a "sibling" route */} - MJ -

-
- ); -} -``` - -It may help to think about the current URL as if it were a directory path on the filesystem and `` like the `cd` command line utility. - -``` -// If your routes look like this - - - - - - -// and the current URL is /app/dashboard (with or without -// a trailing slash) - =>
- => - => - => - -// On the command line, if the current directory is /app/dashboard -cd stats # pwd is /app/dashboard/stats -cd ../stats # pwd is /app/stats -cd ../../stats # pwd is /stats -cd ../../../stats # pwd is /stats -``` - -**Note**: The decision to ignore trailing slashes while matching and creating relative paths was not taken lightly by our team. We consulted with a number of our friends and clients (who are also our friends!) about it. We found that most of us don't even understand how plain HTML relative links are handled with the trailing slash. Most people guessed it worked like `cd` on the command line (it does not). Also, HTML relative links don't have the concept of nested routes, they only worked on the URL, so we had to blaze our own trail here a bit. `@reach/router` set this precedent and it has worked out well for a couple of years. - -In addition to ignoring trailing slashes in the current URL, it is important to note that `` will not always behave like `` when your `` matches more than one segment of the URL. Instead of removing just one segment of the URL, **it will resolve based upon the parent route's path, essentially removing all path segments specified by that route**. - -```tsx -function App() { - return ( - - - - } - /> - - - ); -} -``` - -This may seem like an odd choice, to make `..` operate on routes instead of URL segments, but it's a **huge** help when working with `*` routes where an indeterminate number of segments may be matched by the `*`. In these scenarios, a single `..` segment in your `` value can essentially remove anything matched by the `*`, which lets you create more predictable links in `*` routes. - -```tsx -function App() { - return ( - - - } /> - - } - /> - - - ); -} -``` - -## Pass `` state as separate prop - -The `Link` component in v6 accepts `state` as a separate prop instead of receiving it as part of the object passed to `to` so you'll need to update your `Link` components if they are using `state`: - -```js -import { Link } from "react-router-dom"; - -// Change this: - - -// to this: - -``` - -The state value is still retrieved in the linked component using `useLocation()`: - -```js -function Home() { - const location = useLocation(); - const state = location.state; - return
Home
; -} -``` - -## Use `useRoutes` instead of `react-router-config` - -All of the functionality from v5's `react-router-config` package has moved into core in v6. If you prefer/need to define your routes as JavaScript objects instead of using React elements, you're going to love this. - -```js -function App() { - let element = useRoutes([ - // These are the same as the props you provide to - { path: "/", element: }, - { path: "dashboard", element: }, - { - path: "invoices", - element: , - // Nested routes use a children property, which is also - // the same as - children: [ - { path: ":id", element: }, - { path: "sent", element: }, - ], - }, - // Not found routes work as you'd expect - { path: "*", element: }, - ]); - - // The returned element will render the entire element - // hierarchy with all the appropriate context it needs - return element; -} -``` - -Routes defined in this way follow all of the same semantics as ``. In fact, `` is really just a wrapper around `useRoutes`. - -We encourage you to give both `` and `useRoutes` a shot and decide for yourself which one you prefer to use. Honestly, we like and use them both. - -If you had cooked up some of your own logic around data fetching and rendering server-side, we have a low-level `matchRoutes` function available as well similar to the one we had in react-router-config. - -## Use `useNavigate` instead of `useHistory` - -React Router v6 introduces a new navigation API that is synonymous with `` and provides better compatibility with suspense-enabled apps. We include both imperative and declarative versions of this API depending on your style and needs. - -```js -// This is a React Router v5 app -import { useHistory } from "react-router-dom"; - -function App() { - let history = useHistory(); - function handleClick() { - history.push("/home"); - } - return ( -
- -
- ); -} -``` - -In v6, this app should be rewritten to use the `navigate` API. Most of the time this means changing `useHistory` to `useNavigate` and changing the `history.push` or `history.replace` callsite. - -```js -// This is a React Router v6 app -import { useNavigate } from "react-router-dom"; - -function App() { - let navigate = useNavigate(); - function handleClick() { - navigate("/home"); - } - return ( -
- -
- ); -} -``` - -If you need to replace the current location instead of push a new one onto the history stack, use `navigate(to, { replace: true })`. If you need state, use `navigate(to, { state })`. You can think of the first argument to `navigate` as your `` and the other arguments as the `replace` and `state` props. - -If you prefer to use a declarative API for navigation (ala v5's `Redirect` component), v6 provides a `Navigate` component. Use it like: - -```js -import { Navigate } from "react-router-dom"; - -function App() { - return ; -} -``` - -**Note**: Be aware that the v5 `` uses `replace` logic by default (you may change it via `push` prop), on the other hand, the v6 `` uses `push` logic by default and you may change it via `replace` prop. - -```js -// Change this: - - - -// to this: - - -``` - -If you're currently using `go`, `goBack` or `goForward` from `useHistory` to navigate backwards and forwards, you should also replace these with `navigate` with a numerical argument indicating where to move the pointer in the history stack. For example, here is some code using v5's `useHistory` hook: - -```js -// This is a React Router v5 app -import { useHistory } from "react-router-dom"; - -function App() { - const { go, goBack, goForward } = useHistory(); - - return ( - <> - - - - - - ); -} -``` - -Here is the equivalent app with v6: - -```js -// This is a React Router v6 app -import { useNavigate } from "react-router-dom"; - -function App() { - const navigate = useNavigate(); - - return ( - <> - - - - - - ); -} -``` - -Again, one of the main reasons we are moving from using the `history` API directly to the `navigate` API is to provide better compatibility with React suspense. React Router v6 uses the `useNavigation` hook at the root of your component hierarchy. This lets us provide a smoother experience when user interaction needs to interrupt a pending route navigation, for example when they click a link to another route while a previously-clicked link is still loading. The `navigate` API is aware of the internal pending navigation state and will do a REPLACE instead of a PUSH onto the history stack, so the user doesn't end up with pages in their history that never actually loaded. - -_Note: The `` element from v5 is no longer supported as part of your route config (inside a ``). This is due to upcoming changes in React that make it unsafe to alter the state of the router during the initial render. If you need to redirect immediately, you can either a) do it on your server (probably the best solution) or b) render a `` element in your route component. However, recognize that the navigation will happen in a `useEffect`._ - -Aside from suspense compatibility, `navigate`, like `Link`, supports relative navigation. For example: - -```jsx -// assuming we are at `/stuff` -function SomeForm() { - let navigate = useNavigate(); - return ( -
{ - let newRecord = await saveDataFromForm( - event.target - ); - // you can build up the URL yourself - navigate(`/stuff/${newRecord.id}`); - // or navigate relative, just like Link - navigate(`${newRecord.id}`); - }} - > - {/* ... */} -
- ); -} -``` - -## Remove `` `component` prop - -`` no longer supports the `component` prop for overriding the returned anchor tag. There are a few reasons for this. - -First of all, a `` should pretty much always render an `
`. If yours does not, there's a good chance your app has some serious accessibility and usability problems, and that's no good. The browsers give us a lot of nice usability features with `` and we want your users to get those for free! - -That being said, maybe your app uses a CSS-in-JS library, or maybe you have a custom, fancy link component already in your design system that you'd like to render instead. The `component` prop may have worked well enough in a world before hooks, but now you can create your very own accessible `Link` component with just a few of our hooks: - -```tsx -import { FancyPantsLink } from "@fancy-pants/design-system"; -import { - useHref, - useLinkClickHandler, -} from "react-router-dom"; - -const Link = React.forwardRef( - ( - { - onClick, - replace = false, - state, - target, - to, - ...rest - }, - ref - ) => { - let href = useHref(to); - let handleClick = useLinkClickHandler(to, { - replace, - state, - target, - }); - - return ( - { - onClick?.(event); - if (!event.defaultPrevented) { - handleClick(event); - } - }} - ref={ref} - target={target} - /> - ); - } -); -``` - -If you're using `react-router-native`, we provide `useLinkPressHandler` that works basically the same way. Just call that hook's returned function in your `Link`'s `onPress` handler and you're all set. - -## Rename `` to `` - -This is a simple renaming of a prop to better align with the common practices of other libraries in the React ecosystem. - -## Remove `activeClassName` and `activeStyle` props from `` - -As of `v6.0.0-beta.3`, the `activeClassName` and `activeStyle` props have been removed from `NavLinkProps`. Instead, you can pass a function to either `style` or `className` that will allow you to customize the inline styling or the class string based on the component's active state. - -```diff tsx - ({ color: isActive ? 'green' : 'blue' })} -> - Messages - -``` - -```diff tsx - "nav-link" + (isActive ? " activated" : "")} -> - Messages - -``` - -If you prefer to keep the v5 props, you can create your own `` as a wrapper component for a smoother upgrade path. - -```tsx -import * as React from "react"; -import { NavLink as BaseNavLink } from "react-router-dom"; - -const NavLink = React.forwardRef( - ({ activeClassName, activeStyle, ...props }, ref) => { - return ( - - [ - props.className, - isActive ? activeClassName : null, - ] - .filter(Boolean) - .join(" ") - } - style={({ isActive }) => ({ - ...props.style, - ...(isActive ? activeStyle : null), - })} - /> - ); - } -); -``` - -## Get `StaticRouter` from `react-router-dom/server` - -The `StaticRouter` component has moved into a new bundle: `react-router-dom/server`. - -```js -// change -import { StaticRouter } from "react-router-dom"; -// to -import { StaticRouter } from "react-router-dom/server"; -``` - -This change was made both to follow more closely the convention established by the `react-dom` package and to help users understand better what a `` is for and when it should be used (on the server). - -## Replace `useRouteMatch` with `useMatch` - -`useMatch` is very similar to v5's `useRouteMatch`, with a few key differences: - -- It uses our new [path pattern matching algorithm](#note-on-route-path-patterns) -- The pattern argument is now required -- No longer accepts an array of patterns -- When passing a pattern as an object, some of the options have been renamed to better align with other APIs in v6 - - `useRouteMatch({ strict })` is now `useMatch({ end })` - - `useRouteMatch({ sensitive })` is now `useMatch({ caseSensitive })` -- It returns a match object with a different shape - -To see the exact API of the new `useMatch` hook and its type declaration, check out our [API Reference](../hooks/use-match). - - - -## Change the order of arguments passed to `matchPath`. Change pathPattern options. - -Since version 6 the order of arguments passed to `matchPath` function has changed. Also pattern options has changed. - -- first argument is pathPattern object, then comes pathname -- pathPattern doesn't include `exact` and `strict` options any more. New `caseSensitive` and `end` options has been added. - -Please refactor it as follows: - -Before: - -```js -// This is a React Router v5 app -import { matchPath } from "react-router-dom"; - -const match = matchPath("/users/123", { - path: "/users/:id", - exact: true, // Optional, defaults to false - strict: false, // Optional, defaults to false -}); -``` - -After: - -```js -// This is a React Router v6 app -import { matchPath } from "react-router-dom"; - -const match = matchPath( - { - path: "/users/:id", - caseSensitive: false, // Optional, `true` == static parts of `path` should match case - end: true, // Optional, `true` == pattern should match the entire URL pathname - }, - "/users/123" -); -``` - -## `` is not currently supported - -`` from v5 (along with `usePrompt` and `useBlocker` from the v6 betas) are not included in the current released version of v6. We decided we'd rather ship with what we have than take even more time to nail down a feature that isn't fully baked. We will absolutely be working on adding this back in to v6 at some point in the near future, but not for our first stable release of 6.x. - -We have since added implementations for [`useBlocker`][useblocker] and [`unstable_usePrompt`][useprompt] that you can use instead of `` - -## What did we miss? - -Despite our best attempts at being thorough, it's very likely that we missed something. If you follow this upgrade guide and find that to be the case, please let us know. We are happy to help you figure out what to do with your v5 code to be able to upgrade and take advantage of all of the cool stuff in v6. - -Good luck ๐Ÿค˜ - -[useblocker]: ../hooks/use-blocker -[useprompt]: ../hooks/use-prompt diff --git a/docs/upgrading/v6-data.md b/docs/upgrading/v6-data.md deleted file mode 100644 index cb92d4b7f9..0000000000 --- a/docs/upgrading/v6-data.md +++ /dev/null @@ -1,280 +0,0 @@ ---- -title: Migrating to RouterProvider -order: 1 ---- - -# Migrating to RouterProvider - -When we originally began bringing the [Remix Data APIs over to React Router][remixing-react-router] we realized that they brought about a pretty different way of structuring your routes. Instead of discovering routes via the [``][routes-component] component _as React rendered the component tree_, we needed to _lift_ the route definitions so we could [decouple fetching from rendering][when-to-fetch]. - -This brought about an interesting conundrum. We've got tons of v6 [`BrowserRouter`][browserrouter] apps out there happily defining their routes via `` components -- how can we provide them with a smooth upgrade experience that doesn't require a big-bang migration to the new approach? This ruled out a new major version and we focused on adding these new features in a _fully backwards compatible_ way that would provide users an _incremental_ upgrade path from [`BrowserRouter`][browserrouter] to [`RouterProvider`][routerprovider]. - -## Differences - -The first thing to be aware of is the presence of a handful of new [Data APIs][data-apis] that only work on routes _defined_ via the new [data routers][picking-a-router] (i.e., [`createBrowserRouter`][createbrowserrouter]). These include a few categories of APIs: - -- Route-level data APIs such as `loader`, `action`, `shouldRevalidate`, `handle`, and `lazy` -- In-component data hooks such as `useLoaderData`, `useActionData`, `useFetcher`, `useMatches`, `useNavigation`, etc. -- Error-handling APIs such as `route.errorElement`, `route.ErrorBoundary`, and `useRouteError` - -The rest of the APIs that existed prior to v6.4.0 are still usable in _both_ `BrowserRouter` and `RouterProvider` apps. These include common hooks/components such as `useNavigate`, `useLocation`, `useParams`, ``, ``, etc. - -## Migrating - -We built the new `` component such that it would enable the new Data APIs on routes defined at the root router, while not excluding descendant `` trees so commonly used in `BrowserRouter` apps. This was explicitly to allow incremental migration from one to the other. Let's take a look at how we would do this. - -### Current Application - -Let's assume we've got a current application with 2 descendant route trees, and assume these routes are all doing in-component data fetching, and rendering their own loading and error states. - -```tsx -import { - BrowserRouter, - Link, - Route, - Routes, -} from "react-router-dom"; - -export default function App() { - return ( - - - } /> - } /> - } /> - - - ); -} - -function Home() { - return ( - <> -

Welcome!

-

- Check out the blog or the{" "} - users section -

- - ); -} - -function BlogApp() { - return ( - - Blog Index} /> - Blog Posts} /> - - ); -} - -function UserApp() { - return ( - - Users Index} /> - - ); -} -``` - -### Add RouterProvider with a root splat route - -We can render this application inside a `RouterProvider` with only a few small changes: - -1. Change your current `App` component to `Root` -2. Remove the `` component -3. Create a data router singleton with a splat route for the `Root` element -4. Add a new `App` component rendering a `` - -```tsx lines=[9-12,14-17,19-20,21-22] -import { - createBrowserRouter, - Link, - Route, - RouterProvider, - Routes, -} from "react-router-dom"; - -// 3๏ธโƒฃ Router singleton created -const router = createBrowserRouter([ - { path: "*", element: }, -]); - -// 4๏ธโƒฃ RouterProvider added -export default function App() { - return ; -} - -// 1๏ธโƒฃ Changed from App to Root -function Root() { - // 2๏ธโƒฃ `BrowserRouter` component removed, but the / - // component below are unchanged - return ( - - } /> - } /> - } /> - - ); -} - -function Home() { - /* Unchanged */ -} -function BlogApp() { - /* Unchanged */ -} -function UserApp() { - /* Unchanged */ -} -``` - -๐Ÿฅณ Congrats - you're now rendering a data router app! But wait a minute - we can't use any of the new stuff yet since none of our routes are defined at the top with `createBrowserRouter` ๐Ÿ˜ข. To access the new APIs, we need to start lifting routes one-by-one to the data router. - -### Start lifting routes and leveraging the data APIs - -Let's start with the `/` route for the `` element. All we need to do is lift the `` definition up to the data router: - -```tsx lines=[2,13] -const router = createBrowserRouter([ - { path: "/", element: }, // ๐Ÿ†• - { path: "*", element: }, -]); - -export default function App() { - return ; -} - -function Root() { - return ( - - {/* โฌ†๏ธ Home route lifted up to the data router */} - } /> - } /> - - ); -} -``` - -Now you can add data APIs to your home route (`loader`, `action`, `errorElement`) and start leveraging data hooks inside your Home component (`useLoaderData`, `useActionData`, `useFetcher`, `
`, etc.). - -Now let's look at lifting the Blog App upwards, but still doing it one leaf route at a time. In order to lift the `/blog` index route up, we need the `/blog/*` splat route lifted as well, but we can still render the `/blog/posts` route where it is and do that separately. - -```tsx lines=[3-12,23,32] -const router = createBrowserRouter([ - { path: "/", element: }, - { - // Lifted blog splat route - path: "/blog/*", - children: [ - // New blog index route - { index: true, element:

Blog Index

}, - // Blog subapp splat route added for /blog/posts matching - { path: "*", element: }, - ], - }, - { path: "*", element: }, -]); - -export default function App() { - return ; -} - -function Root() { - return ( - - {/* โฌ†๏ธ Blog splat route lifted */} - } /> - - ); -} - -function BlogApp() { - return ( - - {/* โฌ†๏ธ Blog index route lifted */} - Blog Posts} /> - - ); -} -``` - -And now your blog index route can participate in data loading. - -You can keep doing this one route at a time until you've eventually converted all of your routes to data routes and can no longer use any nested `` to define your routing tree. To avoid bundle bloat, it's recommended to leverage the [route.lazy][route-lazy] prop to lazily load your routes. - -## FAQ - -### But I've got stuff between `` and `` - -Many folks render an app shell around their `` via something like the following: - -```jsx -export default function App() { - return ( - -
-

My Super Cool App

- -
-
- - } /> - } /> - } /> - -
-
ยฉ๏ธ me 2023
-
- ); -} -``` - -If you find yourself in this situation, don't worry - there's a straightforward solution you can do _before_ starting the above migration. - -This is quite common but poses a problem in the above migration approach since we need to lift things to `RouterProvider` route-by-route, but this "app shell" stuff isn't part of a route...but it could be! That "app shell" is really nothing more than a layout route with an ``! So before starting the above migration, just move this "app shell" into a pathless layout route around your routes as follows: - -```jsx lines=[6,25] -export default function App() { - return ( - - - {/* 1๏ธโƒฃ Wrap your routes in a pathless layout route */} - }> - } /> - } /> - } /> - - - - ); -} - -function Layout() { - return ( - <> -
-

My Super Cool App

- -
-
- {/* 2๏ธโƒฃ Render the app routes via the Layout Outlet */} - -
-
ยฉ๏ธ me 2023
- - ); -} -``` - -Once you've done that, you can proceed with the above migration strategy and start lifting routes into your `RouterProvider` one-by-one. You'll likely want to lift the layout route first so all of the children can nest inside of it. - -[remixing-react-router]: https://remix.run/blog/remixing-react-router -[when-to-fetch]: https://www.youtube.com/watch?v=95B8mnhzoCM -[picking-a-router]: ../routers/picking-a-router -[data-apis]: ../routers/picking-a-router#data-apis -[createbrowserrouter]: ../routers/create-browser-router -[routerprovider]: ../routers/router-provider -[browserrouter]: ../router-components/browser-router -[routes-component]: ../components/routes -[route-lazy]: ../route/lazy diff --git a/docs/upgrading/future.md b/docs/upgrading/v6.md similarity index 75% rename from docs/upgrading/future.md rename to docs/upgrading/v6.md index 67759468c1..0e7a69be7b 100644 --- a/docs/upgrading/future.md +++ b/docs/upgrading/v6.md @@ -1,16 +1,15 @@ --- -title: Current Future Flags +title: Upgrading from v6 order: 1 -new: true --- -# Future Flags +# Upgrading from v6 -The following future flags are stable and ready to adopt. To read more about future flags see [Development Strategy](../guides/api-development-strategy) +The v7 upgrade is non-breaking if you are caught up on all future flags. These flags allow you to update your app one change at a time. We highly recommend you make a commit after each step and ship it instead of doing everything all at once. ## Update to latest v6.x -First update to the latest minor version of v6.x to have the latest future flags. +First update to the latest minor version of v6.x to have the latest future flags and console warnings. ๐Ÿ‘‰ **Update to latest v6** @@ -18,7 +17,7 @@ First update to the latest minor version of v6.x to have the latest future flags npm install react-router-dom@6 ``` -## v7_relativeSplatPath +### v7_relativeSplatPath **Background** @@ -46,7 +45,7 @@ createBrowserRouter(routes, { **Update your Code** -If you have any routes with a path + a splat like `` and has relative links like `` or `` beneath it, you will need to update your code. +If you have any routes with a path + a splat like `` that have relative links like `` or `` beneath them, you will need to update your code. ๐Ÿ‘‰ **Split the `` into two** @@ -104,7 +103,7 @@ function Dashboard() { } ``` -## v7_startTransition +### v7_startTransition **Background** @@ -133,9 +132,9 @@ You don't need to update anything unless you are using `React.lazy` _inside_ of Using `React.lazy` inside of a component is incompatible with `React.useTransition` (or other code that makes promises inside of components). Move `React.lazy` to the module scope and stop making promises inside of components. This is not a limitation of React Router but rather incorrect usage of React. -## v7_fetcherPersist +### v7_fetcherPersist -If you are not using a `createBrowserRouter` you can skip this +If you are not using a `` you can skip this **Background** @@ -155,9 +154,9 @@ createBrowserRouter(routes, { It's unlikely to affect your app. You may want to check any usage of `useFetchers` as they may persist longer than they did before. Depending on what you're doing, you may render something longer than before. -## v7_normalizeFormMethod +### v7_normalizeFormMethod -If you are not using a `createBrowserRouter` you can skip this +If you are not using a `` you can skip this This normalizes `formMethod` fields as uppercase HTTP methods to align with the `fetch()` behavior. [View the CHANGELOG](https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#futurev7_normalizeformmethod) for more information. @@ -184,9 +183,9 @@ If any of your code is checking for lowercase HTTP methods, you will need to upd +useFetcher().formMethod === "GET"; ``` -## v7_partialHydration +### v7_partialHydration -If you are not using a `createBrowserRouter` you can skip this +If you are not using a `` you can skip this This allows SSR frameworks to provide only partial hydration data. It's unlikely you need to worry about this, just turn the flag on. [View the CHANGELOG](https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#partial-hydration) for more information. @@ -227,7 +226,7 @@ const router = createBrowserRouter( /> ``` -## v7_skipActionErrorRevalidation +### v7_skipActionErrorRevalidation If you are not using a `createBrowserRouter` you can skip this @@ -293,3 +292,52 @@ function shouldRevalidate({ actionStatus, defaultShouldRevalidate }) { return defaultShouldRevalidate; } ``` + +## Upgrade to v7 + +Now that your app is caught up, you can simply update to v7 (theoretically!) without issue. + +๐Ÿ‘‰ **Install v7** + +```shellscript nonumber +npm install react-router-dom@latest +``` + +๐Ÿ‘‰ **Uninstall react-router-dom, install react-router** + +In v7 we no longer need `"react-router-dom"` as the packages have been simplified. You can import everything from `"react-router"`: + +```shellscript nonumber +npm uninstall react-router-dom +npm install react-router@latest +``` + +Note you only need `"react-router"` in your package.json. + +๐Ÿ‘‰ **Update imports** + +Now you can update you imports to use `react-router`: + +```diff +-import { useLocation } from "react-router-dom"; ++import { useLocation } from "react-router"; +``` + +Instead of manually updating imports, you can use this command. Make sure your git working tree is clean though so you can revert if it doesn't work as expected. + +```shellscript nonumber +find ./path/to/src \( -name "*.tsx" -o -name "*.ts" -o -name "*.js" -o -name "*.jsx" \) -type f -exec sed -i '' 's|from "react-router-dom"|from "react-router"|g' {} + +``` + +๐Ÿ‘‰ **Update DOM-specific imports** + +`RouterProvider` and `HydratedRouter` come from a deep import because they depend on `"react-dom"`: + +```diff +-import { RouterProvider } from "react-router-dom"; ++import { RouterProvider } from "react-router/dom"; +``` + +Congratulations, you're now on v7! + +[react-flushsync]: https://react.dev/reference/react-dom/flushSync diff --git a/docs/utils/create-routes-from-children.md b/docs/utils/create-routes-from-children.md deleted file mode 100644 index ebd7d438c4..0000000000 --- a/docs/utils/create-routes-from-children.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: createRoutesFromChildren ---- - -# `createRoutesFromChildren` - -Alias for [`createRoutesFromElements`][createroutesfromelements] - -[createroutesfromelements]: ./create-routes-from-elements diff --git a/docs/utils/create-routes-from-elements.md b/docs/utils/create-routes-from-elements.md deleted file mode 100644 index f6dcb7a9ab..0000000000 --- a/docs/utils/create-routes-from-elements.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: createRoutesFromElements ---- - -# `createRoutesFromElements` - -`createRoutesFromElements` is a helper that creates route objects from `` elements. It's useful if you prefer to create your routes as JSX instead of objects. - -```jsx -import { - createBrowserRouter, - createRoutesFromElements, - RouterProvider, - Route, -} from "react-router-dom"; - -// You can do this: -const router = createBrowserRouter( - createRoutesFromElements( - }> - } /> - } /> - - ) -); - -// Instead of this: -const router = createBrowserRouter([ - { - path: "/", - element: , - children: [ - { - path: "dashboard", - element: , - }, - { - path: "about", - element: , - }, - ], - }, -]); -``` - -It's also used internally by [``][routes] to generate a route objects from its [``][route] children. - -## Type declaration - -```tsx -declare function createRoutesFromElements( - children: React.ReactNode -): RouteObject[]; - -interface RouteObject { - caseSensitive?: boolean; - children?: RouteObject[]; - element?: React.ReactNode; - index?: boolean; - path?: string; -} -``` - -[routes]: ../components/routes -[route]: ../components/route diff --git a/docs/utils/create-search-params.md b/docs/utils/create-search-params.md deleted file mode 100644 index 0167562b65..0000000000 --- a/docs/utils/create-search-params.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: createSearchParams ---- - -# `createSearchParams` - -
- Type declaration - -```tsx -declare function createSearchParams( - init?: URLSearchParamsInit -): URLSearchParams; -``` - -
- -`createSearchParams` is a thin wrapper around [`new URLSearchParams(init)`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams/URLSearchParams) that adds support for using objects with array values. This is the same function that `useSearchParams` uses internally for creating `URLSearchParams` objects from `URLSearchParamsInit` values. diff --git a/docs/utils/defer.md b/docs/utils/defer.md deleted file mode 100644 index e101d225f9..0000000000 --- a/docs/utils/defer.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: defer -new: true ---- - -# `defer` - -This utility allows you to defer values returned from loaders by passing promises instead of resolved values. - -```jsx -async function loader() { - let product = await getProduct(); - let reviews = getProductReviews(); - return defer({ product, reviews }); -} -``` - -See the [Deferred Guide][deferred guide] for more information. - -[deferred guide]: ../guides/deferred diff --git a/docs/utils/generate-path.md b/docs/utils/generate-path.md deleted file mode 100644 index 48a73f33b6..0000000000 --- a/docs/utils/generate-path.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -title: generatePath ---- - -# `generatePath` - -
- Type declaration - -```tsx -declare function generatePath( - path: Path, - params?: { - [key in PathParams]: string; - } -): string; -``` - -
- -`generatePath` interpolates a set of params into a route path string with `:id` and `*` placeholders. This can be useful when you want to eliminate placeholders from a route path so it matches statically instead of using a dynamic parameter. - -```tsx -generatePath("/users/:id", { id: "42" }); // "/users/42" -generatePath("/files/:type/*", { - type: "img", - "*": "cat.jpg", -}); // "/files/img/cat.jpg" -``` diff --git a/docs/utils/index.md b/docs/utils/index.md deleted file mode 100644 index 1df84691ee..0000000000 --- a/docs/utils/index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Utilities -order: 9 ---- diff --git a/docs/utils/is-route-error-response.md b/docs/utils/is-route-error-response.md deleted file mode 100644 index f890b60134..0000000000 --- a/docs/utils/is-route-error-response.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -title: isRouteErrorResponse -new: true ---- - -# `isRouteErrorResponse` - -This returns `true` if a [route error][routeerror] is a _route error response_. - -```jsx -import { isRouteErrorResponse } from "react-router-dom"; - -function ErrorBoundary() { - const error = useRouteError(); - if (isRouteErrorResponse(error)) { - return ( -
-

Oops!

-

{error.status}

-

{error.statusText}

- {error.data?.message &&

{error.data.message}

} -
- ); - } else { - return
Oops
; - } -} -``` - -When a response is thrown from an action or loader, it will be unwrapped into an `ErrorResponse` so that your component doesn't have to deal with the complexity of unwrapping it (which would require React state and effects to deal with the promise returned from `res.json()`) - -```jsx -import { json } from "react-router-dom"; - -} - action={() => { - throw json( - { message: "email is required" }, - { status: 400 } - ); - }} -/>; - -function ErrorBoundary() { - const error = useRouteError(); - if (isRouteErrorResponse(error)) { - error.status; // 400 - error.data; // { "message: "email is required" } - } -} -``` - -If the user visits a route that does not match any routes in the app, React Router itself will throw a 404 response. - -[routeerror]: ../hooks/use-route-error diff --git a/docs/utils/location.md b/docs/utils/location.md deleted file mode 100644 index b90cb63c7c..0000000000 --- a/docs/utils/location.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Location ---- - -# `Location` - -The term "location" in React Router refers to [the `Location` interface](https://github.com/remix-run/history/blob/main/docs/api-reference.md#location) from the [history](https://github.com/remix-run/history) library. - -The `history` package is React Router's only dependency and many of the core types in React Router come directly from that library including `Location`, `To`, `Path`, and others. You can read more about the history library in [its documentation](https://github.com/remix-run/history/tree/main/docs). diff --git a/docs/utils/match-path.md b/docs/utils/match-path.md deleted file mode 100644 index e0627761fb..0000000000 --- a/docs/utils/match-path.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: matchPath ---- - -# `matchPath` - -
- Type declaration - -```tsx -declare function matchPath< - ParamKey extends string = string ->( - pattern: PathPattern | string, - pathname: string -): PathMatch | null; - -interface PathMatch { - params: Params; - pathname: string; - pattern: PathPattern; -} - -interface PathPattern { - path: string; - caseSensitive?: boolean; - end?: boolean; -} -``` - -
- -`matchPath` matches a route path pattern against a URL pathname and returns information about the match. This is useful whenever you need to manually run the router's matching algorithm to determine if a route path matches or not. It returns `null` if the pattern does not match the given pathname. - -The [`useMatch` hook][usematch] uses this function internally to match a route path relative to the current location. - -[usematch]: ../hooks/use-match diff --git a/docs/utils/match-routes.md b/docs/utils/match-routes.md deleted file mode 100644 index 49094d087e..0000000000 --- a/docs/utils/match-routes.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: matchRoutes ---- - -# `matchRoutes` - -
- Type declaration - -```tsx -declare function matchRoutes( - routes: RouteObject[], - location: Partial | string, - basename?: string -): RouteMatch[] | null; - -interface RouteMatch { - params: Params; - pathname: string; - route: RouteObject; -} -``` - -
- -`matchRoutes` runs the route matching algorithm for a set of routes against a given [`location`][location] to see which routes (if any) match. If it finds a match, an array of `RouteMatch` objects is returned, one for each route that matched. - -This is the heart of React Router's matching algorithm. It is used internally by [`useRoutes`][useroutes] and the [`` component][routes] to determine which routes match the current location. It can also be useful in some situations where you want to manually match a set of routes. - -[location]: ./location -[useroutes]: ../hooks/use-routes -[routes]: ../components/routes diff --git a/docs/utils/render-matches.md b/docs/utils/render-matches.md deleted file mode 100644 index 9a54232cfe..0000000000 --- a/docs/utils/render-matches.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: renderMatches ---- - -# `renderMatches` - -
- Type declaration - -```tsx -declare function renderMatches( - matches: RouteMatch[] | null -): React.ReactElement | null; -``` - -
- -`renderMatches` renders the result of `matchRoutes()` into a React element. diff --git a/docs/utils/resolve-path.md b/docs/utils/resolve-path.md deleted file mode 100644 index 3832f87073..0000000000 --- a/docs/utils/resolve-path.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: resolvePath ---- - -# `resolvePath` - -
- Type declaration - -```tsx -declare function resolvePath( - to: To, - fromPathname?: string -): Path; - -type To = string | Partial; - -interface Path { - pathname: string; - search: string; - hash: string; -} -``` - -
- -`resolvePath` resolves a given `To` value into an actual `Path` object with an absolute `pathname`. This is useful whenever you need to know the exact path for a relative `To` value. For example, the `` component uses this function to know the actual URL it points to. - -The [`useResolvedPath` hook][useresolvedpath] uses `resolvePath` internally to resolve the pathname. If `to` contains a pathname, it is resolved against the current route pathname. Otherwise, it is resolved against the current URL (`location.pathname`). - -[useresolvedpath]: ../hooks/use-resolved-path diff --git a/examples/auth-router-provider/vite.config.ts b/examples/auth-router-provider/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/auth-router-provider/vite.config.ts +++ b/examples/auth-router-provider/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/auth/vite.config.ts b/examples/auth/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/auth/vite.config.ts +++ b/examples/auth/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/basic-data-router/vite.config.ts b/examples/basic-data-router/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/basic-data-router/vite.config.ts +++ b/examples/basic-data-router/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/basic/vite.config.ts b/examples/basic/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/basic/vite.config.ts +++ b/examples/basic/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/custom-filter-link/vite.config.ts b/examples/custom-filter-link/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/custom-filter-link/vite.config.ts +++ b/examples/custom-filter-link/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/custom-link/vite.config.ts b/examples/custom-link/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/custom-link/vite.config.ts +++ b/examples/custom-link/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/custom-query-parsing/vite.config.ts b/examples/custom-query-parsing/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/custom-query-parsing/vite.config.ts +++ b/examples/custom-query-parsing/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/data-router/vite.config.ts b/examples/data-router/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/data-router/vite.config.ts +++ b/examples/data-router/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/error-boundaries/vite.config.ts b/examples/error-boundaries/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/error-boundaries/vite.config.ts +++ b/examples/error-boundaries/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/lazy-loading-router-provider/vite.config.ts b/examples/lazy-loading-router-provider/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/lazy-loading-router-provider/vite.config.ts +++ b/examples/lazy-loading-router-provider/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/lazy-loading/vite.config.ts b/examples/lazy-loading/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/lazy-loading/vite.config.ts +++ b/examples/lazy-loading/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/modal-route-with-outlet/vite.config.ts b/examples/modal-route-with-outlet/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/modal-route-with-outlet/vite.config.ts +++ b/examples/modal-route-with-outlet/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/modal/vite.config.ts b/examples/modal/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/modal/vite.config.ts +++ b/examples/modal/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/multi-app/vite.config.js b/examples/multi-app/vite.config.js index c6a7c72366..1b8e423e4f 100644 --- a/examples/multi-app/vite.config.js +++ b/examples/multi-app/vite.config.js @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -30,10 +29,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/navigation-blocking/vite.config.ts b/examples/navigation-blocking/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/navigation-blocking/vite.config.ts +++ b/examples/navigation-blocking/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/notes/vite.config.ts b/examples/notes/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/notes/vite.config.ts +++ b/examples/notes/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/route-objects/vite.config.ts b/examples/route-objects/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/route-objects/vite.config.ts +++ b/examples/route-objects/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/scroll-restoration/vite.config.ts b/examples/scroll-restoration/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/scroll-restoration/vite.config.ts +++ b/examples/scroll-restoration/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/search-params/vite.config.ts b/examples/search-params/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/search-params/vite.config.ts +++ b/examples/search-params/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/ssr-data-router/package-lock.json b/examples/ssr-data-router/package-lock.json index 5f6ab999b9..78bda52b7e 100644 --- a/examples/ssr-data-router/package-lock.json +++ b/examples/ssr-data-router/package-lock.json @@ -6,7 +6,6 @@ "": { "name": "ssr-data-router", "dependencies": { - "@remix-run/node": "^1.12.0", "@remix-run/router": "^1.8.0", "compression": "1.7.4", "cross-env": "^7.0.3", @@ -786,25 +785,6 @@ "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", "dev": true }, - "node_modules/@remix-run/node": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/node/-/node-1.16.1.tgz", - "integrity": "sha512-Qp9B2htm0bGG0iuxsqezDIl89uVSBZ8xfwq2aKWgRNm1FCa4/GRXzKmTo+sbBcacj7aYe+1r+0sIS6Q1sgaEnA==", - "dependencies": { - "@remix-run/server-runtime": "1.16.1", - "@remix-run/web-fetch": "^4.3.4", - "@remix-run/web-file": "^3.0.2", - "@remix-run/web-stream": "^1.0.3", - "@web3-storage/multipart-parser": "^1.0.0", - "abort-controller": "^3.0.0", - "cookie-signature": "^1.1.0", - "source-map-support": "^0.5.21", - "stream-slice": "^0.1.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@remix-run/router": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.8.0.tgz", @@ -813,79 +793,6 @@ "node": ">=14.0.0" } }, - "node_modules/@remix-run/server-runtime": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-1.16.1.tgz", - "integrity": "sha512-HG+f3PGE9kzTTPe5i5Hv7UGrJLmFID1Ae4BMohP5e0xXOxbdlKDPj6NN6yGDgE7OqKFuDVliW2B5LlUdJZgUFw==", - "dependencies": { - "@remix-run/router": "1.6.2", - "@web3-storage/multipart-parser": "^1.0.0", - "cookie": "^0.4.1", - "set-cookie-parser": "^2.4.8", - "source-map": "^0.7.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@remix-run/server-runtime/node_modules/@remix-run/router": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.6.2.tgz", - "integrity": "sha512-LzqpSrMK/3JBAVBI9u3NWtOhWNw5AMQfrUFYB0+bDHTSw17z++WJLsPsxAuK+oSddsxk4d7F/JcdDPM1M5YAhA==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@remix-run/web-blob": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@remix-run/web-blob/-/web-blob-3.0.4.tgz", - "integrity": "sha512-AfegzZvSSDc+LwnXV+SwROTrDtoLiPxeFW+jxgvtDAnkuCX1rrzmVJ6CzqZ1Ai0bVfmJadkG5GxtAfYclpPmgw==", - "dependencies": { - "@remix-run/web-stream": "^1.0.0", - "web-encoding": "1.1.5" - } - }, - "node_modules/@remix-run/web-fetch": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@remix-run/web-fetch/-/web-fetch-4.3.4.tgz", - "integrity": "sha512-AUM1XBa4hcgeNt2CD86OlB5aDLlqdMl0uJ+89R8dPGx07I5BwMXnbopCaPAkvSBIoHeT/IoLWIuZrLi7RvXS+Q==", - "dependencies": { - "@remix-run/web-blob": "^3.0.4", - "@remix-run/web-form-data": "^3.0.3", - "@remix-run/web-stream": "^1.0.3", - "@web3-storage/multipart-parser": "^1.0.0", - "abort-controller": "^3.0.0", - "data-uri-to-buffer": "^3.0.1", - "mrmime": "^1.0.0" - }, - "engines": { - "node": "^10.17 || >=12.3" - } - }, - "node_modules/@remix-run/web-file": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@remix-run/web-file/-/web-file-3.0.2.tgz", - "integrity": "sha512-eFC93Onh/rZ5kUNpCQersmBtxedGpaXK2/gsUl49BYSGK/DvuPu3l06vmquEDdcPaEuXcsdGP0L7zrmUqrqo4A==", - "dependencies": { - "@remix-run/web-blob": "^3.0.3" - } - }, - "node_modules/@remix-run/web-form-data": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@remix-run/web-form-data/-/web-form-data-3.0.4.tgz", - "integrity": "sha512-UMF1jg9Vu9CLOf8iHBdY74Mm3PUvMW8G/XZRJE56SxKaOFWGSWlfxfG+/a3boAgHFLTkP7K4H1PxlRugy1iQtw==", - "dependencies": { - "web-encoding": "1.1.5" - } - }, - "node_modules/@remix-run/web-stream": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@remix-run/web-stream/-/web-stream-1.0.3.tgz", - "integrity": "sha512-wlezlJaA5NF6SsNMiwQnnAW6tnPzQ5I8qk0Y0pSohm0eHKa2FQ1QhEKLVVcDDu02TmkfHgnux0igNfeYhDOXiA==", - "dependencies": { - "web-streams-polyfill": "^3.1.1" - } - }, "node_modules/@rollup/plugin-replace": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz", @@ -1073,28 +980,6 @@ "vite": "^4.1.0-beta.0" } }, - "node_modules/@web3-storage/multipart-parser": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@web3-storage/multipart-parser/-/multipart-parser-1.0.0.tgz", - "integrity": "sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==" - }, - "node_modules/@zxing/text-encoding": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", - "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", - "optional": true - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1124,17 +1009,6 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, - "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -1211,11 +1085,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, "node_modules/bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", @@ -1370,22 +1239,6 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, - "node_modules/cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.1.tgz", - "integrity": "sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==", - "engines": { - "node": ">=6.6.0" - } - }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -1422,14 +1275,6 @@ "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", "dev": true }, - "node_modules/data-uri-to-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz", - "integrity": "sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==", - "engines": { - "node": ">= 6" - } - }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -1557,14 +1402,6 @@ "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "engines": { - "node": ">=6" - } - }, "node_modules/express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -1681,14 +1518,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dependencies": { - "is-callable": "^1.1.3" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -1756,17 +1585,6 @@ "node": ">=4" } }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -1809,20 +1627,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/history": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", @@ -1870,64 +1674,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", - "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2045,14 +1791,6 @@ "node": ">= 0.6" } }, - "node_modules/mrmime": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", - "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -2406,11 +2144,6 @@ "node": ">= 0.8.0" } }, - "node_modules/set-cookie-parser": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", - "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2448,14 +2181,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "engines": { - "node": ">= 8" - } - }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", @@ -2465,23 +2190,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -2490,11 +2198,6 @@ "node": ">= 0.8" } }, - "node_modules/stream-slice": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/stream-slice/-/stream-slice-0.1.2.tgz", - "integrity": "sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==" - }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2587,18 +2290,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -2663,25 +2354,6 @@ } } }, - "node_modules/web-encoding": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", - "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", - "dependencies": { - "util": "^0.12.3" - }, - "optionalDependencies": { - "@zxing/text-encoding": "0.9.0" - } - }, - "node_modules/web-streams-polyfill": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", - "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", - "engines": { - "node": ">= 8" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2696,25 +2368,6 @@ "node": ">= 8" } }, - "node_modules/which-typed-array": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", - "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3183,93 +2836,11 @@ } } }, - "@remix-run/node": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/node/-/node-1.16.1.tgz", - "integrity": "sha512-Qp9B2htm0bGG0iuxsqezDIl89uVSBZ8xfwq2aKWgRNm1FCa4/GRXzKmTo+sbBcacj7aYe+1r+0sIS6Q1sgaEnA==", - "requires": { - "@remix-run/server-runtime": "1.16.1", - "@remix-run/web-fetch": "^4.3.4", - "@remix-run/web-file": "^3.0.2", - "@remix-run/web-stream": "^1.0.3", - "@web3-storage/multipart-parser": "^1.0.0", - "abort-controller": "^3.0.0", - "cookie-signature": "^1.1.0", - "source-map-support": "^0.5.21", - "stream-slice": "^0.1.2" - } - }, "@remix-run/router": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.8.0.tgz", "integrity": "sha512-mrfKqIHnSZRyIzBcanNJmVQELTnX+qagEDlcKO90RgRBVOZGSGvZKeDihTRfWcqoDn5N/NkUcwWTccnpN18Tfg==" }, - "@remix-run/server-runtime": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-1.16.1.tgz", - "integrity": "sha512-HG+f3PGE9kzTTPe5i5Hv7UGrJLmFID1Ae4BMohP5e0xXOxbdlKDPj6NN6yGDgE7OqKFuDVliW2B5LlUdJZgUFw==", - "requires": { - "@remix-run/router": "1.6.2", - "@web3-storage/multipart-parser": "^1.0.0", - "cookie": "^0.4.1", - "set-cookie-parser": "^2.4.8", - "source-map": "^0.7.3" - }, - "dependencies": { - "@remix-run/router": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.6.2.tgz", - "integrity": "sha512-LzqpSrMK/3JBAVBI9u3NWtOhWNw5AMQfrUFYB0+bDHTSw17z++WJLsPsxAuK+oSddsxk4d7F/JcdDPM1M5YAhA==" - } - } - }, - "@remix-run/web-blob": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@remix-run/web-blob/-/web-blob-3.0.4.tgz", - "integrity": "sha512-AfegzZvSSDc+LwnXV+SwROTrDtoLiPxeFW+jxgvtDAnkuCX1rrzmVJ6CzqZ1Ai0bVfmJadkG5GxtAfYclpPmgw==", - "requires": { - "@remix-run/web-stream": "^1.0.0", - "web-encoding": "1.1.5" - } - }, - "@remix-run/web-fetch": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/@remix-run/web-fetch/-/web-fetch-4.3.4.tgz", - "integrity": "sha512-AUM1XBa4hcgeNt2CD86OlB5aDLlqdMl0uJ+89R8dPGx07I5BwMXnbopCaPAkvSBIoHeT/IoLWIuZrLi7RvXS+Q==", - "requires": { - "@remix-run/web-blob": "^3.0.4", - "@remix-run/web-form-data": "^3.0.3", - "@remix-run/web-stream": "^1.0.3", - "@web3-storage/multipart-parser": "^1.0.0", - "abort-controller": "^3.0.0", - "data-uri-to-buffer": "^3.0.1", - "mrmime": "^1.0.0" - } - }, - "@remix-run/web-file": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@remix-run/web-file/-/web-file-3.0.2.tgz", - "integrity": "sha512-eFC93Onh/rZ5kUNpCQersmBtxedGpaXK2/gsUl49BYSGK/DvuPu3l06vmquEDdcPaEuXcsdGP0L7zrmUqrqo4A==", - "requires": { - "@remix-run/web-blob": "^3.0.3" - } - }, - "@remix-run/web-form-data": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@remix-run/web-form-data/-/web-form-data-3.0.4.tgz", - "integrity": "sha512-UMF1jg9Vu9CLOf8iHBdY74Mm3PUvMW8G/XZRJE56SxKaOFWGSWlfxfG+/a3boAgHFLTkP7K4H1PxlRugy1iQtw==", - "requires": { - "web-encoding": "1.1.5" - } - }, - "@remix-run/web-stream": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@remix-run/web-stream/-/web-stream-1.0.3.tgz", - "integrity": "sha512-wlezlJaA5NF6SsNMiwQnnAW6tnPzQ5I8qk0Y0pSohm0eHKa2FQ1QhEKLVVcDDu02TmkfHgnux0igNfeYhDOXiA==", - "requires": { - "web-streams-polyfill": "^3.1.1" - } - }, "@rollup/plugin-replace": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-5.0.2.tgz", @@ -3429,25 +3000,6 @@ "react-refresh": "^0.14.0" } }, - "@web3-storage/multipart-parser": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@web3-storage/multipart-parser/-/multipart-parser-1.0.0.tgz", - "integrity": "sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==" - }, - "@zxing/text-encoding": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz", - "integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==", - "optional": true - }, - "abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "requires": { - "event-target-shim": "^5.0.0" - } - }, "accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3471,11 +3023,6 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, - "available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==" - }, "body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -3527,11 +3074,6 @@ "update-browserslist-db": "^1.0.11" } }, - "buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, "bytes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", @@ -3641,16 +3183,6 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, - "cookie": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", - "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" - }, - "cookie-signature": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.1.tgz", - "integrity": "sha512-78KWk9T26NhzXtuL26cIJ8/qNHANyJ/ZYrmEXFzUmhZdjpBv+DlWlOANRTGBt48YcyslsLrj0bMLFTmXvLRCOw==" - }, "cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -3675,11 +3207,6 @@ "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", "dev": true }, - "data-uri-to-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz", - "integrity": "sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==" - }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -3773,11 +3300,6 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, - "event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" - }, "express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -3875,14 +3397,6 @@ } } }, - "for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "requires": { - "is-callable": "^1.1.3" - } - }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3928,14 +3442,6 @@ "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", "dev": true }, - "gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "requires": { - "get-intrinsic": "^1.1.3" - } - }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -3960,14 +3466,6 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" }, - "has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "requires": { - "has-symbols": "^1.0.2" - } - }, "history": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", @@ -4006,40 +3504,6 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" }, - "is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "requires": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - } - }, - "is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" - }, - "is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "requires": { - "has-tostringtag": "^1.0.0" - } - }, - "is-typed-array": { - "version": "1.1.10", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.10.tgz", - "integrity": "sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==", - "requires": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" - } - }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4121,11 +3585,6 @@ "mime-db": "1.52.0" } }, - "mrmime": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", - "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==" - }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -4376,11 +3835,6 @@ "send": "0.18.0" } }, - "set-cookie-parser": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", - "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==" - }, "setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -4409,43 +3863,17 @@ "object-inspect": "^1.9.0" } }, - "source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==" - }, "source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", "dev": true }, - "source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - }, - "dependencies": { - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" - } - } - }, "statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" }, - "stream-slice": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/stream-slice/-/stream-slice-0.1.2.tgz", - "integrity": "sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==" - }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -4496,18 +3924,6 @@ "picocolors": "^1.0.0" } }, - "util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "requires": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -4530,20 +3946,6 @@ "rollup": "^3.21.0" } }, - "web-encoding": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz", - "integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==", - "requires": { - "@zxing/text-encoding": "0.9.0", - "util": "^0.12.3" - } - }, - "web-streams-polyfill": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", - "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==" - }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4552,19 +3954,6 @@ "isexe": "^2.0.0" } }, - "which-typed-array": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.9.tgz", - "integrity": "sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==", - "requires": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0", - "is-typed-array": "^1.1.10" - } - }, "yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/examples/ssr-data-router/package.json b/examples/ssr-data-router/package.json index 5bbe4a9c16..f168a5ffc9 100644 --- a/examples/ssr-data-router/package.json +++ b/examples/ssr-data-router/package.json @@ -10,7 +10,6 @@ "debug": "node --inspect-brk server.js" }, "dependencies": { - "@remix-run/node": "^1.12.0", "@remix-run/router": "^1.8.0", "compression": "1.7.4", "cross-env": "^7.0.3", diff --git a/examples/ssr-data-router/server.js b/examples/ssr-data-router/server.js index cda4971101..e88e219c15 100644 --- a/examples/ssr-data-router/server.js +++ b/examples/ssr-data-router/server.js @@ -1,10 +1,6 @@ let path = require("path"); let fsp = require("fs/promises"); let express = require("express"); -let { installGlobals } = require("@remix-run/node"); - -// Polyfill Web Fetch API -installGlobals(); let root = process.cwd(); let isProduction = process.env.NODE_ENV === "production"; diff --git a/examples/ssr-data-router/vite.config.js b/examples/ssr-data-router/vite.config.js index 691a13100a..e595084a02 100644 --- a/examples/ssr-data-router/vite.config.js +++ b/examples/ssr-data-router/vite.config.js @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,18 +20,10 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" ), - "react-router-dom/server": path.resolve( - __dirname, - "../../packages/react-router-dom/server.tsx" - ), "react-router-dom": path.resolve( __dirname, "../../packages/react-router-dom/index.tsx" diff --git a/examples/ssr/vite.config.js b/examples/ssr/vite.config.js index fbadfa5d9f..e595084a02 100644 --- a/examples/ssr/vite.config.js +++ b/examples/ssr/vite.config.js @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/examples/view-transitions/src/main.tsx b/examples/view-transitions/src/main.tsx index bfe23d1fc0..2d6ce1789a 100644 --- a/examples/view-transitions/src/main.tsx +++ b/examples/view-transitions/src/main.tsx @@ -246,13 +246,7 @@ function NavImage({ src, idx }: { src: string; idx: number }) { const rootElement = document.getElementById("root") as HTMLElement; ReactDOMClient.createRoot(rootElement).render( - + ); diff --git a/examples/view-transitions/vite.config.ts b/examples/view-transitions/vite.config.ts index fbadfa5d9f..e595084a02 100644 --- a/examples/view-transitions/vite.config.ts +++ b/examples/view-transitions/vite.config.ts @@ -12,7 +12,6 @@ export default defineConfig({ rollupReplace({ preventAssignment: true, values: { - __DEV__: JSON.stringify(true), "process.env.NODE_ENV": JSON.stringify("development"), }, }), @@ -21,10 +20,6 @@ export default defineConfig({ resolve: process.env.USE_SOURCE ? { alias: { - "@remix-run/router": path.resolve( - __dirname, - "../../packages/router/index.ts" - ), "react-router": path.resolve( __dirname, "../../packages/react-router/index.ts" diff --git a/integration/CHANGELOG.md b/integration/CHANGELOG.md new file mode 100644 index 0000000000..6fccf850d7 --- /dev/null +++ b/integration/CHANGELOG.md @@ -0,0 +1,15 @@ +# integration-tests + +## 0.0.0 + +### Minor Changes + +- Unstable Vite support for Node-based Remix apps ([#7590](https://github.com/remix-run/remix/pull/7590)) + + - `remix build` ๐Ÿ‘‰ `vite build && vite build --ssr` + - `remix dev` ๐Ÿ‘‰ `vite dev` + + Other runtimes (e.g. Deno, Cloudflare) not yet supported. + Custom server (e.g. Express) not yet supported. + + See "Future > Vite" in the Remix Docs for details. diff --git a/integration/abort-signal-test.ts b/integration/abort-signal-test.ts new file mode 100644 index 0000000000..f650ec6f1f --- /dev/null +++ b/integration/abort-signal-test.ts @@ -0,0 +1,65 @@ +import { test } from "@playwright/test"; + +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { useActionData, useLoaderData, Form } from "react-router"; + + export async function action ({ request }) { + // New event loop causes express request to close + await new Promise(r => setTimeout(r, 0)); + return { aborted: request.signal.aborted }; + } + + export function loader({ request }) { + return { aborted: request.signal.aborted }; + } + + export default function Index() { + let actionData = useActionData(); + let data = useLoaderData(); + return ( +
+

{actionData ? String(actionData.aborted) : "empty"}

+

{String(data.aborted)}

+ + + +
+ ) + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(() => { + appFixture.close(); +}); + +test("should not abort the request in a new event loop", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector(`.action:has-text("empty")`); + await page.waitForSelector(`.loader:has-text("false")`); + + await app.clickElement('button[type="submit"]'); + + await page.waitForSelector(`.action:has-text("false")`); + await page.waitForSelector(`.loader:has-text("false")`); +}); diff --git a/integration/action-test.ts b/integration/action-test.ts new file mode 100644 index 0000000000..7bb077c18c --- /dev/null +++ b/integration/action-test.ts @@ -0,0 +1,213 @@ +import { test, expect } from "@playwright/test"; + +import { + createFixture, + createAppFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture, selectHtml } from "./helpers/playwright-fixture.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 ( +
+

+ {data ? {data} : "${WAITING_VALUE}"} +

+

+ + +

+
+ ); + } + `, + + "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 ( +
+

+ {data ? {data} : "${WAITING_VALUE}"} +

+

+ + + +

+
+ ); + } + `, + + [`app/routes/${THROWS_REDIRECT}.jsx`]: js` + import { redirect } from "react-router"; + import { Form } from "react-router"; + + export function action() { + throw redirect("/${REDIRECT_TARGET}") + } + + export default function () { + return ( +
+ +
+ ) + } + `, + + [`app/routes/${REDIRECT_TARGET}.jsx`]: js` + export default function () { + return
${PAGE_TEXT}
+ } + `, + + "app/routes/no-action.tsx": js` + import { Form } from "react-router"; + + export default function Component() { + return ( +
+ +
+ ); + } + `, + }, + }); + + 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( + 'a=1&b=2' + ); + }); + + 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/assets/toupload.txt b/integration/assets/toupload.txt new file mode 100644 index 0000000000..b45ef6fec8 --- /dev/null +++ b/integration/assets/toupload.txt @@ -0,0 +1 @@ +Hello, World! \ No newline at end of file diff --git a/integration/assets/touploadtoobig.txt b/integration/assets/touploadtoobig.txt new file mode 100644 index 0000000000..8811b05287 --- /dev/null +++ b/integration/assets/touploadtoobig.txt @@ -0,0 +1 @@ +Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World!Hello, World! \ No newline at end of file diff --git a/integration/blocking-test.ts b/integration/blocking-test.ts new file mode 100644 index 0000000000..5b99df29b8 --- /dev/null +++ b/integration/blocking-test.ts @@ -0,0 +1,113 @@ +import { test, expect } from "@playwright/test"; + +import type { AppFixture, Fixture } from "./helpers/create-fixture.js"; +import { + createFixture, + js, + createAppFixture, +} from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.afterAll(() => appFixture.close()); + +test("handles synchronous proceeding correctly", async ({ page }) => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + export default function Component() { + return ( +
+

Index

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

A

+ /b +
+ ) + } + `, + "app/routes/b.tsx": js` + import * as React from "react"; + import { Form, useAction, useBlocker } from "react-router"; + export default function Component() { + return ( +
+

B

+ +
+ ) + } + function ImportantForm() { + let [value, setValue] = React.useState(""); + let shouldBlock = React.useCallback( + ({ currentLocation, nextLocation }) => + value !== "" && currentLocation.pathname !== nextLocation.pathname, + [value] + ); + let blocker = useBlocker(shouldBlock); + // Reset the blocker if the user cleans the form + React.useEffect(() => { + if (blocker.state === "blocked") { + blocker.proceed(); + } + }, [blocker]); + return ( + <> +

+ Is the form dirty?{" "} + {value !== "" ? ( + Yes + ) : ( + No + )} +

+
+ + +
+ + ); + } + `, + }, + }); + + // This creates an interactive app using puppeteer. + appFixture = await createAppFixture(fixture); + + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/"); + await app.clickLink("/a"); + await page.waitForSelector("#a"); + await app.clickLink("/b"); + await page.waitForSelector("#b"); + await page.getByLabel("Enter some important data:").fill("Hello Remix!"); + + // Going back should: + // - block + // - immediately call blocker.proceed() once we enter the blocked state + // - and land back one history entry (/a) + await page.goBack(); + await page.waitForSelector("#a"); + expect(await app.getHtml()).toContain("A"); +}); diff --git a/integration/browser-entry-test.ts b/integration/browser-entry-test.ts new file mode 100644 index 0000000000..9f36485364 --- /dev/null +++ b/integration/browser-entry-test.ts @@ -0,0 +1,88 @@ +import { test, expect } from "@playwright/test"; + +import type { AppFixture, Fixture } from "./helpers/create-fixture.js"; +import { + createFixture, + js, + createAppFixture, +} from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + + export default function Index() { + return ( +
+
pizza
+ burger link +
+ ) + } + `, + + "app/routes/burgers.tsx": js` + export default function Index() { + return
cheeseburger
; + } + `, + }, + }); + + // This creates an interactive app using puppeteer. + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(() => appFixture.close()); + +test( + "expect to be able to browse backward out of a remix app, then forward " + + "twice in history and have pages render correctly", + async ({ page, browserName }) => { + test.skip( + browserName === "firefox", + "FireFox doesn't support browsing to an empty page (aka about:blank)" + ); + + let app = new PlaywrightFixture(appFixture, page); + + // Slow down the entry chunk on the second load so the bug surfaces + let isSecondLoad = false; + await page.route(/entry/, async (route) => { + if (isSecondLoad) { + await new Promise((r) => setTimeout(r, 1000)); + } + route.continue(); + }); + + // This sets up the Remix modules cache in memory, priming the error case. + await app.goto("/"); + await app.clickLink("/burgers"); + expect(await page.content()).toContain("cheeseburger"); + await page.goBack(); + await page.waitForSelector("#pizza"); + expect(await app.getHtml()).toContain("pizza"); + + // Takes the browser out of the Remix app + await page.goBack(); + expect(page.url()).toContain("about:blank"); + + // Forward to / and immediately again to /burgers. This will trigger the + // error since we'll load __routeModules for / but then try to hydrate /burgers + isSecondLoad = true; + await page.goForward(); + await page.goForward(); + await page.waitForSelector("#cheeseburger"); + + // If we resolve the error, we should hard reload and eventually + // successfully render /burgers + await page.waitForSelector("#cheeseburger"); + expect(await app.getHtml()).toContain("cheeseburger"); + } +); diff --git a/integration/bug-report-test.ts b/integration/bug-report-test.ts new file mode 100644 index 0000000000..5e272b897a --- /dev/null +++ b/integration/bug-report-test.ts @@ -0,0 +1,120 @@ +import { test, expect } from "@playwright/test"; + +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +//////////////////////////////////////////////////////////////////////////////// +// ๐Ÿ’ฟ ๐Ÿ‘‹ Hola! It's me, Dora the Remix Disc, I'm here to help you write a great +// bug report pull request. +// +// You don't need to fix the bug, this is just to report one. +// +// The pull request you are submitting is supposed to fail when created, to let +// the team see the erroneous behavior, and understand what's going wrong. +// +// If you happen to have a fix as well, it will have to be applied in a subsequent +// commit to this pull request, and your now-succeeding test will have to be moved +// to the appropriate file. +// +// First, make sure to install dependencies and build Remix. From the root of +// the project, run this: +// +// ``` +// pnpm install && pnpm build +// ``` +// +// Now try running this test: +// +// ``` +// pnpm bug-report-test +// ``` +// +// You can add `--watch` to the end to have it re-run on file changes: +// +// ``` +// pnpm bug-report-test --watch +// ``` +//////////////////////////////////////////////////////////////////////////////// + +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({ + //////////////////////////////////////////////////////////////////////////// + // ๐Ÿ’ฟ Next, add files to this object, just like files in a real app, + // `createFixture` will make an app and run your tests against it. + //////////////////////////////////////////////////////////////////////////// + files: { + "app/routes/_index.tsx": js` + import { useLoaderData, Link } from "react-router"; + + export function loader() { + return "pizza"; + } + + export default function Index() { + let data = useLoaderData(); + return ( +
+ {data} + Other Route +
+ ) + } + `, + + "app/routes/burgers.tsx": js` + export default function Index() { + return
cheeseburger
; + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(() => { + appFixture.close(); +}); + +//////////////////////////////////////////////////////////////////////////////// +// ๐Ÿ’ฟ Almost done, now write your failing test case(s) down here Make sure to +// add a good description for what you expect Remix to do ๐Ÿ‘‡๐Ÿฝ +//////////////////////////////////////////////////////////////////////////////// + +test("[description of what you expect it to do]", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + // You can test any request your app might get using `fixture`. + let response = await fixture.requestDocument("/"); + expect(await response.text()).toMatch("pizza"); + + // If you need to test interactivity use the `app` + await app.goto("/"); + await app.clickLink("/burgers"); + await page.waitForSelector("text=cheeseburger"); + + // If you're not sure what's going on, you can "poke" the app, it'll + // automatically open up in your browser for 20 seconds, so be quick! + // await app.poke(20); + + // Go check out the other tests to see what else you can do. +}); + +//////////////////////////////////////////////////////////////////////////////// +// ๐Ÿ’ฟ Finally, push your changes to your fork of Remix and open a pull request! +//////////////////////////////////////////////////////////////////////////////// diff --git a/integration/catch-boundary-data-test.ts b/integration/catch-boundary-data-test.ts new file mode 100644 index 0000000000..7f6c89284e --- /dev/null +++ b/integration/catch-boundary-data-test.ts @@ -0,0 +1,243 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +let ROOT_BOUNDARY_TEXT = "ROOT_TEXT" as const; +let LAYOUT_BOUNDARY_TEXT = "LAYOUT_BOUNDARY_TEXT" as const; +let OWN_BOUNDARY_TEXT = "OWN_BOUNDARY_TEXT" as const; + +let NO_BOUNDARY_LOADER_FILE = "/no.loader" as const; +let NO_BOUNDARY_LOADER = "/no/loader" as const; + +let HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE = + "/yes.loader-layout-boundary" as const; +let HAS_BOUNDARY_LAYOUT_NESTED_LOADER = "/yes/loader-layout-boundary" as const; + +let HAS_BOUNDARY_NESTED_LOADER_FILE = "/yes.loader-self-boundary" as const; +let HAS_BOUNDARY_NESTED_LOADER = "/yes/loader-self-boundary" as const; + +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 ( + + + + + + +
{data}
+ + + + + ); + } + + export function ErrorBoundary() { + let matches = useMatches(); + let { data } = matches.find(match => match.id === "root"); + + return ( + + + +
${ROOT_BOUNDARY_TEXT}
+
{data}
+ + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + export default function Index() { + return ( +
+ ${NO_BOUNDARY_LOADER} + ${HAS_BOUNDARY_LAYOUT_NESTED_LOADER} + ${HAS_BOUNDARY_NESTED_LOADER} +
+ ); + } + `, + + [`app/routes${NO_BOUNDARY_LOADER_FILE}.jsx`]: js` + export function loader() { + throw new Response("", { status: 401 }); + } + export default function Index() { + return
; + } + `, + + [`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
; + } + export function ErrorBoundary() { + let matches = useMatches(); + let { data } = matches.find(match => match.id === "routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}"); + + return ( +
+
${LAYOUT_BOUNDARY_TEXT}
+
{data}
+
+ ); + } + `, + + [`app/routes${HAS_BOUNDARY_LAYOUT_NESTED_LOADER_FILE}._index.jsx`]: js` + export function loader() { + throw new Response("", { status: 401 }); + } + export default function Index() { + return
; + } + `, + + [`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 ( +
+
{data}
+ +
+ ); + } + `, + + [`app/routes${HAS_BOUNDARY_NESTED_LOADER_FILE}._index.jsx`]: js` + export function loader() { + throw new Response("", { status: 401 }); + } + export default function Index() { + return
; + } + export function ErrorBoundary() { + return ( +
${OWN_BOUNDARY_TEXT}
+ ); + } + `, + }, + }); + + 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/catch-boundary-test.ts b/integration/catch-boundary-test.ts new file mode 100644 index 0000000000..0cad7d7b5c --- /dev/null +++ b/integration/catch-boundary-test.ts @@ -0,0 +1,366 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("ErrorBoundary (thrown responses)", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let ROOT_BOUNDARY_TEXT = "ROOT_TEXT" as const; + let OWN_BOUNDARY_TEXT = "OWN_BOUNDARY_TEXT" as const; + + let HAS_BOUNDARY_LOADER = "/yes/loader" as const; + let HAS_BOUNDARY_LOADER_FILE = "/yes.loader" as const; + let HAS_BOUNDARY_ACTION = "/yes/action" as const; + let HAS_BOUNDARY_ACTION_FILE = "/yes.action" as const; + let NO_BOUNDARY_ACTION = "/no/action" as const; + let NO_BOUNDARY_ACTION_FILE = "/no.action" as const; + let NO_BOUNDARY_LOADER = "/no/loader" as const; + let NO_BOUNDARY_LOADER_FILE = "/no.loader" as const; + + let NOT_FOUND_HREF = "/not/found"; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, useMatches } from "react-router"; + + export function loader() { + return { data: "ROOT LOADER" }; + } + + export default function Root() { + return ( + + + + + + + + + + + ); + } + + export function ErrorBoundary() { + let matches = useMatches() + return ( + + + +
${ROOT_BOUNDARY_TEXT}
+
{JSON.stringify(matches)}
+ + + + ) + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "react-router"; + export default function() { + return ( +
+ ${NOT_FOUND_HREF} + +
+
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "react-router"; + export async function action() { + throw new Response("", { status: 401 }) + } + export function ErrorBoundary() { + return

${OWN_BOUNDARY_TEXT}

+ } + export default function Index() { + return ( +
+ +
+ ); + } + `, + + [`app/routes${NO_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "react-router"; + export function action() { + throw new Response("", { status: 401 }) + } + export default function Index() { + return ( +
+ +
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_LOADER_FILE}.jsx`]: js` + import { useRouteError } from "react-router"; + export function loader() { + throw new Response("", { status: 401 }) + } + export function ErrorBoundary() { + let error = useRouteError(); + return ( + <> +
${OWN_BOUNDARY_TEXT}
+
{error.status}
+ + ); + } + export default function Index() { + return
+ } + `, + + [`app/routes${HAS_BOUNDARY_LOADER_FILE}.child.jsx`]: js` + export function loader() { + throw new Response("", { status: 404 }) + } + export default function Index() { + return
+ } + `, + + [`app/routes${NO_BOUNDARY_LOADER_FILE}.jsx`]: js` + export function loader() { + throw new Response("", { status: 401 }) + } + export default function Index() { + return
+ } + `, + + "app/routes/action.tsx": js` + import { Outlet, useLoaderData } from "react-router"; + + export function loader() { + return "PARENT"; + } + + export default function () { + return ( +
+

{useLoaderData()}

+ +
+ ) + } + `, + + "app/routes/action.child-catch.tsx": js` + import { Form, useLoaderData, useRouteError } from "react-router"; + + export function loader() { + return "CHILD"; + } + + export function action() { + throw new Response("Caught!", { status: 400 }); + } + + export default function () { + return ( + <> +

{useLoaderData()}

+
+ +
+ + ) + } + + export function ErrorBoundary() { + let error = useRouteError() + return

{error.status} {error.data}

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("non-matching urls on document requests", async () => { + let oldConsoleError; + oldConsoleError = console.error; + console.error = () => {}; + + let res = await fixture.requestDocument(NOT_FOUND_HREF); + expect(res.status).toBe(404); + let html = await res.text(); + expect(html).toMatch(ROOT_BOUNDARY_TEXT); + + // There should be no loader data on the root route + let expected = JSON.stringify([ + { id: "root", pathname: "", params: {} }, + ]).replace(/"/g, """); + expect(html).toContain(`
${expected}
`); + + console.error = oldConsoleError; + }); + + test("non-matching urls on client transitions", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NOT_FOUND_HREF, { wait: false }); + await page.waitForSelector("#root-boundary"); + + // Root loader data sticks around from previous load + let expected = JSON.stringify([ + { id: "root", pathname: "", params: {}, data: { data: "ROOT LOADER" } }, + ]); + expect(await app.getHtml("#matches")).toContain(expected); + }); + + test("own boundary, action, document request", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, action, client transition from other route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector("#action-boundary"); + }); + + test("own boundary, action, client transition from itself", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(HAS_BOUNDARY_ACTION); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector("#action-boundary"); + }); + + test("bubbles to parent in action document requests", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(NO_BOUNDARY_ACTION, params); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector("#root-boundary"); + }); + + test("bubbles to parent in action script transitions from self", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(NO_BOUNDARY_ACTION); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector("#root-boundary"); + }); + + test("own boundary, loader, document request", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_LOADER); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, loader, client transition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_LOADER); + await page.waitForSelector("#boundary-loader"); + }); + + test("bubbles to parent in loader document requests", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); + expect(res.status).toBe(401); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in loader transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_LOADER); + await page.waitForSelector("#root-boundary"); + }); + + test("uses correct catch boundary on server action errors", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/action/child-catch`); + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-data")).toMatch("CHILD"); + await page.click("button[type=submit]"); + await page.waitForSelector("#child-catch"); + // Preserves parent loader data + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-catch")).toMatch("400"); + expect(await app.getHtml("#child-catch")).toMatch("Caught!"); + }); + + test("prefers parent catch when child loader also bubbles, document request", async () => { + let res = await fixture.requestDocument(`${HAS_BOUNDARY_LOADER}/child`); + expect(res.status).toBe(401); + let text = await res.text(); + expect(text).toMatch(OWN_BOUNDARY_TEXT); + expect(text).toMatch('
401
'); + }); + + test("prefers parent catch when child loader also bubbles, client transition", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(`${HAS_BOUNDARY_LOADER}/child`); + await page.waitForSelector("#boundary-loader"); + expect(await app.getHtml("#boundary-loader")).toMatch(OWN_BOUNDARY_TEXT); + expect(await app.getHtml("#status")).toMatch("401"); + }); +}); diff --git a/integration/client-data-test.ts b/integration/client-data-test.ts new file mode 100644 index 0000000000..57b6a083f9 --- /dev/null +++ b/integration/client-data-test.ts @@ -0,0 +1,1490 @@ +import { test, expect } from "@playwright/test"; + +import { UNSAFE_ServerMode as ServerMode } from "react-router"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +function getFiles({ + parentClientLoader, + parentClientLoaderHydrate, + parentAdditions, + childClientLoader, + childClientLoaderHydrate, + childAdditions, +}: { + parentClientLoader: boolean; + parentClientLoaderHydrate: boolean; + parentAdditions?: string; + childClientLoader: boolean; + childClientLoaderHydrate: boolean; + childAdditions?: string; +}) { + return { + "app/root.tsx": js` + import { Outlet, Scripts } from "react-router" + + export default function Root() { + return ( + + + +
+ +
+ + + + ); + } + `, + "app/routes/_index.tsx": js` + import { Link } from "react-router" + export default function Component() { + return Go to /parent/child + } + `, + "app/routes/parent.tsx": js` + import { Outlet, useLoaderData } from "react-router" + export function loader() { + return { message: 'Parent Server Loader' }; + } + ${ + parentClientLoader + ? js` + export async function clientLoader({ serverLoader }) { + // 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)" }; + } + ` + : "" + } + ${ + parentClientLoaderHydrate + ? js` + clientLoader.hydrate = true; + export function HydrateFallback() { + return

Parent Fallback

+ } + ` + : "" + } + ${parentAdditions || ""} + export default function Component() { + let data = useLoaderData(); + return ( + <> +

{data.message}

+ + + ); + } + `, + "app/routes/parent.child.tsx": js` + import { Form, Outlet, useActionData, useLoaderData } from "react-router" + export function loader() { + return { message: 'Child Server Loader' }; + } + export function action() { + return { message: 'Child Server Action' }; + } + ${ + childClientLoader + ? js` + export async function clientLoader({ serverLoader }) { + // 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)" }; + } + ` + : "" + } + ${ + childClientLoaderHydrate + ? js` + clientLoader.hydrate = true; + export function HydrateFallback() { + return

Child Fallback

+ } + ` + : "" + } + ${childAdditions || ""} + export default function Component() { + let data = useLoaderData(); + let actionData = useActionData(); + return ( + <> +

{data.message}

+
+ + {actionData ?

{actionData.message}

: null} +
+ + ); + } + `, + }; +} + +test.describe("Client Data", () => { + let appFixture: AppFixture; + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("clientLoader - critical route module", () => { + test("no client loaders or fallbacks", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + files: getFiles({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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

Parent Fallback

+ } + `, + 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({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from "react-router" + 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

Child Fallback

+ } + export default function Component() { + let data = useLoaderData(); + return ( + <> +

{data.message}

+ Loading Deferred Data...

}> + + {(value) =>

{value}

} +
+
+ + ); + } + `, + }, + }); + + // 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({ + 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({ + 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

SHOULD NOT SEE ME

+ } + export default function Component() { + let data = useLoaderData(); + return

{data.message}

; + } + `, + }, + }); + 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"); + }); + + test("clientLoader.hydrate is automatically implied when no server loader exists (w HydrateFallback)", async ({ + page, + }) => { + appFixture = await createAppFixture( + await createFixture({ + files: { + ...getFiles({ + 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

Child Fallback

+ } + export default function Component() { + let data = useLoaderData(); + return

{data.message}

; + } + `, + }, + }) + ); + 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, + }) => { + appFixture = await createAppFixture( + await createFixture({ + files: { + ...getFiles({ + 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

{data.message}

; + } + `, + }, + }) + ); + 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({ + files: { + ...getFiles({ + 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

Child

; + } + export function HydrateFallback() { + return

Loading...

; + } + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.status} {error.data}

; + } + `, + }, + }) + ); + 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({ + files: { + ...getFiles({ + 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 ( + <> +

{data.message}

+ + + ); + } + export function HydrateFallback() { + return

Loading...

+ } + `, + }, + }) + ); + 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({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from "react-router"; + 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 ( + <> +

{data.message}

+ + + ); + } + export function HydrateFallback() { + return

Loading...

+ } + `, + }, + }) + ); + 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( + { + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import { ClientLoaderFunctionArgs, 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

Should not see me

; + } + + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.message}

; + } + `, + }, + }, + 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({ + 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 ( + <> +

{data.message}

+ + + ); + } + export function ErrorBoundary() { + let data = useRouteLoaderData("routes/parent") + let error = useRouteError(); + return ( + <> +

Parent Error

+

{data?.message}

+

{error?.message}

+ + ); + } + `, + "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 ( + <> +

Should not see me

+

{data}

; + + ); + } + `, + }, + }, + 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

"); + 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)

"); + 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.describe("clientLoader - lazy route module", () => { + test("no client loaders or fallbacks", async ({ page }) => { + appFixture = await createAppFixture( + await createFixture({ + files: getFiles({ + 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({ + 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({ + 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({ + 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({ + 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

Child

; + } + export function HydrateFallback() { + return

Loading...

; + } + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.status} {error.data}

; + } + `, + }, + }) + ); + 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({ + parentClientLoader: true, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/_index.tsx": js` + import { Link } from 'react-router' + export default function Component() { + return ( + <> + Go to /parent + Go to /parent/child + + ); + } + `, + }, + }) + ); + + 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( + { + files: getFiles({ + 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({ + files: getFiles({ + 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({ + 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({ + files: getFiles({ + 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({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from "react-router"; + import { Form, useRouteError } from "react-router"; + export async function clientAction({ serverAction }) { + return await serverAction(); + } + export default function Component() { + return ( +
+ +
+ ); + } + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.status} {error.data}

; + } + `, + }, + }) + ); + 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({ + 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({ + files: getFiles({ + 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({ + files: getFiles({ + 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({ + files: getFiles({ + 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({ + files: { + ...getFiles({ + parentClientLoader: false, + parentClientLoaderHydrate: false, + childClientLoader: false, + childClientLoaderHydrate: false, + }), + "app/routes/parent.child.tsx": js` + import * as React from 'react'; + import { json } from "react-router"; + import { Form, useRouteError } from "react-router"; + export async function clientAction({ serverAction }) { + return await serverAction(); + } + export default function Component() { + return ( +
+ +
+ ); + } + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.status} {error.data}

; + } + `, + }, + }) + ); + 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/custom-entry-server-test.ts b/integration/custom-entry-server-test.ts new file mode 100644 index 0000000000..ed8240b134 --- /dev/null +++ b/integration/custom-entry-server-test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test"; + +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/entry.server.tsx": js` + import * as React from "react"; + import { ServerRouter } from "react-router"; + import { renderToString } from "react-dom/server"; + + export default function handleRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) { + let markup = renderToString( + + ); + responseHeaders.set("Content-Type", "text/html"); + responseHeaders.set("x-custom-header", "custom-value"); + return new Response('' + markup, { + headers: responseHeaders, + status: responseStatusCode, + }); + } + `, + "app/routes/_index.tsx": js` + export default function Index() { + return

Hello World

+ } + `, + }, + }); + + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(() => { + appFixture.close(); +}); + +test("allows user specified entry.server", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let responses = app.collectResponses((url) => url.pathname === "/"); + await app.goto("/"); + let header = await responses[0].headerValues("x-custom-header"); + expect(header).toEqual(["custom-value"]); +}); diff --git a/integration/defer-loader-test.ts b/integration/defer-loader-test.ts new file mode 100644 index 0000000000..9fc9beb6f6 --- /dev/null +++ b/integration/defer-loader-test.ts @@ -0,0 +1,110 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.describe("deferred loaders", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { useLoaderData, Link } from "react-router"; + export default function Index() { + return ( +
+ Redirect + Direct Promise Access +
+ ) + } + `, + + "app/routes/redirect.tsx": js` + import { data } from 'react-router'; + export function loader() { + return data( + { food: "pizza" }, + { + status: 301, + headers: { + Location: "/?redirected" + } + } + ); + } + export default function Redirect() { + return null; + } + `, + + "app/routes/direct-promise-access.tsx": js` + import * as React from "react"; + import { useLoaderData, Link, Await } from "react-router"; + export function loader() { + return { + bar: new Promise(async (resolve, reject) => { + resolve("hamburger"); + }), + }; + } + let count = 0; + export default function Index() { + let {bar} = useLoaderData(); + React.useEffect(() => { + let aborted = false; + bar.then((data) => { + if (aborted) return; + document.getElementById("content").innerHTML = data + " " + (++count); + document.getElementById("content").setAttribute("data-done", ""); + }); + return () => { + aborted = true; + }; + }, [bar]); + return ( +
+ Waiting for client hydration.... +
+ ) + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(async () => appFixture.close()); + + test("deferred response can redirect on document request", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/redirect"); + await page.waitForURL(/\?redirected/); + }); + + test("deferred response can redirect on transition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/redirect"); + await page.waitForURL(/\?redirected/); + }); + + test("can directly access result from deferred promise on document request", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/direct-promise-access"); + let element = await page.waitForSelector("[data-done]"); + expect(await element.innerText()).toMatch("hamburger 1"); + }); +}); diff --git a/integration/defer-test.ts b/integration/defer-test.ts new file mode 100644 index 0000000000..4c8dfc792e --- /dev/null +++ b/integration/defer-test.ts @@ -0,0 +1,1276 @@ +import { test, expect } from "@playwright/test"; +import type { ConsoleMessage, Page } from "@playwright/test"; + +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; + +const ROOT_ID = "ROOT_ID"; +const INDEX_ID = "INDEX_ID"; +const DEFERRED_ID = "DEFERRED_ID"; +const RESOLVED_DEFERRED_ID = "RESOLVED_DEFERRED_ID"; +const FALLBACK_ID = "FALLBACK_ID"; +const ERROR_ID = "ERROR_ID"; +const ERROR_BOUNDARY_ID = "ERROR_BOUNDARY_ID"; +const MANUAL_RESOLVED_ID = "MANUAL_RESOLVED_ID"; +const MANUAL_FALLBACK_ID = "MANUAL_FALLBACK_ID"; +const MANUAL_ERROR_ID = "MANUAL_ERROR_ID"; + +declare global { + var __deferredManualResolveCache: { + nextId: number; + deferreds: Record< + string, + { resolve: (value: any) => void; reject: (error: Error) => void } + >; + }; +} + +test.describe("non-aborted", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + 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/components/counter.tsx": js` + import { useState } from "react"; + + export default function Counter({ id }) { + let [count, setCount] = useState(0); + return ( +
+ +

{count}

+
+ ) + } + `, + "app/components/interactive.tsx": js` + import { useEffect, useState } from "react"; + + export default function Interactive() { + let [interactive, setInteractive] = useState(false); + useEffect(() => { + setInteractive(true); + }, []); + return interactive ? ( +
+

interactive

+
+ ) : null; + } + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + import Interactive from "~/components/interactive"; + + export const meta: MetaFunction = () => { + return [{ title: "New Remix App" }]; + }; + + export const loader = () => ({ + id: "${ROOT_ID}", + }); + + export default function Root() { + let { id } = useLoaderData(); + return ( + + + + + + + + +
+

{id}

+ + + +
+ + {/* Send arbitrary data so safari renders the initial shell before + the document finishes downloading. */} + {Array(10000).fill(null).map((_, i)=>

YOOOOOOOOOO {i}

)} + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + id: "${INDEX_ID}", + }; + } + + export default function Index() { + let { id } = useLoaderData(); + return ( +
+

{id}

+ + +
    +
  • deferred-script-resolved
  • +
  • deferred-script-unresolved
  • +
  • deferred-script-rejected
  • +
  • deferred-script-unrejected
  • +
  • deferred-script-rejected-no-error-element
  • +
  • deferred-script-unrejected-no-error-element
  • +
+
+ ); + } + `, + + "app/routes/deferred-noscript-resolved.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: Promise.resolve("${RESOLVED_DEFERRED_ID}"), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-noscript-unresolved.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10) + ), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-script-resolved.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: Promise.resolve("${RESOLVED_DEFERRED_ID}"), + deferredUndefined: Promise.resolve(undefined), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-script-unresolved.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10) + ), + deferredUndefined: new Promise( + (resolve) => setTimeout(() => { + resolve(undefined); + }, 10) + ), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-script-rejected.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: Promise.reject(new Error("${RESOLVED_DEFERRED_ID}")), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + + error + +
+ } + children={(resolvedDeferredId) => ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-script-unrejected.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (_, reject) => setTimeout(() => { + reject(new Error("${RESOLVED_DEFERRED_ID}")); + }, 10) + ), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId, resolvedUndefined } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + + error + +
+ } + children={(resolvedDeferredId) => ( +
+

{resolvedDeferredId}

+ +
+ )} + /> +
+ + ); + } + `, + + "app/routes/deferred-script-rejected-no-error-element.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: Promise.reject(new Error("${RESOLVED_DEFERRED_ID}")), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> +
+ + ); + } + + export function ErrorBoundary() { + return ( +
+ error + +
+ ); + } + `, + + "app/routes/deferred-script-unrejected-no-error-element.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (_, reject) => setTimeout(() => { + reject(new Error("${RESOLVED_DEFERRED_ID}")); + }, 10) + ), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + + + ); + } + + export function ErrorBoundary() { + return ( +
+ error + +
+ ); + } + `, + + "app/routes/deferred-manual-resolve.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + global.__deferredManualResolveCache = global.__deferredManualResolveCache || { + nextId: 1, + deferreds: {}, + }; + + let id = "" + global.__deferredManualResolveCache.nextId++; + let promise = new Promise((resolve, reject) => { + global.__deferredManualResolveCache.deferreds[id] = { resolve, reject }; + }); + + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10) + ), + id, + manualValue: promise, + }; + } + + export default function Deferred() { + let { deferredId, resolvedId, id, manualValue } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{id}

+ +
+ )} + /> + + manual fallback}> + + error + + + } + children={(value) => ( +
+
{JSON.stringify(value)}
+ +
+ )} + /> +
+ + ); + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + function counterHtml(id: string, val: number) { + return `

${val}

`; + } + + test("works with critical JSON like data", async ({ page }) => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(INDEX_ID, 0)); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).not.toBe(""); + expect(deferredHTML).not.toContain('

{ + let response = await fixture.requestDocument("/deferred-noscript-resolved"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(FALLBACK_ID); + expect(criticalHTML).not.toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-noscript-resolved"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + }); + + test("slow promises render in subsequent payload", async ({ page }) => { + let response = await fixture.requestDocument( + "/deferred-noscript-unresolved" + ); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(`

`); + expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-noscript-unresolved"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + }); + + test("resolved promises render in initial payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument("/deferred-script-resolved"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(FALLBACK_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-resolved", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); + + test("slow to resolve promises render in subsequent payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument("/deferred-script-unresolved"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(`
`); + expect(criticalHTML).not.toContain(RESOLVED_DEFERRED_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(RESOLVED_DEFERRED_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-unresolved", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); + + test("rejected promises render in initial payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument("/deferred-script-rejected"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(FALLBACK_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-rejected", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + + await assertConsole(); + }); + + test("slow to reject promises render in subsequent payload and hydrates", async ({ + page, + }) => { + let response = await fixture.requestDocument("/deferred-script-unrejected"); + let html = await response.text(); + let criticalHTML = html.slice(0, html.indexOf("") + 7); + expect(criticalHTML).toContain(counterHtml(ROOT_ID, 0)); + expect(criticalHTML).toContain(counterHtml(DEFERRED_ID, 0)); + expect(criticalHTML).toContain(`
`); + expect(criticalHTML).not.toContain(ERROR_ID); + let deferredHTML = html.slice(html.indexOf("") + 7); + expect(deferredHTML).toContain(counterHtml(ERROR_ID, 0)); + + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-script-unrejected", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + + await assertConsole(); + }); + + test("rejected promises bubble to ErrorBoundary on hydrate", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-script-rejected-no-error-element", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); + + test("slow to reject promises bubble to ErrorBoundary on hydrate", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-script-unrejected-no-error-element", true); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); + + test("routes are interactive when deferred promises are suspended and after resolve in subsequent payload", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + app.goto("/deferred-manual-resolve", false); + + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${MANUAL_FALLBACK_ID}`); + let idElement = await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + let id = await idElement.innerText(); + expect(id).toBeTruthy(); + + // Ensure the deferred promise is suspended + await page.waitForSelector(`#${MANUAL_RESOLVED_ID}`, { state: "hidden" }); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + global.__deferredManualResolveCache.deferreds[id].resolve("value"); + + await ensureInteractivity(page, MANUAL_RESOLVED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID, 2); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, ROOT_ID, 2); + + await assertConsole(); + }); + + test("routes are interactive when deferred promises are suspended and after rejection in subsequent payload", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/deferred-manual-resolve", false); + + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${MANUAL_FALLBACK_ID}`); + let idElement = await page.waitForSelector(`#${RESOLVED_DEFERRED_ID}`); + let id = await idElement.innerText(); + expect(id).toBeTruthy(); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + global.__deferredManualResolveCache.deferreds[id].reject( + new Error("error") + ); + + await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID, 2); + await ensureInteractivity(page, MANUAL_ERROR_ID); + + await assertConsole(); + }); + + test("client transition with resolved promises work", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-resolved"); + + await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); + + test("client transition with unresolved promises work", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-unresolved"); + + await ensureInteractivity(page, ROOT_ID, 2); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, RESOLVED_DEFERRED_ID); + + await assertConsole(); + }); + + test("client transition with rejected promises work", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + app.clickLink("/deferred-script-rejected"); + + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, ROOT_ID, 2); + + await assertConsole(); + }); + + test("client transition with unrejected promises work", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let assertConsole = monitorConsole(page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-unrejected"); + + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + await ensureInteractivity(page, DEFERRED_ID, 2); + await ensureInteractivity(page, ROOT_ID, 2); + + await assertConsole(); + }); + + test("client transition with rejected promises bubble to ErrorBoundary", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-rejected-no-error-element"); + + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + await ensureInteractivity(page, ROOT_ID, 2); + }); + + test("client transition with unrejected promises bubble to ErrorBoundary", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + await page.waitForSelector("#interactive"); + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, INDEX_ID); + + await app.clickLink("/deferred-script-unrejected-no-error-element"); + + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + await ensureInteractivity(page, ROOT_ID, 2); + }); +}); + +test.describe("aborted", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + 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/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + import type { AppLoadContext, EntryContext } from "react-router"; + import { createReadableStreamFromReadable } from "@react-router/node"; + import { ServerRouter } from "react-router"; + import { isbot } from "isbot"; + import { renderToPipeableStream } from "react-dom/server"; + + // Exported for use by the server runtime so we can abort the + // turbo-stream encode() call + export const streamTimeout = 250; + const renderTimeout = streamTimeout + 250; + + export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext, + loadContext: AppLoadContext, + ) { + return isbot(request.headers.get("user-agent") || "") + ? handleBotRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) + : handleBrowserRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ); + } + + function handleBotRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + return new Promise((resolve, reject) => { + let didError = false; + + let { pipe, abort } = renderToPipeableStream( + , + { + onAllReady() { + let body = new PassThrough(); + let stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error: unknown) { + reject(error); + }, + onError(error: unknown) { + didError = true; + + console.error(error); + }, + } + ); + + setTimeout(abort, renderTimeout); + }); + } + + function handleBrowserRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext + ) { + return new Promise((resolve, reject) => { + let didError = false; + + let { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + let body = new PassThrough(); + let stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: didError ? 500 : responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(err: unknown) { + reject(err); + }, + onError(error: unknown) { + didError = true; + + console.error(error); + }, + } + ); + + setTimeout(abort, renderTimeout); + }); + } + `, + "app/components/counter.tsx": js` + import { useState } from "react"; + + export default function Counter({ id }) { + let [count, setCount] = useState(0); + return ( +
+ +

{count}

+
+ ) + } + `, + "app/components/interactive.tsx": js` + import { useEffect, useState } from "react"; + + export default function Interactive() { + let [interactive, setInteractive] = useState(false); + useEffect(() => { + setInteractive(true); + }, []); + return interactive ? ( +
+

interactive

+
+ ) : null; + } + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + import Interactive from "~/components/interactive"; + + export const meta: MetaFunction = () => { + return [{ title: "New Remix App" }]; + }; + + export const loader = () => ({ + id: "${ROOT_ID}", + }); + + export default function Root() { + let { id } = useLoaderData(); + return ( + + + + + + + + +
+

{id}

+ + + +
+ + {/* Send arbitrary data so safari renders the initial shell before + the document finishes downloading. */} + {Array(6000).fill(null).map((_, i)=>

YOOOOOOOOOO {i}

)} + + + ); + } + `, + + "app/routes/deferred-server-aborted.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10000) + ), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + + error + +
+ } + children={(resolvedDeferredId) => ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + `, + + "app/routes/deferred-server-aborted-no-error-element.tsx": js` + import { Suspense } from "react"; + import { Await, Link, useLoaderData } from "react-router"; + import Counter from "~/components/counter"; + + export function loader() { + return { + deferredId: "${DEFERRED_ID}", + resolvedId: new Promise( + (resolve) => setTimeout(() => { + resolve("${RESOLVED_DEFERRED_ID}"); + }, 10000) + ), + }; + } + + export default function Deferred() { + let { deferredId, resolvedId } = useLoaderData(); + return ( +
+

{deferredId}

+ + fallback
}> + ( +
+

{resolvedDeferredId}

+ +
+ )} + /> + +
+ ); + } + + export function ErrorBoundary() { + return ( +
+ error + +
+ ); + } + `, + }, + }); + + // This creates an interactive app using playwright. + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("server aborts render the errorElement", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-server-aborted"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${DEFERRED_ID}`); + await page.waitForSelector(`#${ERROR_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, DEFERRED_ID); + await ensureInteractivity(page, ERROR_ID); + }); + + test("server aborts render the ErrorBoundary when no errorElement", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/deferred-server-aborted-no-error-element"); + await page.waitForSelector(`#${ROOT_ID}`); + await page.waitForSelector(`#${ERROR_BOUNDARY_ID}`); + + await ensureInteractivity(page, ROOT_ID); + await ensureInteractivity(page, ERROR_BOUNDARY_ID); + }); +}); + +async function ensureInteractivity(page: Page, id: string, expect: number = 1) { + await page.waitForSelector("#interactive"); + let increment = await page.waitForSelector("#increment-" + id); + await increment.click(); + await page.waitForSelector(`#count-${id}:has-text('${expect}')`); +} + +function monitorConsole(page: Page) { + let messages: ConsoleMessage[] = []; + page.on("console", (message) => { + messages.push(message); + }); + + return async () => { + if (!messages.length) return; + let errors: string[] = []; + for (let message of messages) { + let logs = []; + let args = message.args(); + if (args[0]) { + let arg0 = await args[0].jsonValue(); + if ( + typeof arg0 === "string" && + arg0.includes("Download the React DevTools") + ) { + continue; + } + logs.push(arg0); + } + errors.push( + `Unexpected console.log(${JSON.stringify(logs).slice(1, -1)})` + ); + } + if (errors.length) { + throw new Error(`Unexpected console.log's:\n` + errors.join("\n") + "\n"); + } + }; +} diff --git a/integration/error-boundary-test.ts b/integration/error-boundary-test.ts new file mode 100644 index 0000000000..0ec725d5fa --- /dev/null +++ b/integration/error-boundary-test.ts @@ -0,0 +1,1021 @@ +import { test, expect } from "@playwright/test"; + +import { UNSAFE_ServerMode as ServerMode } from "react-router"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let _consoleError: any; + + let ROOT_BOUNDARY_TEXT = "ROOT_BOUNDARY_TEXT"; + let OWN_BOUNDARY_TEXT = "OWN_BOUNDARY_TEXT"; + + let HAS_BOUNDARY_LOADER = "/yes/loader" as const; + let HAS_BOUNDARY_LOADER_FILE = "/yes.loader" as const; + let HAS_BOUNDARY_ACTION = "/yes/action" as const; + let HAS_BOUNDARY_ACTION_FILE = "/yes.action" as const; + let HAS_BOUNDARY_RENDER = "/yes/render" as const; + let HAS_BOUNDARY_RENDER_FILE = "/yes.render" as const; + let HAS_BOUNDARY_NO_LOADER_OR_ACTION = "/yes/no-loader-or-action" as const; + let HAS_BOUNDARY_NO_LOADER_OR_ACTION_FILE = + "/yes.no-loader-or-action" as const; + + let NO_BOUNDARY_ACTION = "/no/action" as const; + let NO_BOUNDARY_ACTION_FILE = "/no.action" as const; + let NO_BOUNDARY_LOADER = "/no/loader" as const; + let NO_BOUNDARY_LOADER_FILE = "/no.loader" as const; + let NO_BOUNDARY_RENDER = "/no/render" as const; + let NO_BOUNDARY_RENDER_FILE = "/no.render" as const; + let NO_BOUNDARY_NO_LOADER_OR_ACTION = "/no/no-loader-or-action" as const; + let NO_BOUNDARY_NO_LOADER_OR_ACTION_FILE = "/no.no-loader-or-action" as const; + + let NOT_FOUND_HREF = "/not/found"; + + // packages/remix-react/errorBoundaries.tsx + let INTERNAL_ERROR_BOUNDARY_HEADING = "Application Error"; + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = () => {}; + fixture = await createFixture( + { + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + + export function ErrorBoundary() { + return ( + + + +
+
${ROOT_BOUNDARY_TEXT}
+
+ + + + ) + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "react-router"; + export default function () { + return ( +
+ ${NOT_FOUND_HREF} + +
+ + + + +
+ + + ${HAS_BOUNDARY_LOADER} + + + ${NO_BOUNDARY_LOADER} + + + ${HAS_BOUNDARY_RENDER} + + + ${NO_BOUNDARY_RENDER} + +
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "react-router"; + export async function action() { + throw new Error("Kaboom!") + } + export function ErrorBoundary() { + return

${OWN_BOUNDARY_TEXT}

+ } + export default function () { + return ( +
+ +
+ ); + } + `, + + [`app/routes${NO_BOUNDARY_ACTION_FILE}.jsx`]: js` + import { Form } from "react-router"; + export function action() { + throw new Error("Kaboom!") + } + export default function () { + return ( +
+ +
+ ) + } + `, + + [`app/routes${HAS_BOUNDARY_LOADER_FILE}.jsx`]: js` + export function loader() { + throw new Error("Kaboom!") + } + export function ErrorBoundary() { + return
${OWN_BOUNDARY_TEXT}
+ } + export default function () { + return
+ } + `, + + [`app/routes${NO_BOUNDARY_LOADER_FILE}.jsx`]: js` + export function loader() { + throw new Error("Kaboom!") + } + export default function () { + return
+ } + `, + + [`app/routes${NO_BOUNDARY_RENDER_FILE}.jsx`]: js` + export default function () { + throw new Error("Kaboom!") + return
+ } + `, + + [`app/routes${HAS_BOUNDARY_RENDER_FILE}.jsx`]: js` + export default function () { + throw new Error("Kaboom!") + return
+ } + + export function ErrorBoundary() { + return
${OWN_BOUNDARY_TEXT}
+ } + `, + + [`app/routes${HAS_BOUNDARY_NO_LOADER_OR_ACTION_FILE}.jsx`]: js` + export function ErrorBoundary() { + return
${OWN_BOUNDARY_TEXT}
+ } + export default function Index() { + return
+ } + `, + + [`app/routes${NO_BOUNDARY_NO_LOADER_OR_ACTION_FILE}.jsx`]: js` + export default function Index() { + return
+ } + `, + + "app/routes/fetcher-boundary.tsx": js` + import { useFetcher } from "react-router"; + export function ErrorBoundary() { + return

${OWN_BOUNDARY_TEXT}

+ } + export default function() { + let fetcher = useFetcher(); + + return ( +
+ +
+ ) + } + `, + + "app/routes/fetcher-no-boundary.tsx": js` + import { useFetcher } from "react-router"; + export default function() { + let fetcher = useFetcher(); + + return ( +
+ + + +
+ ) + } + `, + + "app/routes/action.tsx": js` + import { Outlet, useLoaderData } from "react-router"; + + export function loader() { + return "PARENT"; + } + + export default function () { + return ( +
+

{useLoaderData()}

+ +
+ ) + } + `, + + "app/routes/action.child-error.tsx": js` + import { Form, useLoaderData, useRouteError } from "react-router"; + + export function loader() { + return "CHILD"; + } + + export function action() { + throw new Error("Broken!"); + } + + export default function () { + return ( + <> +

{useLoaderData()}

+
+ +
+ + ) + } + + export function ErrorBoundary() { + let error = useRouteError(); + return

{error.message}

; + } + `, + }, + }, + ServerMode.Development + ); + + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + console.error = _consoleError; + appFixture.close(); + }); + + test("invalid request methods", async () => { + let res = await fixture.requestDocument("/", { method: "OPTIONS" }); + expect(res.status).toBe(405); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("own boundary, action, document request", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(HAS_BOUNDARY_ACTION, params); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, action, client transition from other route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, action, client transition from itself", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(HAS_BOUNDARY_ACTION); + await app.clickSubmitButton(HAS_BOUNDARY_ACTION); + await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action document requests", async () => { + let params = new URLSearchParams(); + let res = await fixture.postDocument(NO_BOUNDARY_ACTION, params); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in action script transitions from self", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(NO_BOUNDARY_ACTION); + await app.clickSubmitButton(NO_BOUNDARY_ACTION); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("own boundary, loader, document request", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_LOADER); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("own boundary, loader, client transition", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_LOADER); + await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("bubbles to parent in loader document requests", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_LOADER); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("bubbles to parent in loader script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_LOADER); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("ssr rendering errors with no boundary", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_RENDER); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("script transition rendering errors with no boundary", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_BOUNDARY_RENDER); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("ssr rendering errors with boundary", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_RENDER); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("script transition rendering errors with boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(HAS_BOUNDARY_RENDER); + await page.waitForSelector(`text=${OWN_BOUNDARY_TEXT}`); + expect(await app.getHtml("main")).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("uses correct error boundary on server action errors in nested routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/action/child-error`); + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-data")).toMatch("CHILD"); + await page.click("button[type=submit]"); + await page.waitForSelector("#child-error"); + // Preserves parent loader data + expect(await app.getHtml("#parent-data")).toMatch("PARENT"); + expect(await app.getHtml("#child-error")).toMatch("Broken!"); + }); + + test("renders own boundary in fetcher action submission without action from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/fetcher-boundary"); + await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION); + await page.waitForSelector("#fetcher-boundary"); + }); + + test("renders root boundary in fetcher action submission without action from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/fetcher-no-boundary"); + await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + }); + + test("renders root boundary in document POST without action requests", async () => { + let res = await fixture.requestDocument(NO_BOUNDARY_NO_LOADER_OR_ACTION, { + method: "post", + }); + expect(res.status).toBe(405); + expect(await res.text()).toMatch(ROOT_BOUNDARY_TEXT); + }); + + test("renders root boundary in action script transitions without action from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_BOUNDARY_NO_LOADER_OR_ACTION); + await page.waitForSelector(`text=${ROOT_BOUNDARY_TEXT}`); + }); + + test("renders own boundary in document POST without action requests", async () => { + let res = await fixture.requestDocument(HAS_BOUNDARY_NO_LOADER_OR_ACTION, { + method: "post", + }); + expect(res.status).toBe(405); + expect(await res.text()).toMatch(OWN_BOUNDARY_TEXT); + }); + + test("renders own boundary in action script transitions without action from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(HAS_BOUNDARY_NO_LOADER_OR_ACTION); + await page.waitForSelector("#boundary-no-loader-or-action"); + }); + + test.describe("if no error boundary exists in the app", () => { + let NO_ROOT_BOUNDARY_LOADER = "/loader-bad" as const; + let NO_ROOT_BOUNDARY_ACTION = "/action-bad" as const; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "react-router"; + + export default function () { + return ( +
+

Home

+
+ +
+
+ ) + } + `, + + [`app/routes${NO_ROOT_BOUNDARY_LOADER}.jsx`]: js` + export async function loader() { + throw Error("BLARGH"); + } + + export default function () { + return ( +
+

Hello

+
+ ) + } + `, + + [`app/routes${NO_ROOT_BOUNDARY_ACTION}.jsx`]: js` + export async function action() { + throw Error("YOOOOOOOO WHAT ARE YOU DOING"); + } + + export default function () { + return ( +
+

Goodbye

+
+ ) + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test("bubbles to internal boundary in loader document requests", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto(NO_ROOT_BOUNDARY_LOADER); + expect(await app.getHtml("h1")).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING); + }); + + test("bubbles to internal boundary in action script transitions from other routes", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_ROOT_BOUNDARY_ACTION); + await page.waitForSelector(`text=${INTERNAL_ERROR_BOUNDARY_HEADING}`); + expect(await app.getHtml("h1")).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING); + }); + }); +}); + +test.describe("Default ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let _consoleError: any; + + function getFiles({ + includeRootErrorBoundary = false, + rootErrorBoundaryThrows = false, + } = {}) { + let errorBoundaryCode = !includeRootErrorBoundary + ? "" + : rootErrorBoundaryThrows + ? js` + export function ErrorBoundary() { + let error = useRouteError(); + return ( + + + +
+
Root Error Boundary
+

{error.message}

+

{oh.no.what.have.i.done}

+
+ + + + ) + } + ` + : js` + export function ErrorBoundary() { + let error = useRouteError(); + return ( + + + +
+
Root Error Boundary
+

{error.message}

+
+ + + + ) + } + `; + + return { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, useRouteError } from "react-router"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + + ${errorBoundaryCode} + `, + + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + export default function () { + return ( +
+

Index

+ Loader Error + Render Error +
+ ); + } + `, + + "app/routes/loader-error.tsx": js` + export function loader() { + throw new Error('Loader Error'); + } + export default function () { + return

Loader Error

+ } + `, + + "app/routes/render-error.tsx": js` + export default function () { + throw new Error("Render Error") + } + `, + }; + } + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = () => {}; + }); + + test.afterAll(async () => { + console.error = _consoleError; + appFixture.close(); + }); + + test.describe("When the root route does not have a boundary", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + files: getFiles({ includeRootErrorBoundary: false }), + }, + ServerMode.Development + ); + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => appFixture.close()); + + test.describe("document requests", () => { + test("renders default boundary on loader errors", async () => { + let res = await fixture.requestDocument("/loader-error"); + expect(res.status).toBe(500); + let text = await res.text(); + expect(text).toMatch("Application Error"); + expect(text).toMatch("Loader Error"); + expect(text).not.toMatch("Root Error Boundary"); + }); + + test("renders default boundary on render errors", async () => { + let res = await fixture.requestDocument("/render-error"); + expect(res.status).toBe(500); + let text = await res.text(); + expect(text).toMatch("Application Error"); + expect(text).toMatch("Render Error"); + expect(text).not.toMatch("Root Error Boundary"); + }); + }); + + test.describe("SPA navigations", () => { + test("renders default boundary on loader errors", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/loader-error"); + await page.waitForSelector("pre"); + let html = await app.getHtml(); + expect(html).toMatch("Application Error"); + expect(html).toMatch("Loader Error"); + expect(html).not.toMatch("Root Error Boundary"); + + // Ensure we can click back to our prior page + await app.goBack(); + await page.waitForSelector("h1#index"); + }); + + test("renders default boundary on render errors", async ({ + page, + }, workerInfo) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/render-error"); + await page.waitForSelector("pre"); + let html = await app.getHtml(); + expect(html).toMatch("Application Error"); + // Chromium seems to be the only one that includes the message in the stack + if (workerInfo.project.name === "chromium") { + expect(html).toMatch("Render Error"); + } + expect(html).not.toMatch("Root Error Boundary"); + + // Ensure we can click back to our prior page + await app.goBack(); + await page.waitForSelector("h1#index"); + }); + }); + }); + + test.describe("When the root route has a boundary", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + files: getFiles({ includeRootErrorBoundary: true }), + }, + ServerMode.Development + ); + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => appFixture.close()); + + test.describe("document requests", () => { + test("renders root boundary on loader errors", async () => { + let res = await fixture.requestDocument("/loader-error"); + expect(res.status).toBe(500); + let text = await res.text(); + expect(text).toMatch("Root Error Boundary"); + expect(text).toMatch("Loader Error"); + expect(text).not.toMatch("Application Error"); + }); + + test("renders root boundary on render errors", async () => { + let res = await fixture.requestDocument("/render-error"); + expect(res.status).toBe(500); + let text = await res.text(); + expect(text).toMatch("Root Error Boundary"); + expect(text).toMatch("Render Error"); + expect(text).not.toMatch("Application Error"); + }); + }); + + test.describe("SPA navigations", () => { + test("renders root boundary on loader errors", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/loader-error"); + await page.waitForSelector("#root-error-boundary"); + let html = await app.getHtml(); + expect(html).toMatch("Root Error Boundary"); + expect(html).toMatch("Loader Error"); + expect(html).not.toMatch("Application Error"); + + // Ensure we can click back to our prior page + await app.goBack(); + await page.waitForSelector("h1#index"); + }); + + test("renders root boundary on render errors", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/render-error"); + await page.waitForSelector("#root-error-boundary"); + let html = await app.getHtml(); + expect(html).toMatch("Root Error Boundary"); + expect(html).toMatch("Render Error"); + expect(html).not.toMatch("Application Error"); + + // Ensure we can click back to our prior page + await app.goBack(); + await page.waitForSelector("h1#index"); + }); + }); + }); + + test.describe("When the root route has a boundary but it also throws ๐Ÿ˜ฆ", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: getFiles({ + includeRootErrorBoundary: true, + rootErrorBoundaryThrows: true, + }), + }); + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => appFixture.close()); + + test.describe("document requests", () => { + test("tries to render root boundary on loader errors but bubbles to default boundary", async () => { + let res = await fixture.requestDocument("/loader-error"); + expect(res.status).toBe(500); + let text = await res.text(); + expect(text).toMatch("Unexpected Server Error"); + expect(text).not.toMatch("Application Error"); + expect(text).not.toMatch("Loader Error"); + expect(text).not.toMatch("Root Error Boundary"); + }); + + test("tries to render root boundary on render errors but bubbles to default boundary", async () => { + let res = await fixture.requestDocument("/render-error"); + expect(res.status).toBe(500); + let text = await res.text(); + expect(text).toMatch("Unexpected Server Error"); + expect(text).not.toMatch("Application Error"); + expect(text).not.toMatch("Render Error"); + expect(text).not.toMatch("Root Error Boundary"); + }); + }); + + test.describe("SPA navigations", () => { + test("tries to render root boundary on loader errors but bubbles to default boundary", async ({ + page, + }, workerInfo) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/loader-error"); + await page.waitForSelector("pre"); + let html = await app.getHtml(); + expect(html).toMatch("Application Error"); + if (workerInfo.project.name === "chromium") { + expect(html).toMatch("ReferenceError: oh is not defined"); + } + expect(html).not.toMatch("Loader Error"); + expect(html).not.toMatch("Root Error Boundary"); + + // Ensure we can click back to our prior page + await app.goBack(); + await page.waitForSelector("h1#index"); + }); + + test("tries to render root boundary on render errors but bubbles to default boundary", async ({ + page, + }, workerInfo) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink("/render-error"); + await page.waitForSelector("pre"); + let html = await app.getHtml(); + expect(html).toMatch("Application Error"); + if (workerInfo.project.name === "chromium") { + expect(html).toMatch("ReferenceError: oh is not defined"); + } + expect(html).not.toMatch("Render Error"); + expect(html).not.toMatch("Root Error Boundary"); + + // Ensure we can click back to our prior page + await app.goBack(); + await page.waitForSelector("h1#index"); + }); + }); + }); +}); + +test("Allows back-button out of an error boundary after a hard reload", async ({ + page, + browserName, +}) => { + let _consoleError = console.error; + console.error = () => {}; + + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts, useRouteError } from "react-router"; + + export default function App() { + return ( + + + + + + + + + + + ); + } + + export function ErrorBoundary() { + let error = useRouteError(); + return ( + + + Oh no! + + + + +

ERROR BOUNDARY

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

INDEX

+ This will error +
+ ); + } + `, + + "app/routes/boom.tsx": js` + import { json } from "react-router"; + export function loader() { return boom(); } + export default function() { return my page; } + `, + }, + }); + + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/"); + await page.waitForSelector("#index"); + expect(app.page.url()).not.toMatch("/boom"); + + await app.clickLink("/boom"); + await page.waitForSelector("#error"); + expect(app.page.url()).toMatch("/boom"); + + await app.reload(); + await page.waitForSelector("#error"); + expect(app.page.url()).toMatch("boom"); + + await app.goBack(); + + // Here be dragons + // - Playwright sets the Firefox `fission.webContentIsolationStrategy=0` preference + // for reasons having to do with out-of-process iframes: + // https://github.com/microsoft/playwright/issues/22640#issuecomment-1543287282 + // - That preference exposes a bug in firefox where a hard reload adds to the + // history stack: https://bugzilla.mozilla.org/show_bug.cgi?id=1832341 + // - Your can disable this preference via the Playwright `firefoxUserPrefs` config, + // but that is broken until 1.34: + // https://github.com/microsoft/playwright/issues/22640#issuecomment-1546230104 + // https://github.com/microsoft/playwright/issues/15405 + // - We can't yet upgrade to 1.34 because it drops support for Node 14: + // https://github.com/microsoft/playwright/releases/tag/v1.34.0 + // + // So for now when in firefox we just navigate back twice to work around the issue + if (browserName === "firefox") { + await app.goBack(); + } + + await page.waitForSelector("#index"); + expect(app.page.url()).not.toContain("boom"); + + appFixture.close(); + console.error = _consoleError; +}); diff --git a/integration/error-boundary-v2-test.ts b/integration/error-boundary-v2-test.ts new file mode 100644 index 0000000000..6cb52156bc --- /dev/null +++ b/integration/error-boundary-v2-test.ts @@ -0,0 +1,257 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; + +import { UNSAFE_ServerMode as ServerMode } from "react-router"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let oldConsoleError: () => void; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + `, + + "app/routes/parent.tsx": js` + import { + Link, + Outlet, + isRouteErrorResponse, + useLoaderData, + useRouteError, + } from "react-router"; + + export function loader() { + return "PARENT LOADER"; + } + + export default function Component() { + return ( +
+ +

{useLoaderData()}

+ +
+ ) + } + + export function ErrorBoundary() { + let error = useRouteError(); + return isRouteErrorResponse(error) ? +

{error.status + ' ' + error.data}

: +

{error.message}

; + } + `, + + "app/routes/parent.child-with-boundary.tsx": js` + import { + isRouteErrorResponse, + useLoaderData, + useLocation, + useRouteError, + } from "react-router"; + + export function loader({ request }) { + let errorType = new URL(request.url).searchParams.get('type'); + if (errorType === 'response') { + throw new Response('Loader Response', { status: 418 }); + } else if (errorType === 'error') { + throw new Error('Loader Error'); + } + return "CHILD LOADER"; + } + + export default function Component() {; + let data = useLoaderData(); + if (new URLSearchParams(useLocation().search).get('type') === "render") { + throw new Error("Render Error"); + } + return

{data}

; + } + + export function ErrorBoundary() { + let error = useRouteError(); + return isRouteErrorResponse(error) ? +

{error.status + ' ' + error.data}

: +

{error.message}

; + } + `, + + "app/routes/parent.child-without-boundary.tsx": js` + import { useLoaderData, useLocation } from "react-router"; + + export function loader({ request }) { + let errorType = new URL(request.url).searchParams.get('type'); + if (errorType === 'response') { + throw new Response('Loader Response', { status: 418 }); + } else if (errorType === 'error') { + throw new Error('Loader Error'); + } + return "CHILD LOADER"; + } + + export default function Component() {; + let data = useLoaderData(); + if (new URLSearchParams(useLocation().search).get('type') === "render") { + throw new Error("Render Error"); + } + return

{data}

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.beforeEach(({ page }) => { + oldConsoleError = console.error; + console.error = () => {}; + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runBoundaryTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runBoundaryTests(); + + test("Network errors that never reach the Remix server", async ({ + page, + }) => { + // Cause a .data request to trigger an HTTP error that never reaches the + // Remix server, and ensure we properly handle it at the ErrorBoundary + await page.route(/\/parent\/child-with-boundary\.data$/, (route) => { + route.fulfill({ status: 500, body: "CDN Error!" }); + }); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert( + page, + app, + "#parent-error", + "Unable to decode turbo-stream response" + ); + }); + }); + + function runBoundaryTests() { + test("No errors", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary"); + await waitForAndAssert(page, app, "#child-data", "CHILD LOADER"); + }); + + test("Throwing a Response to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=response"); + await waitForAndAssert( + page, + app, + "#child-error-response", + "418 Loader Response" + ); + }); + + test("Throwing an Error to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=error"); + await waitForAndAssert(page, app, "#child-error", "Loader Error"); + }); + + test("Throwing a render error to own boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-with-boundary?type=render"); + await waitForAndAssert(page, app, "#child-error", "Render Error"); + }); + + test("Throwing a Response to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=response"); + await waitForAndAssert( + page, + app, + "#parent-error-response", + "418 Loader Response" + ); + }); + + test("Throwing an Error to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=error"); + await waitForAndAssert(page, app, "#parent-error", "Loader Error"); + }); + + test("Throwing a render error to parent boundary", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent"); + await app.clickLink("/parent/child-without-boundary?type=render"); + await waitForAndAssert(page, app, "#parent-error", "Render Error"); + }); + } +}); + +// Shorthand util to wait for an element to appear before asserting it +async function waitForAndAssert( + page: Page, + app: PlaywrightFixture, + selector: string, + match: string +) { + await page.waitForSelector(selector); + expect(await app.getHtml(selector)).toMatch(match); +} diff --git a/integration/error-data-request-test.ts b/integration/error-data-request-test.ts new file mode 100644 index 0000000000..057cc6dd82 --- /dev/null +++ b/integration/error-data-request-test.ts @@ -0,0 +1,176 @@ +import { test, expect } from "@playwright/test"; +import { UNSAFE_ErrorResponseImpl as ErrorResponseImpl } from "react-router"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; + +test.describe("ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let _consoleError: any; + let errorLogs: any[]; + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = (v) => errorLogs.push(v); + + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { Link, Form } from "react-router"; + + export default function () { + return

Index

+ } + `, + + [`app/routes/loader-throw-error.jsx`]: js` + export async function loader() { + throw Error("BLARGH"); + } + + export default function () { + return

Hello

+ } + `, + + [`app/routes/loader-return-json.jsx`]: js` + export async function loader() { + return { ok: true }; + } + + export default function () { + return

Hello

+ } + `, + + [`app/routes/action-throw-error.jsx`]: js` + export async function action() { + throw Error("YOOOOOOOO WHAT ARE YOU DOING"); + } + + export default function () { + return

Goodbye

; + } + `, + + [`app/routes/action-return-json.jsx`]: js` + export async function action() { + return { ok: true }; + } + + export default function () { + return

Hi!

+ } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.beforeEach(async () => { + errorLogs = []; + }); + + test.afterAll(() => { + console.error = _consoleError; + appFixture.close(); + }); + + function assertLoggedErrorInstance(message: string) { + let error = errorLogs[0] as Error; + expect(error).toBeInstanceOf(Error); + expect(error.message).toEqual(message); + } + + test("returns a 200 empty response on a data fetch to a path with no loaders", async () => { + let { status, headers, data } = await fixture.requestSingleFetchData( + "/_root.data" + ); + expect(status).toBe(200); + expect(headers.has("X-Remix-Error")).toBe(false); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: null, + }, + }); + }); + + test("returns a 405 on a data fetch POST to a path with no action", async () => { + let { status, headers, data } = await fixture.requestSingleFetchData( + "/_root.data?index", + { + method: "POST", + } + ); + expect(status).toBe(405); + expect(headers.has("X-Remix-Error")).toBe(false); + expect(data).toEqual({ + error: new ErrorResponseImpl( + 405, + "Method Not Allowed", + 'Error: You made a POST request to "/" but did not provide an `action` for route "routes/_index", so there is no way to handle the request.' + ), + }); + assertLoggedErrorInstance( + 'You made a POST request to "/" but did not provide an `action` for route "routes/_index", so there is no way to handle the request.' + ); + }); + + test("returns a 405 on a data fetch with a bad method", async () => { + try { + await fixture.requestSingleFetchData("/loader-return-json.data", { + method: "TRACE", + }); + expect(false).toBe(true); + } catch (e) { + expect((e as Error).message).toMatch( + "'TRACE' HTTP method is unsupported." + ); + } + }); + + test("returns a 404 on a data fetch to a path with no matches", async () => { + let { status, headers, data } = await fixture.requestSingleFetchData( + "/i/match/nothing.data" + ); + expect(status).toBe(404); + expect(headers.has("X-Remix-Error")).toBe(false); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/i/match/nothing"' + ), + }, + }); + assertLoggedErrorInstance('No route matches URL "/i/match/nothing"'); + }); +}); diff --git a/integration/error-sanitization-test.ts b/integration/error-sanitization-test.ts new file mode 100644 index 0000000000..e0614e428e --- /dev/null +++ b/integration/error-sanitization-test.ts @@ -0,0 +1,731 @@ +import { test, expect } from "@playwright/test"; +import { + UNSAFE_ErrorResponseImpl as ErrorResponseImpl, + UNSAFE_ServerMode as ServerMode, +} from "react-router"; + +import type { Fixture } from "./helpers/create-fixture.js"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +const routeFiles = { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + +
+ +
+ + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { useLoaderData, useLocation, useRouteError } from "react-router"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has('loader')) { + throw new Error("Loader Error"); + } + if (new URL(request.url).searchParams.has('subclass')) { + // This will throw a ReferenceError + console.log(thisisnotathing); + } + return "LOADER" + } + + export default function Component() { + let data = useLoaderData(); + let location = useLocation(); + + if (location.search.includes('render')) { + throw new Error("Render Error"); + } + + return ( + <> +

Index Route

+

{JSON.stringify(data)}

+ + ); + } + + export function ErrorBoundary() { + let error = useRouteError(); + return ( + <> +

Index Error

+

{"MESSAGE:" + error.message}

+

{"NAME:" + error.name}

+ {error.stack ?

{"STACK:" + error.stack}

: null} + + ); + } + `, + + "app/routes/defer.tsx": js` + import * as React from 'react'; + import { Await, useAsyncError, useLoaderData, useRouteError } from "react-router"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has('loader')) { + return { + lazy: Promise.reject(new Error("REJECTED")), + }; + } + return { + lazy: Promise.resolve("RESOLVED"), + }; + } + + export default function Component() { + let data = useLoaderData(); + + return ( + <> +

Defer Route

+ Loading...

}> + }> + {(val) =>

{val}

} +
+
+ + ); + } + + function AwaitError() { + let error = useAsyncError(); + return ( + <> +

Defer Error

+

{error.message}

+ + ); + } + + export function ErrorBoundary() { + let error = useRouteError(); + return ( + <> +

Defer Error

+

{"MESSAGE:" + error.message}

+ {error.stack ?

{"STACK:" + error.stack}

: null} + + ); + } + `, + + "app/routes/resource.tsx": js` + export function loader({ request }) { + if (new URL(request.url).searchParams.has('loader')) { + throw new Error("Loader Error"); + } + return "RESOURCE LOADER" + } + `, +}; + +test.describe("Error Sanitization", () => { + let fixture: Fixture; + let oldConsoleError: () => void; + let errorLogs: any[] = []; + + test.beforeEach(() => { + oldConsoleError = console.error; + errorLogs = []; + console.error = (...args) => errorLogs.push(args); + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("serverMode=production", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + files: routeFiles, + }, + ServerMode.Production + ); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value + expect(html).toMatch( + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + ); + expect(html).not.toMatch(/ at /i); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("sanitizes render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value + expect(html).toMatch( + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + ); + expect(html).not.toMatch(/ at /i); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Render Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).not.toMatch("x.stack=e.stack;"); + }); + + test("sanitizes defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch("Unexpected Server Error"); + expect(html).not.toMatch("stack"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: "LOADER", + }, + }); + }); + + test("sanitizes loader errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data?loader"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + error: new Error("Unexpected Server Error"), + }, + }); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("returns deferred data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); + }); + + test("sanitizes loader errors in deferred data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("Unexpected Server Error"); + expect((e as Error).stack).toBeUndefined(); + } + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("sanitizes loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not sanitize mismatched route errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/not-a-route.data"); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"' + ), + }, + }); + expect(errorLogs).toEqual([ + [new Error('No route matches URL "/not-a-route"')], + ]); + }); + + test("does not support hydration of Error subclasses", async ({ page }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("

MESSAGE:Unexpected Server Error"); + expect(html).toMatch("

NAME:Error"); + + // Hydration + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/?subclass", true); + html = await app.getHtml(); + expect(html).toMatch("

MESSAGE:Unexpected Server Error"); + expect(html).toMatch("

NAME:Error"); + }); + }); + + test.describe("serverMode=development", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + files: routeFiles, + }, + ServerMode.Development + ); + }); + let ogEnv = process.env.NODE_ENV; + test.beforeEach(() => { + ogEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + }); + test.afterEach(() => { + process.env.NODE_ENV = ogEnv; + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("does not sanitize loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("

MESSAGE:Loader Error"); + expect(html).toMatch("

STACK:Error: Loader Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not sanitize render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("

MESSAGE:Render Error"); + expect(html).toMatch("

STACK:Error: Render Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Render Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/"stack":/i); + }); + + test("does not sanitize defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch("

REJECTED

"); + expect(html).toMatch("Error: REJECTED\\\\n at "); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: "LOADER", + }, + }); + }); + + test("does not sanitize loader errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data?loader"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + error: new Error("Loader Error"), + }, + }); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("returns deferred data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toEqual("RESOLVED"); + }); + + test("does not sanitize loader errors in deferred data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("REJECTED"); + expect((e as Error).stack).not.toBeUndefined(); + } + + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("does not sanitize loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error\n\nError: Loader Error"); + expect(errorLogs.length).toBe(1); + expect(errorLogs[0][0].message).toMatch("Loader Error"); + expect(errorLogs[0][0].stack).toMatch(" at "); + }); + + test("does not sanitize mismatched route errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/not-a-route.data"); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"' + ), + }, + }); + expect(errorLogs).toEqual([ + [new Error('No route matches URL "/not-a-route"')], + ]); + }); + + test("supports hydration of Error subclasses", async ({ page }) => { + let response = await fixture.requestDocument("/?subclass"); + let html = await response.text(); + expect(html).toMatch("

MESSAGE:thisisnotathing is not defined"); + expect(html).toMatch("

NAME:ReferenceError"); + expect(html).toMatch( + "

STACK:ReferenceError: thisisnotathing is not defined" + ); + + // Hydration + let appFixture = await createAppFixture(fixture, ServerMode.Development); + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/?subclass", true); + html = await app.getHtml(); + expect(html).toMatch("

MESSAGE:thisisnotathing is not defined"); + expect(html).toMatch("

NAME:ReferenceError"); + expect(html).toMatch( + "STACK:ReferenceError: thisisnotathing is not defined" + ); + }); + }); + + test.describe("serverMode=production (user-provided handleError)", () => { + test.beforeAll(async () => { + fixture = await createFixture( + { + files: { + "app/entry.server.tsx": js` + import { PassThrough } from "node:stream"; + + import { createReadableStreamFromReadable } from "@react-router/node"; + import { ServerRouter, isRouteErrorResponse } from "react-router"; + import { renderToPipeableStream } from "react-dom/server"; + + const ABORT_DELAY = 5_000; + + export default function handleRequest( + request, + responseStatusCode, + responseHeaders, + remixContext + ) { + return new Promise((resolve, reject) => { + let shellRendered = false; + const { pipe, abort } = renderToPipeableStream( + , + { + onShellReady() { + shellRendered = true; + const body = new PassThrough(); + const stream = createReadableStreamFromReadable(body); + + responseHeaders.set("Content-Type", "text/html"); + + resolve( + new Response(stream, { + headers: responseHeaders, + status: responseStatusCode, + }) + ); + + pipe(body); + }, + onShellError(error) { + reject(error); + }, + onError(error) { + responseStatusCode = 500; + // Log streaming rendering errors from inside the shell. Don't log + // errors encountered during initial shell rendering since they'll + // reject and get logged in handleDocumentRequest. + if (shellRendered) { + console.error(error); + } + }, + } + ); + + setTimeout(abort, ABORT_DELAY); + }); + } + + export function handleError( + error: unknown, + { request }: { request: Request }, + ) { + console.error("App Specific Error Logging:"); + console.error(" Request: " + request.method + " " + request.url); + if (isRouteErrorResponse(error)) { + console.error(" Status: " + error.status + " " + error.statusText); + console.error(" Error: " + error.error.message); + console.error(" Stack: " + error.error.stack); + } else if (error instanceof Error) { + console.error(" Error: " + error.message); + console.error(" Stack: " + error.stack); + } else { + console.error("Dunno what this is"); + } + } + `, + ...routeFiles, + }, + }, + ServerMode.Production + ); + }); + + test("renders document without errors", async () => { + let response = await fixture.requestDocument("/"); + let html = await response.text(); + expect(html).toMatch("Index Route"); + expect(html).toMatch("LOADER"); + expect(html).not.toMatch("MESSAGE:"); + expect(html).not.toMatch(/stack/i); + }); + + test("sanitizes loader errors in document requests", async () => { + let response = await fixture.requestDocument("/?loader"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).not.toMatch("LOADER"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value + expect(html).toMatch( + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + ); + expect(html).not.toMatch(/ at /i); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?loader"); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("sanitizes render errors in document requests", async () => { + let response = await fixture.requestDocument("/?render"); + let html = await response.text(); + expect(html).toMatch("Index Error"); + expect(html).toMatch("MESSAGE:Unexpected Server Error"); + // This is the turbo-stream encoding - the fact that stack goes right + // into __type means it has no value + expect(html).toMatch( + '\\"message\\",\\"Unexpected Server Error\\",\\"stack\\",\\"__type\\",\\"Error\\"' + ); + expect(html).not.toMatch(/ at /i); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual(" Request: GET test://test/?render"); + expect(errorLogs[2][0]).toEqual(" Error: Render Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("renders deferred document without errors", async () => { + let response = await fixture.requestDocument("/defer"); + let html = await response.text(); + expect(html).toMatch("Defer Route"); + expect(html).toMatch("RESOLVED"); + expect(html).not.toMatch("MESSAGE:"); + // Defer errors are not not part of the JSON blob but rather rejected + // against a pending promise and therefore are inlined JS. + expect(html).not.toMatch("x.stack=e.stack;"); + }); + + test("sanitizes defer errors in document requests", async () => { + let response = await fixture.requestDocument("/defer?loader"); + let html = await response.text(); + expect(html).toMatch("Defer Error"); + expect(html).not.toMatch("RESOLVED"); + expect(html).toMatch("Unexpected Server Error"); + expect(html).not.toMatch("stack"); + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("returns data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data"); + expect(data).toEqual({ + root: { + data: null, + }, + "routes/_index": { + data: "LOADER", + }, + }); + }); + + test("sanitizes loader errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/_root.data?loader"); + expect(data).toEqual({ + root: { data: null }, + "routes/_index": { error: new Error("Unexpected Server Error") }, + }); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/_root.data?loader" + ); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("returns deferred data without errors", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data"); + // @ts-expect-error + expect(await data["routes/defer"].data.lazy).toBe("RESOLVED"); + }); + + test("sanitizes loader errors in deferred data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/defer.data?loader"); + try { + // @ts-expect-error + await data["routes/defer"].data.lazy; + expect(true).toBe(false); + } catch (e) { + expect((e as Error).message).toBe("Unexpected Server Error"); + expect((e as Error).stack).toBeUndefined(); + } + // defer errors are not logged to the server console since the request + // has "succeeded" + expect(errorLogs.length).toBe(0); + }); + + test("sanitizes loader errors in resource requests", async () => { + let response = await fixture.requestResource("/resource?loader"); + let text = await response.text(); + expect(text).toBe("Unexpected Server Error"); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/resource?loader" + ); + expect(errorLogs[2][0]).toEqual(" Error: Loader Error"); + expect(errorLogs[3][0]).toMatch(" at "); + expect(errorLogs.length).toBe(4); + }); + + test("does not sanitize mismatched route errors in data requests", async () => { + let { data } = await fixture.requestSingleFetchData("/not-a-route.data"); + expect(data).toEqual({ + root: { + error: new ErrorResponseImpl( + 404, + "Not Found", + 'Error: No route matches URL "/not-a-route"' + ), + }, + }); + expect(errorLogs[0][0]).toEqual("App Specific Error Logging:"); + expect(errorLogs[1][0]).toEqual( + " Request: GET test://test/not-a-route.data" + ); + expect(errorLogs[2][0]).toEqual(" Status: 404 Not Found"); + expect(errorLogs[3][0]).toEqual( + ' Error: No route matches URL "/not-a-route"' + ); + expect(errorLogs[4][0]).toMatch(" at "); + expect(errorLogs.length).toBe(5); + }); + }); +}); diff --git a/integration/fetch-globals-test.ts b/integration/fetch-globals-test.ts new file mode 100644 index 0000000000..f7a5c3fe42 --- /dev/null +++ b/integration/fetch-globals-test.ts @@ -0,0 +1,43 @@ +import { test, expect } from "@playwright/test"; + +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { json } from "react-router"; + import { useLoaderData } from "react-router"; + export async function loader() { + const resp = await fetch('https://reqres.in/api/users?page=2'); + return (resp instanceof Response) ? 'is an instance of global Response' : 'is not an instance of global Response'; + } + export default function Index() { + let data = useLoaderData(); + return ( +

+ {data} +
+ ) + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(async () => appFixture.close()); + +test("returned variable from fetch() should be instance of global Response", async () => { + let response = await fixture.requestDocument("/"); + expect(await response.text()).toMatch("is an instance of global Response"); +}); diff --git a/integration/fetcher-layout-test.ts b/integration/fetcher-layout-test.ts new file mode 100644 index 0000000000..b4113fd8a9 --- /dev/null +++ b/integration/fetcher-layout-test.ts @@ -0,0 +1,276 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/layout-action.tsx": js` + import { Outlet, useFetcher, useFormAction } from "react-router"; + + export let action = ({ params }) => "layout action data"; + + export default function ActionLayout() { + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.submit({}, { method: "post", action }); + }; + + return ( +
+

Layout

+ + {!!fetcher.data &&

{fetcher.data}

} + +
+ ); + } + `, + + "app/routes/layout-action._index.tsx": js` + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router"; + + export let loader = ({ params }) => "index data"; + + export let action = ({ params }) => "index action data"; + + export default function ActionLayoutIndex() { + let data = useLoaderData(); + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.submit({}, { method: "post", action }) + }; + + return ( + <> +

{data}

+ + {!!fetcher.data &&

{fetcher.data}

} + + ); + } + `, + + "app/routes/layout-action.$param.tsx": js` + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router"; + + export let loader = ({ params }) => params.param; + + export let action = ({ params }) => "param action data"; + + export default function ActionLayoutChild() { + let data = useLoaderData(); + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.submit({}, { method: "post", action }) + }; + + return ( + <> +

{data}

+ + {!!fetcher.data &&

{fetcher.data}

} + + ); + } + `, + + "app/routes/layout-loader.tsx": js` + import { Outlet, useFetcher, useFormAction } from "react-router"; + + export let loader = () => "layout loader data"; + + export default function LoaderLayout() { + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.load(action); + }; + + return ( +
+

Layout

+ + {!!fetcher.data &&

{fetcher.data}

} + +
+ ); + } + `, + + "app/routes/layout-loader._index.tsx": js` + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router"; + + export let loader = ({ params }) => "index data"; + + export default function ActionLayoutIndex() { + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.load(action); + }; + + return ( + <> + + {!!fetcher.data &&

{fetcher.data}

} + + ); + } + `, + + "app/routes/layout-loader.$param.tsx": js` + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router"; + + export let loader = ({ params }) => params.param; + + export default function ActionLayoutChild() { + let fetcher = useFetcher(); + let action = useFormAction(); + + let invokeFetcher = () => { + fetcher.load(action); + }; + + return ( + <> + + {!!fetcher.data &&

{fetcher.data}

} + + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); +}); + +test.afterAll(() => { + appFixture.close(); +}); + +test("fetcher calls layout route action when at index route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action"); + await app.clickElement("#layout-fetcher"); + await page.waitForSelector("#layout-fetcher-data"); + let dataElement = await app.getElement("#layout-fetcher-data"); + expect(dataElement.text()).toBe("layout action data"); + dataElement = await app.getElement("#child-data"); + expect(dataElement.text()).toBe("index data"); +}); + +test("fetcher calls layout route loader when at index route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader"); + await app.clickElement("#layout-fetcher"); + await page.waitForSelector("#layout-fetcher-data"); + let dataElement = await app.getElement("#layout-fetcher-data"); + expect(dataElement.text()).toBe("layout loader data"); +}); + +test("fetcher calls index route action when at index route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action"); + await app.clickElement("#index-fetcher"); + await page.waitForSelector("#index-fetcher-data"); + let dataElement = await app.getElement("#index-fetcher-data"); + expect(dataElement.text()).toBe("index action data"); + dataElement = await app.getElement("#child-data"); + expect(dataElement.text()).toBe("index data"); +}); + +test("fetcher calls index route loader when at index route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader"); + await app.clickElement("#index-fetcher"); + await page.waitForSelector("#index-fetcher-data"); + let dataElement = await app.getElement("#index-fetcher-data"); + expect(dataElement.text()).toBe("index data"); +}); + +test("fetcher calls layout route action when at paramaterized route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action/foo"); + await app.clickElement("#layout-fetcher"); + await page.waitForSelector("#layout-fetcher-data"); + let dataElement = await app.getElement("#layout-fetcher-data"); + expect(dataElement.text()).toBe("layout action data"); + dataElement = await app.getElement("#child-data"); + expect(dataElement.text()).toBe("foo"); +}); + +test("fetcher calls layout route loader when at parameterized route", async ({ + page, +}) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader/foo"); + await app.clickElement("#layout-fetcher"); + await page.waitForSelector("#layout-fetcher-data"); + let dataElement = await app.getElement("#layout-fetcher-data"); + expect(dataElement.text()).toBe("layout loader data"); +}); + +test("fetcher calls parameterized route route action", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-action/foo"); + await app.clickElement("#param-fetcher"); + await page.waitForSelector("#param-fetcher-data"); + let dataElement = await app.getElement("#param-fetcher-data"); + expect(dataElement.text()).toBe("param action data"); + dataElement = await app.getElement("#child-data"); + expect(dataElement.text()).toBe("foo"); +}); + +test("fetcher calls parameterized route route loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/layout-loader/foo"); + await app.clickElement("#param-fetcher"); + await page.waitForSelector("#param-fetcher-data"); + let dataElement = await app.getElement("#param-fetcher-data"); + expect(dataElement.text()).toBe("foo"); +}); diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts new file mode 100644 index 0000000000..138300cb25 --- /dev/null +++ b/integration/fetcher-test.ts @@ -0,0 +1,528 @@ +import { expect, test } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("useFetcher", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let CHEESESTEAK = "CHEESESTEAK"; + let LUNCH = "LUNCH"; + let PARENT_LAYOUT_LOADER = "parent layout loader"; + let PARENT_LAYOUT_ACTION = "parent layout action"; + let PARENT_INDEX_LOADER = "parent index loader"; + let PARENT_INDEX_ACTION = "parent index action"; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/resource-route-action-only.ts": js` + import { json } from "react-router"; + export function action() { + return new Response("${CHEESESTEAK}"); + } + `, + + "app/routes/fetcher-action-only-call.tsx": js` + import { useFetcher } from "react-router"; + + export default function FetcherActionOnlyCall() { + let fetcher = useFetcher(); + + let executeFetcher = () => { + fetcher.submit(new URLSearchParams(), { + method: 'post', + action: '/resource-route-action-only', + }); + }; + + return ( + <> + + {fetcher.data &&
{fetcher.data}
} + + ); + } + `, + + "app/routes/resource-route.tsx": js` + export function loader() { + return new Response("${LUNCH}"); + } + export function action() { + return new Response("${CHEESESTEAK}"); + } + `, + + "app/routes/_index.tsx": js` + import { useFetcher } from "react-router"; + export default function Index() { + let fetcher = useFetcher(); + return ( + <> + + + + + + +
{fetcher.data}
+ + ); + } + `, + + "app/routes/parent.tsx": js` + import { Outlet } from "react-router"; + + export function action() { + return new Response("${PARENT_LAYOUT_ACTION}"); + }; + + export function loader() { + return new Response("${PARENT_LAYOUT_LOADER}"); + }; + + export default function Parent() { + return ; + } + `, + + "app/routes/parent._index.tsx": js` + import { useFetcher } from "react-router"; + + export function action() { + return new Response("${PARENT_INDEX_ACTION}"); + }; + + export function loader() { + return new Response("${PARENT_INDEX_LOADER}"); + }; + + export default function ParentIndex() { + let fetcher = useFetcher(); + + return ( + <> +
{fetcher.data}
+ + + + + + + + + ); + } + `, + + "app/routes/fetcher-echo.tsx": js` + import { useFetcher } from "react-router"; + + export async function action({ request }) { + await new Promise(r => setTimeout(r, 1000)); + let contentType = request.headers.get('Content-Type'); + let value; + if (contentType.includes('application/json')) { + let json = await request.json(); + value = json === null ? json : json.value; + } else if (contentType.includes('text/plain')) { + value = await request.text(); + } else { + value = (await request.formData()).get('value'); + } + return { data: "ACTION (" + contentType + ") " + value } + } + + export async function loader({ request }) { + await new Promise(r => setTimeout(r, 1000)); + let value = new URL(request.url).searchParams.get('value'); + return { data: "LOADER " + value } + } + + export default function Index() { + let fetcherValues = []; + if (typeof window !== 'undefined') { + if (!window.fetcherValues) { + window.fetcherValues = []; + } + fetcherValues = window.fetcherValues + } + + let fetcher = useFetcher(); + + let currentValue = fetcher.state + '/' + fetcher.data?.data; + if (fetcherValues[fetcherValues.length - 1] !== currentValue) { + fetcherValues.push(currentValue) + } + + return ( + <> + + + + + + + + + {fetcher.state === 'idle' ?

IDLE

: null} +
{JSON.stringify(fetcherValues)}
+ + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("No JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + test("Form can hit a loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + await Promise.all([ + page.waitForNavigation(), + app.clickSubmitButton("/resource-route", { + wait: false, + method: "get", + }), + ]); + // Check full HTML here - Chromium/Firefox/Webkit seem to render this in + // a
 but Edge puts it in some weird code editor markup:
+      // 
+      //   
+      expect(await app.getHtml()).toContain(LUNCH);
+    });
+
+    test("Form can hit an action", async ({ page }) => {
+      let app = new PlaywrightFixture(appFixture, page);
+      await app.goto("/");
+      await Promise.all([
+        page.waitForNavigation({ waitUntil: "load" }),
+        app.clickSubmitButton("/resource-route", {
+          wait: false,
+          method: "post",
+        }),
+      ]);
+      // Check full HTML here - Chromium/Firefox/Webkit seem to render this in
+      // a 
 but Edge puts it in some weird code editor markup:
+      // 
+      //   
+      expect(await app.getHtml()).toContain(CHEESESTEAK);
+    });
+  });
+
+  test("load can hit a loader", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/");
+    await app.clickElement("#fetcher-load");
+    await page.waitForSelector(`pre:has-text("${LUNCH}")`);
+  });
+
+  test("submit can hit an action", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/");
+    await app.clickElement("#fetcher-submit");
+    await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
+  });
+
+  test("submit can hit an action with json", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/fetcher-echo", true);
+    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"'
+    );
+  });
+
+  test("submit can hit an action with null json", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/fetcher-echo", true);
+    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"');
+  });
+
+  test("submit can hit an action with text", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/fetcher-echo", true);
+    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"'
+    );
+  });
+
+  test("submit can hit an action with empty text", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/fetcher-echo", true);
+    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) "');
+  });
+
+  test("submit can hit an action only route", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/fetcher-action-only-call");
+    await app.clickElement("#fetcher-submit");
+    await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`);
+  });
+
+  test("fetchers handle ?index param correctly", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+    await app.goto("/parent");
+
+    await app.clickElement("#load-parent");
+    await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`);
+
+    await app.clickElement("#load-index");
+    await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
+
+    // fetcher.submit({}) defaults to GET for the current Route
+    await app.clickElement("#submit-empty");
+    await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
+
+    await app.clickElement("#submit-parent-get");
+    await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_LOADER}")`);
+
+    await app.clickElement("#submit-index-get");
+    await page.waitForSelector(`pre:has-text("${PARENT_INDEX_LOADER}")`);
+
+    await app.clickElement("#submit-parent-post");
+    await page.waitForSelector(`pre:has-text("${PARENT_LAYOUT_ACTION}")`);
+
+    await app.clickElement("#submit-index-post");
+    await page.waitForSelector(`pre:has-text("${PARENT_INDEX_ACTION}")`);
+  });
+
+  test("fetcher.load persists data through reloads", async ({ page }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+
+    await app.goto("/fetcher-echo", true);
+    expect(await app.getHtml("pre")).toMatch(
+      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(
+      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(
+      JSON.stringify([
+        "idle/undefined",
+        "loading/undefined",
+        "idle/LOADER 1",
+        "loading/LOADER 1", // Preserves old data during reload
+        "idle/LOADER 2",
+      ])
+    );
+  });
+
+  test("fetcher.submit persists data through resubmissions", async ({
+    page,
+  }) => {
+    let app = new PlaywrightFixture(appFixture, page);
+
+    await app.goto("/fetcher-echo", true);
+    expect(await app.getHtml("pre")).toMatch(
+      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(
+      JSON.stringify([
+        "idle/undefined",
+        "submitting/undefined",
+        "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+        "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+      ])
+    );
+
+    await page.fill("#fetcher-input", "2");
+    await app.clickElement("#fetcher-submit");
+    await page.waitForSelector("#fetcher-idle");
+    expect(await app.getHtml("pre")).toMatch(
+      JSON.stringify([
+        "idle/undefined",
+        "submitting/undefined",
+        "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+        "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+        // Preserves old data during resubmissions
+        "submitting/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 1",
+        "loading/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 2",
+        "idle/ACTION (application/x-www-form-urlencoded;charset=UTF-8) 2",
+      ])
+    );
+  });
+});
+
+test.describe("fetcher aborts and adjacent forms", () => {
+  let fixture: Fixture;
+  let appFixture: AppFixture;
+
+  test.beforeAll(async () => {
+    fixture = await createFixture({
+      files: {
+        "app/routes/_index.tsx": js`
+          import * as React from "react";
+          import {
+            Form,
+            useFetcher,
+            useLoaderData,
+            useNavigation
+          } from "react-router";
+
+          export async function loader({ request }) {
+            // 1 second timeout on data
+            await new Promise((r) => setTimeout(r, 1000));
+            return { foo: 'bar' };
+          }
+
+          export default function Index() {
+            const [open, setOpen] = React.useState(true);
+            const { data } = useLoaderData();
+            const navigation = useNavigation();
+
+            return (
+              
+ {navigation.state === 'idle' &&
Idle
} +
+ +
+ + + {open && setOpen(false)} />} +
+ ); + } + + function Child({ onClose }) { + const fetcher = useFetcher(); + + return ( + + + + + ); + } + `, + + "app/routes/api.tsx": js` + export async function loader() { + await new Promise((resolve) => setTimeout(resolve, 500)); + return { message: 'Hello world!' } + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("Unmounting a fetcher does not cancel the request of an adjacent form", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + // Works as expected before the fetcher is loaded + + // submit the main form and unmount the fetcher form + await app.clickElement("#submit-and-close"); + // Wait for our navigation state to be "Idle" + await page.waitForSelector("#idle", { timeout: 2000 }); + + // Breaks after the fetcher is loaded + + // re-mount the fetcher form + await app.clickElement("#open"); + // submit the fetcher form + await app.clickElement("#submit-fetcher"); + // submit the main form and unmount the fetcher form + await app.clickElement("#submit-and-close"); + // Wait for navigation state to be "Idle" + await page.waitForSelector("#idle", { timeout: 2000 }); + }); +}); diff --git a/integration/fog-of-war-test.ts b/integration/fog-of-war-test.ts new file mode 100644 index 0000000000..f422e42a1a --- /dev/null +++ b/integration/fog-of-war-test.ts @@ -0,0 +1,1276 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +function getFiles() { + return { + "app/root.tsx": js` + import * as React from "react"; + import { Link, Links, Meta, Outlet, Scripts } from "react-router"; + export default function Root() { + let [showLink, setShowLink] = React.useState(false); + return ( + + + + + + + Home
+ /a
+ + {showLink ? /a/b : null} + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

+ } + `, + + "app/routes/a.tsx": js` + import { Link, Outlet, useLoaderData } from "react-router"; + export function loader({ request }) { + return { message: "A LOADER" }; + } + export default function Index() { + let data = useLoaderData(); + return ( + <> +

A: {data.message}

+ /a/b + + + ) + } + `, + "app/routes/a.b.tsx": js` + import { Outlet, useLoaderData } from "react-router"; + export function loader({ request }) { + return { message: "B LOADER" }; + } + export default function Index() { + let data = useLoaderData(); + return ( + <> +

B: {data.message}

+ + + ) + } + `, + "app/routes/a.b.c.tsx": js` + import { Outlet, useLoaderData } from "react-router"; + export function loader({ request }) { + return { message: "C LOADER" }; + } + export default function Index() { + let data = useLoaderData(); + return

C: {data.message}

+ } + `, + }; +} + +test.describe("Fog of War", () => { + let oldConsoleError: typeof console.error; + + test.beforeEach(() => { + oldConsoleError = console.error; + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test("loads minimal manifest on initial load", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...getFiles(), + "app/entry.client.tsx": js` + import { HydratedRouter } from "react-router/dom"; + import { startTransition, StrictMode } from "react"; + import { hydrateRoot } from "react-dom/client"; + startTransition(() => { + hydrateRoot( + document, + + + + ); + }); + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + let res = await fixture.requestDocument("/"); + let html = await res.text(); + + expect(html).toContain('"root": {'); + expect(html).toContain('"routes/_index": {'); + expect(html).not.toContain('"routes/a"'); + + // Linking to A loads A and succeeds + await app.goto("/", true); + await app.clickLink("/a"); + await page.waitForSelector("#a"); + expect(await app.getHtml("#a")).toBe(`

A: A LOADER

`); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toContain("routes/a"); + }); + + test("prefetches initially rendered links", async ({ page }) => { + let fixture = await createFixture({ + files: getFiles(), + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickLink("/a"); + await page.waitForSelector("#a"); + expect(await app.getHtml("#a")).toBe(`

A: A LOADER

`); + }); + + test("prefetches links rendered via navigations", async ({ page }) => { + let fixture = await createFixture({ + files: getFiles(), + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickLink("/a"); + await page.waitForSelector("#a"); + + await page.waitForFunction( + () => (window as any).__reactRouterManifest.routes["routes/a.b"] + ); + + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a", "routes/a.b"]); + }); + + test("prefetches links rendered via in-page stateful updates", async ({ + page, + }) => { + let fixture = await createFixture({ + files: getFiles(), + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickElement("button"); + await page.waitForFunction( + () => (window as any).__reactRouterManifest.routes["routes/a.b"] + ); + + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a", "routes/a.b"]); + }); + + test("prefetches links who opt-into [data-discover] via an in-page stateful update", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + + + + + + ); + } + `, + "app/routes/_index.tsx": js` + import * as React from 'react'; + import { Link, Outlet, useLoaderData } from "react-router"; + export default function Index() { + let [discover, setDiscover] = React.useState(false) + return ( + <> + /a + + + ) + } + `, + "app/routes/a.tsx": js` + export default function Index() { + return

A

+ } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index"]); + + await app.clickElement("button"); + await page.waitForFunction( + () => (window as any).__reactRouterManifest.routes["routes/a"] + ); + + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + }); + + test('does not prefetch links with discover="none"', async ({ page }) => { + let fixture = await createFixture({ + files: { + ...getFiles(), + "app/routes/a.tsx": js` + import { Link, Outlet, useLoaderData } from "react-router"; + export function loader({ request }) { + return { message: "A LOADER" }; + } + export default function Index() { + let data = useLoaderData(); + return ( + <> +

A: {data.message}

+ /a/b + + + ) + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickLink("/a"); + await page.waitForSelector("#a"); + await new Promise((resolve) => setTimeout(resolve, 250)); + + // /a/b is not discovered yet even thought it's rendered + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + + // /a/b gets discovered on click + await app.clickLink("/a/b"); + await page.waitForSelector("#b"); + + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a", "routes/a.b"]); + }); + + test("prefetches initially rendered forms", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...getFiles(), + "app/root.tsx": js` + import * as React from "react"; + import { Form, Links, Meta, Outlet, Scripts } from "react-router"; + export default function Root() { + let [showLink, setShowLink] = React.useState(false); + return ( + + + + + + +
+ +
+ + + + + ); + } + `, + "app/routes/a.tsx": js` + import { useActionData } from "react-router"; + export function action() { + return { message: "A ACTION" }; + } + export default function Index() { + let actionData = useActionData(); + return

A: {actionData.message}

+ } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + await page.waitForFunction( + () => (window as any).__reactRouterManifest.routes["routes/a"] + ); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickSubmitButton("/a"); + await page.waitForSelector("#a"); + expect(await app.getHtml("#a")).toBe(`

A: A ACTION

`); + }); + + test("prefetches forms rendered via navigations", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...getFiles(), + "app/routes/a.tsx": js` + import { Form } from "react-router"; + export default function Component() { + return ( +
+ +
+ ); + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickLink("/a"); + await page.waitForSelector("form"); + + await page.waitForFunction( + () => (window as any).__reactRouterManifest.routes["routes/a.b"] + ); + + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a", "routes/a.b"]); + }); + + test("prefetches root index child when SSR-ing a deep route", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

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

Deep

+ Home + + ) + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/deep", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/deep", "routes/_index"]); + + // Without pre-loading the index, we'd "match" `/` to just the root route + // client side and never fetch the `routes/_index` route + await app.clickLink("/"); + await page.waitForSelector("#index"); + expect(await app.getHtml("#index")).toMatch(`Index`); + + expect(manifestRequests.length).toBe(0); + }); + + test("prefetches ancestor index children when SSR-ing a deep route", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

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

Parent

+ + + ) + } + `, + "app/routes/parent._index.tsx": js` + export default function Component() { + return

Parent Index

; + } + `, + "app/routes/parent.child.tsx": js` + import { Outlet } from "react-router"; + export default function Component() { + return ( + <> +

Child

+ + + ) + } + `, + "app/routes/parent.child._index.tsx": js` + export default function Component() { + return

Child Index

; + } + `, + "app/routes/parent.child.grandchild.tsx": js` + export default function Component() { + return

Grandchild

; + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/parent/child/grandchild", true); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual([ + "root", + "routes/parent", + "routes/parent.child", + "routes/parent.child.grandchild", + "routes/_index", + "routes/parent.child._index", + "routes/parent._index", + ]); + + // Without pre-loading the index, we'd "match" `/parent/child` to just the + // parent and child routes client side and never fetch the + // `routes/parent.child._index` route + await app.clickLink("/parent/child"); + await page.waitForSelector("#child-index"); + expect(await app.getHtml("#parent")).toMatch("Parent"); + expect(await app.getHtml("#child")).toMatch("Child"); + expect(await app.getHtml("#child-index")).toMatch(`Child Index`); + + await app.clickLink("/parent"); + await page.waitForSelector("#parent-index"); + expect(await app.getHtml("#parent")).toMatch(`Parent`); + expect(await app.getHtml("#parent-index")).toMatch(`Parent Index`); + + expect(manifestRequests.length).toBe(0); + }); + + test("prefetches ancestor pathless children when SSR-ing a deep route", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

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

Parent

+ + + ) + } + `, + "app/routes/parent.child.tsx": js` + export default function Component() { + return

Child

; + } + `, + "app/routes/parent._a.tsx": js` + import { Outlet } from 'react-router'; + export default function Component() { + return
; + } + `, + "app/routes/parent._a._b._index.tsx": js` + export default function Component() { + return

Parent Pathless Index

; + } + `, + "app/routes/parent._a._b.tsx": js` + import { Outlet } from 'react-router'; + export default function Component() { + return
; + } + `, + "app/routes/parent._a._b.child2.tsx": js` + export default function Component() { + return

Child 2

; + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/parent/child", true); + expect(await app.getHtml("#child")).toMatch("Child"); + expect(await page.$("#a")).toBeNull(); + expect(await page.$("#b")).toBeNull(); + + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual([ + "root", + "routes/parent", + "routes/parent.child", + "routes/_index", + "routes/parent._a", + "routes/parent._a._b", + "routes/parent._a._b._index", + ]); + expect(manifestRequests).toEqual([]); + + // Without pre-loading the index, we'd "match" `/parent` to just the + // parent route client side and never fetch the children pathless/index routes + await app.clickLink("/parent"); + await page.waitForSelector("#parent-index"); + expect(await page.$("#a")).not.toBeNull(); + expect(await page.$("#b")).not.toBeNull(); + expect(await app.getHtml("#parent")).toMatch("Parent"); + expect(await app.getHtml("#parent-index")).toMatch("Parent Pathless Index"); + expect(manifestRequests.length).toBe(0); + + // This will require a new fetch for the child2 portion + await app.clickLink("/parent/child2"); + await page.waitForSelector("#child2"); + expect(await app.getHtml("#parent")).toMatch(`Parent`); + expect(await app.getHtml("#child2")).toMatch(`Child 2`); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?p=%2Fparent%2Fchild2&version=/), + ]); + }); + + test("detects higher-ranking static routes on the server when a slug match is already known by the client", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

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

Slug

; + Go to /static + + ); + } + `, + "app/routes/static.tsx": js` + export default function Component() { + return

Static

; + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + expect(await app.getHtml("#index")).toMatch("Index"); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/$slug"]); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?p=%2Fsomething&version=/), + ]); + manifestRequests = []; + + await app.clickLink("/something"); + await page.waitForSelector("#slug"); + expect(await app.getHtml("#slug")).toMatch("Slug"); + expect(manifestRequests).toEqual([]); + + // This will require a new fetch for the /static route + await app.clickLink("/static"); + await page.waitForSelector("#static"); + expect(await app.getHtml("#static")).toMatch("Static"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?p=%2Fstatic&version=/), + ]); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/$slug", "routes/static"]); + }); + + test("detects higher-ranking static routes on the server when a splat match is already known by the client", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

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

Splat

; + Go to /static + + ); + } + `, + "app/routes/static.tsx": js` + export default function Component() { + return

Static

; + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + expect(await app.getHtml("#index")).toMatch("Index"); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/$"]); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?p=%2Fsomething&version=/), + ]); + manifestRequests = []; + + await app.clickLink("/something"); + await page.waitForSelector("#splat"); + expect(await app.getHtml("#splat")).toMatch("Splat"); + expect(manifestRequests).toEqual([]); + + // This will require a new fetch for the /static route + await app.clickLink("/static"); + await page.waitForSelector("#static"); + expect(await app.getHtml("#static")).toMatch("Static"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?p=%2Fstatic&version=/), + ]); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/$", "routes/static"]); + }); + + test("does not re-request for previously discovered slug routes", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

; + } + `, + "app/routes/$slug.tsx": js` + import { Link, useParams } from "react-router"; + export default function Component() { + let params = useParams(); + return

Slug: {params.slug}

; + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + expect(await app.getHtml("#index")).toMatch("Index"); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index"]); + expect(manifestRequests.length).toBe(0); + + // Click /a which will discover via a manifest request + await app.clickLink("/a"); + await page.waitForSelector("#slug"); + expect(await app.getHtml("#slug")).toMatch("Slug: a"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?p=%2Fa&version=/), + ]); + manifestRequests = []; + + // Go back home + await app.clickLink("/"); + await page.waitForSelector("#index"); + expect(manifestRequests).toEqual([]); + + // Click /a again which will not re-discover + await app.clickLink("/a"); + await page.waitForSelector("#slug"); + expect(await app.getHtml("#slug")).toMatch("Slug: a"); + expect(manifestRequests).toEqual([]); + manifestRequests = []; + + // Click /b which will need to discover + await app.clickLink("/b"); + await page.waitForSelector("#slug"); + expect(await app.getHtml("#slug")).toMatch("Slug: b"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?p=%2Fb&version=/), + ]); + }); + + test("does not re-request for previously discovered splat routes", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts } from "react-router"; + export default function Root() { + return ( + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

; + } + `, + "app/routes/$.tsx": js` + import { Link, useParams } from "react-router"; + export default function Component() { + let params = useParams(); + return

Splat: {params["*"]}

; + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + expect(await app.getHtml("#index")).toMatch("Index"); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index"]); + expect(manifestRequests.length).toBe(0); + + // Click /a which will discover via a manifest request + await app.clickLink("/a"); + await page.waitForSelector("#splat"); + expect(await app.getHtml("#splat")).toMatch("Splat: a"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?p=%2Fa&version=/), + ]); + manifestRequests = []; + + // Go back home + await app.clickLink("/"); + await page.waitForSelector("#index"); + expect(manifestRequests).toEqual([]); + + // Click /a again which will not re-discover + await app.clickLink("/a"); + await page.waitForSelector("#splat"); + expect(await app.getHtml("#splat")).toMatch("Splat: a"); + expect(manifestRequests).toEqual([]); + manifestRequests = []; + + // Click /b which will need to discover + await app.clickLink("/b/c"); + await page.waitForSelector("#splat"); + expect(await app.getHtml("#splat")).toMatch("Splat: b/c"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?p=%2Fb%2Fc&version=/), + ]); + }); + + test("does not re-request for previously navigated 404 routes", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts } from "react-router"; + export function Layout({ children }) { + return ( + + + + + {children} + + + + ); + } + export default function Root() { + return ; + } + export function ErrorBoundary() { + return

Error

; + } + `, + + "app/routes/_index.tsx": js` + export default function Index() { + return

Index

; + } + `, + "app/routes/$slug.tsx": js` + import { Link, useParams } from "react-router"; + export default function Component() { + let params = useParams(); + return

Slug: {params.slug}

; + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + expect(await app.getHtml("#index")).toMatch("Index"); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index"]); + expect(manifestRequests.length).toBe(0); + + // Click a 404 link which will try to discover via a manifest request + await app.clickLink("/not/a/path"); + await page.waitForSelector("#error"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?p=%2Fnot%2Fa%2Fpath&version=/), + ]); + manifestRequests = []; + + // Go to a valid slug route + await app.clickLink("/something"); + await page.waitForSelector("#slug"); + expect(manifestRequests).toEqual([ + expect.stringMatching(/\/__manifest\?p=%2Fsomething&version=/), + ]); + manifestRequests = []; + + // Click the same 404 link again which will not re-discover + await app.clickLink("/not/a/path"); + await page.waitForSelector("#error"); + expect(manifestRequests).toEqual([]); + }); + + test("skips prefetching if the URL gets too large", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...getFiles(), + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + export default function Index() { + return ( + <> +

Index

+ {/* 400 links * ~19 chars per link > our 7198 char URL limit */} + {...new Array(400).fill(null).map((el, i) => ( + {i} + ))} + + ); + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(manifestRequests.length).toBe(0); + + await app.clickLink("/a"); + await page.waitForSelector("#a"); + expect(await app.getHtml("#a")).toMatch("A LOADER"); + expect( + await page.evaluate(() => + Object.keys((window as any).__reactRouterManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + }); + + test("includes a version query parameter as a cachebuster", async ({ + page, + }) => { + let fixture = await createFixture({ + files: { + ...getFiles(), + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + export default function Index() { + return ( + <> +

Index

+ /a + /b + + ); + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(manifestRequests).toEqual([ + expect.stringMatching( + /\/__manifest\?p=%2F&p=%2Fa&p=%2Fb&version=[a-z0-9]{8}/ + ), + ]); + }); + + test("sorts url parameters", async ({ page }) => { + let fixture = await createFixture({ + files: { + ...getFiles(), + "app/routes/_index.tsx": js` + import { Link } from "react-router"; + export default function Index() { + return ( + <> +

Index

+ /a + /c + /e + /g + /f + /d + /b + + ); + } + `, + }, + }); + let appFixture = await createAppFixture(fixture); + let app = new PlaywrightFixture(appFixture, page); + + let manifestRequests: string[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req.url()); + } + }); + + await app.goto("/", true); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(manifestRequests).toEqual([ + expect.stringMatching( + /\/__manifest\?p=%2F&p=%2Fa&p=%2Fb&p=%2Fc&p=%2Fd&p=%2Fe&p=%2Ff&p=%2F/ + ), + ]); + }); +}); diff --git a/integration/form-data-test.ts b/integration/form-data-test.ts new file mode 100644 index 0000000000..3653f92850 --- /dev/null +++ b/integration/form-data-test.ts @@ -0,0 +1,58 @@ +import { test, expect } from "@playwright/test"; + +import { createFixture, js } from "./helpers/create-fixture.js"; +import type { Fixture } from "./helpers/create-fixture.js"; + +let fixture: Fixture; + +test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + export async function action({ request }) { + try { + await request.formData() + } catch { + return new Response("no pizza"); + } + return new Response("pizza"); + } + `, + }, + }); +}); + +test("invalid content-type does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "application/json" }, + }); + expect(await response.text()).toMatch("no pizza"); +}); + +test("invalid urlencoded body does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "$rofl this is totally invalid$", + }); + expect(await response.text()).toMatch("pizza"); +}); + +test("invalid multipart content-type does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "multipart/form-data" }, + body: "$rofl this is totally invalid$", + }); + expect(await response.text()).toMatch("pizza"); +}); + +test("invalid multipart body does not crash server", async () => { + let response = await fixture.requestDocument("/", { + method: "post", + headers: { "content-type": "multipart/form-data; boundary=abc" }, + body: "$rofl this is totally invalid$", + }); + expect(await response.text()).toMatch("pizza"); +}); diff --git a/integration/form-test.ts b/integration/form-test.ts new file mode 100644 index 0000000000..243d7f427d --- /dev/null +++ b/integration/form-test.ts @@ -0,0 +1,1138 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { getElement, PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("Forms", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + let KEYBOARD_INPUT = "KEYBOARD_INPUT"; + let CHECKBOX_BUTTON = "CHECKBOX_BUTTON"; + let ORPHAN_BUTTON = "ORPHAN_BUTTON"; + let FORM_WITH_ACTION_INPUT = "FORM_WITH_ACTION_INPUT"; + let FORM_WITH_ORPHAN = "FORM_WITH_ORPHAN"; + let LUNCH = "LUNCH"; + let CHEESESTEAK = "CHEESESTEAK"; + let LAKSA = "LAKSA"; + let SQUID_INK_HOTDOG = "SQUID_INK_HOTDOG"; + let ACTION = "action"; + let EAT = "EAT"; + + let STATIC_ROUTE_NO_ACTION = "static-route-none"; + let STATIC_ROUTE_ABSOLUTE_ACTION = "static-route-abs"; + let STATIC_ROUTE_CURRENT_ACTION = "static-route-cur"; + let STATIC_ROUTE_PARENT_ACTION = "static-route-parent"; + let STATIC_ROUTE_TOO_MANY_DOTS_ACTION = "static-route-too-many-dots"; + let INDEX_ROUTE_NO_ACTION = "index-route-none"; + let INDEX_ROUTE_NO_ACTION_POST = "index-route-none-post"; + let INDEX_ROUTE_ABSOLUTE_ACTION = "index-route-abs"; + let INDEX_ROUTE_CURRENT_ACTION = "index-route-cur"; + let INDEX_ROUTE_PARENT_ACTION = "index-route-parent"; + let INDEX_ROUTE_TOO_MANY_DOTS_ACTION = "index-route-too-many-dots"; + let DYNAMIC_ROUTE_NO_ACTION = "dynamic-route-none"; + let DYNAMIC_ROUTE_ABSOLUTE_ACTION = "dynamic-route-abs"; + let DYNAMIC_ROUTE_CURRENT_ACTION = "dynamic-route-cur"; + let DYNAMIC_ROUTE_PARENT_ACTION = "dynamic-route-parent"; + let DYNAMIC_ROUTE_TOO_MANY_DOTS_ACTION = "dynamic-route-too-many-dots"; + let LAYOUT_ROUTE_NO_ACTION = "layout-route-none"; + let LAYOUT_ROUTE_ABSOLUTE_ACTION = "layout-route-abs"; + let LAYOUT_ROUTE_CURRENT_ACTION = "layout-route-cur"; + let LAYOUT_ROUTE_PARENT_ACTION = "layout-route-parent"; + let LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION = "layout-route-too-many-dots"; + let SPLAT_ROUTE_NO_ACTION = "splat-route-none"; + let SPLAT_ROUTE_ABSOLUTE_ACTION = "splat-route-abs"; + let SPLAT_ROUTE_CURRENT_ACTION = "splat-route-cur"; + let SPLAT_ROUTE_PARENT_ACTION = "splat-route-parent"; + let SPLAT_ROUTE_TOO_MANY_DOTS_ACTION = "splat-route-too-many-dots"; + + 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/routes/get-submission.tsx": js` + import { useLoaderData, Form } from "react-router"; + + export function loader({ request }) { + let url = new URL(request.url); + return url.searchParams.toString() + } + + export default function() { + let data = useLoaderData(); + return ( + <> +
+ + + +
+ +
+ + +
+ +
+ + +
+ + + +
+ + + + +
+ +
{data}
+ + ) + } + `, + + "app/routes/about.tsx": js` + export async function action({ request }) { + return { submitted: true }; + } + export default function () { + return

About

; + } + `, + + "app/routes/inbox.tsx": js` + import { Form } from "react-router"; + export default function() { + return ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + ) + } + `, + + "app/routes/blog.tsx": js` + import { Form, Outlet } from "react-router"; + export default function() { + return ( + <> +

Blog

+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + + ) + } + `, + + "app/routes/blog._index.tsx": js` + import { Form } from "react-router"; + export function action() { + return { ok: true }; + } + export default function() { + return ( + <> +
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+ + +
+ + ) + } + `, + + "app/routes/blog.$postId.tsx": js` + import { Form } from "react-router"; + export default function() { + return ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + ) + } + `, + + "app/routes/projects.tsx": js` + import { Form, Outlet } from "react-router"; + export default function() { + return ( + <> +

Projects

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

All projects

+ } + `, + + "app/routes/projects.$.tsx": js` + import { Form } from "react-router"; + export default function() { + return ( + <> +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + ) + } + `, + + "app/routes/stop-propagation.tsx": js` + import { Form, useActionData } from "react-router"; + + export async function action({ request }) { + let formData = await request.formData(); + return Object.fromEntries(formData); + } + + export default function Index() { + let actionData = useActionData(); + return ( +
event.stopPropagation()}> + {actionData ?
{JSON.stringify(actionData)}
: null} +
+ +
+
+ ) + } + `, + + "app/routes/form-method.tsx": js` + import { Form, useActionData, useLoaderData, useSearchParams } from "react-router"; + + export function action({ request }) { + return request.method + } + + export function loader({ request }) { + return request.method + } + + export default function() { + let actionData = useActionData(); + let loaderData = useLoaderData(); + let [searchParams] = useSearchParams(); + let formMethod = searchParams.get('method') || 'GET'; + let submitterFormMethod = searchParams.get('submitterFormMethod') || 'GET'; + return ( + <> +
+ + +
+ {actionData ?
{actionData}
: null} +
{loaderData}
+ + ) + } + `, + + "app/routes/submitter.tsx": js` + import { Form } from "react-router"; + + export default function() { + return ( + <> + +
+ + + + + + + + + +
+ + ) + } + `, + + "app/routes/file-upload.tsx": js` + import { Form, useSearchParams } from "react-router"; + + export default function() { + const [params] = useSearchParams(); + return ( +
+ + + +
+ {actionData ?

{JSON.stringify(actionData)}

: null} + + ) + } + `, + + // Generic route for outputting url-encoded form data (either from the request body or search params) + // + // TODO: refactor other tests to use this + "app/routes/outputFormData.tsx": js` + import { useActionData, useSearchParams } from "react-router"; + + export async function action({ request }) { + const formData = await request.formData(); + const body = new URLSearchParams(); + for (let [key, value] of formData) { + body.append( + key, + value instanceof File ? await streamToString(value.stream()) : value + ); + } + return body.toString(); + } + + export default function OutputFormData() { + const requestBody = useActionData(); + const searchParams = useSearchParams()[0]; + return ; + } + `, + + "myfile.txt": "stuff", + + "app/routes/pathless-layout-parent.tsx": js` + import { Form, Outlet, useActionData } from "react-router" + + export async function action({ request }) { + return { submitted: true }; + } + export default function () { + let data = useActionData(); + return ( + <> +
+

Pathless Layout Parent

+ +
+ +

{data?.submitted === true ? 'Submitted - Yes' : 'Submitted - No'}

+ + ); + } + `, + + "app/routes/pathless-layout-parent._pathless.nested.tsx": js` + import { Outlet } from "react-router"; + + export default function () { + return ( + <> +

Pathless Layout

+ + + ); + } + `, + + "app/routes/pathless-layout-parent._pathless.nested._index.tsx": js` + export default function () { + return

Pathless Layout Index

+ } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + + runFormTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); // explicitly set so we don't have to check against undefined + + runFormTests(); + }); + + function runFormTests() { + test("posts to a loader", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + // this indirectly tests that clicking SVG children in buttons works + await app.goto("/get-submission"); + await app.clickSubmitButton("/get-submission", { wait: true }); + await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + }); + + test("posts to a loader with an ", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement(`#${FORM_WITH_ACTION_INPUT} button`); + await page.waitForSelector(`pre:has-text("${EAT}")`); + }); + + test("posts to a loader with button data with click", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement("#buttonWithValue"); + await page.waitForSelector(`pre:has-text("${LAKSA}")`); + }); + + test("posts to a loader with button data with keyboard", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await page.focus(`#${KEYBOARD_INPUT}`); + await app.waitForNetworkAfter(async () => { + await page.keyboard.press("Enter"); + // there can be a delay before the request gets kicked off (worse with JS disabled) + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + await page.waitForSelector(`pre:has-text("${LAKSA}")`); + }); + + test("posts with the correct checkbox data", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement(`#${CHECKBOX_BUTTON}`); + await page.waitForSelector(`pre:has-text("${LAKSA}")`); + await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + }); + + test("posts button data from outside the form", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/get-submission"); + await app.clickElement(`#${ORPHAN_BUTTON}`); + await page.waitForSelector(`pre:has-text("${SQUID_INK_HOTDOG}")`); + }); + + test( + "when clicking on a submit button as a descendant of an element that " + + "stops propagation on click, still passes the clicked submit button's " + + "`name` and `value` props to the request payload", + async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/stop-propagation"); + await app.clickSubmitButton("/stop-propagation", { wait: true }); + await page.waitForSelector("#action-data"); + expect(await app.getHtml()).toMatch('{"intent":"add"}'); + } + ); + + test.describe("
action", () => { + test.describe("in a static route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/inbox"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/inbox?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toBe("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toBe("/inbox"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toBe("/inbox"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toBe("/"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/inbox"); + let html = await app.getHtml(); + let el = getElement(html, `#${STATIC_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toBe("/about"); + }); + }); + + test.describe("in a dynamic route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog/abc"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog/abc?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toBe("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toBe("/blog/abc"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toBe("/blog/abc"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toBe("/blog"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog/abc"); + let html = await app.getHtml(); + let el = getElement(html, `#${DYNAMIC_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toBe("/about"); + }); + }); + + test.describe("in an index route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toBe("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toBe("/blog?index"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toBe("/blog?index"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toBe("/"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toBe("/about"); + }); + + test("handles search params correctly on GET submissions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + // Start with a query param + await app.goto("/blog?junk=1"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&junk=1"); + expect(app.page.url()).toMatch(/\/blog\?junk=1$/); + + // On submission, we replace existing parameters (reflected in the + // form action) with the values from the form data. We also do not + // need to preserve the index param in the URL on GET submissions + await app.clickElement(`#${INDEX_ROUTE_NO_ACTION} button`); + html = await app.getHtml(); + el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&foo=1"); + expect(app.page.url()).toMatch(/\/blog\?foo=1$/); + + // Does not append duplicate params on re-submissions + await app.clickElement(`#${INDEX_ROUTE_NO_ACTION} button`); + html = await app.getHtml(); + el = getElement(html, `#${INDEX_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?index&foo=1"); + expect(app.page.url()).toMatch(/\/blog\?foo=1$/); + }); + + test("handles search params correctly on POST submissions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + // Start with a query param + await app.goto("/blog?junk=1"); + let html = await app.getHtml(); + let el = getElement(html, `#${INDEX_ROUTE_NO_ACTION_POST}`); + expect(el.attr("action")).toBe("/blog?index&junk=1"); + expect(app.page.url()).toMatch(/\/blog\?junk=1$/); + + // Form action reflects the current params and change them on submission + await app.clickElement(`#${INDEX_ROUTE_NO_ACTION_POST} button`); + html = await app.getHtml(); + el = getElement(html, `#${INDEX_ROUTE_NO_ACTION_POST}`); + expect(el.attr("action")).toBe("/blog?index&junk=1"); + await page.waitForURL(/\/blog\?index&junk=1$/); + expect(app.page.url()).toMatch(/\/blog\?index&junk=1$/); + }); + }); + + test.describe("in a layout route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/blog?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toBe("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toBe("/blog"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toBe("/blog"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toBe("/"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/blog"); + let html = await app.getHtml(); + let el = getElement(html, `#${LAYOUT_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toBe("/about"); + }); + }); + + test.describe("in a splat route", () => { + test("no action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/projects/blarg"); + }); + + test("no action resolves to URL including search params", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_NO_ACTION}`); + expect(el.attr("action")).toBe("/projects/blarg?foo=bar"); + }); + + test("absolute action resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_ABSOLUTE_ACTION}`); + expect(el.attr("action")).toBe("/about"); + }); + + test("'.' action resolves relative to the closest route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toBe("/projects/blarg"); + }); + + test("'.' excludes search params", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg?foo=bar"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_CURRENT_ACTION}`); + expect(el.attr("action")).toBe("/projects/blarg"); + }); + + test("'..' action resolves relative to the parent route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_PARENT_ACTION}`); + expect(el.attr("action")).toBe("/projects"); + }); + + test("'..' action with more .. segments than parent routes resolves relative to the root route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/projects/blarg"); + let html = await app.getHtml(); + let el = getElement(html, `#${SPLAT_ROUTE_TOO_MANY_DOTS_ACTION}`); + expect(el.attr("action")).toBe("/about"); + }); + }); + }); + + let FORM_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"]; + let NATIVE_FORM_METHODS = ["GET", "POST"]; + + test.describe("uses the Form `method` attribute", () => { + FORM_METHODS.forEach((method) => { + test(`submits with ${method}`, async ({ page, javaScriptEnabled }) => { + test.fail( + !javaScriptEnabled && !NATIVE_FORM_METHODS.includes(method), + `Native doesn't support method ${method} #4420` + ); + + let app = new PlaywrightFixture(appFixture, page); + await app.goto(`/form-method?method=${method}`, true); + await app.clickElement(`text=Submit`); + if (method !== "GET") { + await page.waitForSelector("#action-method"); + expect(await app.getHtml("pre#action-method")).toBe( + `
${method}
` + ); + } + expect(await app.getHtml("pre#loader-method")).toBe( + `
GET
` + ); + }); + }); + }); + + test.describe("overrides the Form `method` attribute with the submitter's `formMethod` attribute", () => { + // NOTE: HTMLButtonElement only supports get/post as formMethod, which is why we don't test put/patch/delete + NATIVE_FORM_METHODS.forEach((overrideMethod) => { + // ensure the form's method is different from the submitter's + let method = overrideMethod === "GET" ? "POST" : "GET"; + test(`submits with ${overrideMethod} instead of ${method}`, async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto( + `/form-method?method=${method}&submitterFormMethod=${overrideMethod}`, + true + ); + await app.clickElement(`text=Submit with ${overrideMethod}`); + if (overrideMethod !== "GET") { + await page.waitForSelector("#action-method"); + expect(await app.getHtml("pre#action-method")).toBe( + `
${overrideMethod}
` + ); + } + expect(await app.getHtml("pre#loader-method")).toBe( + `
GET
` + ); + }); + }); + }); + + test("submits the submitter's value(s) in tree order in the form data", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + + await app.goto("/submitter"); + await app.clickElement("text=Add Task"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=first&tasks=second&tasks=&tasks=last" + ); + + await app.goto("/submitter"); + await app.clickElement("text=No Name"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=first&tasks=second&tasks=last" + ); + + await app.goto("/submitter"); + await app.clickElement("[alt='Add Task']"); + expect((await app.getElement("#formData")).val()).toMatch( + /^tasks=first&tasks=second&tasks.x=\d+&tasks.y=\d+&tasks=last$/ + ); + + await app.goto("/submitter"); + await app.clickElement("[alt='No Name']"); + expect((await app.getElement("#formData")).val()).toMatch( + /^tasks=first&tasks=second&x=\d+&y=\d+&tasks=last$/ + ); + + await app.goto("/submitter"); + await app.clickElement("text=Outside"); + expect((await app.getElement("#formData")).val()).toBe( + "tasks=outside&tasks=first&tasks=second&tasks=last" + ); + }); + + test("sends file names when submitting via url encoding", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + let myFile = fixture.projectDir + "/myfile.txt"; + + await app.goto("/file-upload"); + await app.uploadFile(`[name=filey]`, myFile); + await app.uploadFile(`[name=filey2]`, myFile, myFile); + await app.clickElement("button"); + await page.waitForSelector("#formData"); + + expect((await app.getElement("#formData")).val()).toBe( + "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" + ); + + await app.goto("/file-upload?method=post"); + await app.uploadFile(`[name=filey]`, myFile); + await app.uploadFile(`[name=filey2]`, myFile, myFile); + await app.clickElement("button"); + await page.waitForSelector("#formData"); + + expect((await app.getElement("#formData")).val()).toBe( + "filey=myfile.txt&filey2=myfile.txt&filey2=myfile.txt&filey3=" + ); + }); + + 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"); + await app.clickSubmitButton("/empty-file-upload"); + await page.waitForSelector("#action-data"); + expect((await app.getElement("#action-data")).text()).toContain( + '{"text":"","file":{"name":"","size":0},"fileMultiple":[{"name":"","size":0}]}' + ); + }); + + test("pathless layout routes are ignored in form actions", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/pathless-layout-parent/nested"); + let html = await app.getHtml(); + expect(html).toMatch("Pathless Layout Parent"); + expect(html).toMatch("Pathless Layout "); + expect(html).toMatch("Pathless Layout Index"); + + let el = getElement(html, `form`); + expect(el.attr("action")).toBe("/pathless-layout-parent"); + + expect(await app.getHtml()).toMatch("Submitted - No"); + // This submission should ignore the index route and the pathless layout + // route above it and hit the action in routes/pathless-layout-parent.jsx + await app.clickSubmitButton("/pathless-layout-parent"); + await page.waitForSelector("text=Submitted - Yes"); + expect(await app.getHtml()).toMatch("Submitted - Yes"); + }); + } +}); diff --git a/integration/fs-routes-test.ts b/integration/fs-routes-test.ts new file mode 100644 index 0000000000..1a5d6122a3 --- /dev/null +++ b/integration/fs-routes-test.ts @@ -0,0 +1,454 @@ +import { PassThrough } from "node:stream"; +import { test, expect } from "@playwright/test"; + +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { createFixtureProject } from "./helpers/create-fixture.js"; +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; + +let fixture: Fixture; +let appFixture: AppFixture; + +test.describe("fs-routes", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "vite.config.js": js` + import { defineConfig } from "vite"; + import { reactRouter } from "@react-router/dev/vite"; + + export default defineConfig({ + plugins: [reactRouter()], + }); + `, + "app/routes.ts": js` + import { type RouteConfig } from "@react-router/dev/routes"; + import { flatRoutes } from "@react-router/fs-routes"; + import { remixRoutesOptionAdapter } from "@react-router/remix-routes-option-adapter"; + + export default [ + ...await flatRoutes({ + ignoredRouteFiles: ["**/ignored-route.*"], + }), + + // Ensure Remix back compat layer works + ...await remixRoutesOptionAdapter(async (defineRoutes) => { + // Ensure async routes work + return defineRoutes((route) => { + route("/remix/config/route", "remix-config-route.tsx") + }); + }) + ] satisfies RouteConfig; + `, + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + +
+

Root

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

Index

; + } + `, + + "app/routes/folder/route.tsx": js` + export default function () { + return

Folder (Route.jsx)

; + } + `, + + "app/routes/folder2/index.tsx": js` + export default function () { + return

Folder (Index.jsx)

; + } + `, + + "app/routes/flat.file.tsx": js` + export default function () { + return

Flat File

; + } + `, + + "app/remix-config-route.tsx": js` + export default function () { + return

Remix Config Route

; + } + `, + + "app/routes/.dotfile": ` + DOTFILE SHOULD BE IGNORED + `, + + "app/routes/.route-with-unescaped-leading-dot.tsx": js` + throw new Error("This file should be ignored as a route"); + `, + + "app/routes/[.]route-with-escaped-leading-dot.tsx": js` + export default function () { + return

Route With Escaped Leading Dot

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

Dashboard Layout

+ + + ) + } + `, + + "app/routes/dashboard._index/route.tsx": js` + export default function () { + return

Dashboard Index

; + } + `, + + [`app/routes/ignored-route.jsx`]: js` + export default function () { + return

i should 404

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runTests(); + }); + + function runTests() { + test("renders matching routes (index)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Index

+
`); + }); + + test("renders matching routes (folder route.jsx)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/folder"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Folder (Route.jsx)

+
`); + }); + + test("renders matching routes (folder index.jsx)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/folder2"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Folder (Index.jsx)

+
`); + }); + + test("renders matching routes (flat file)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/flat/file"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Flat File

+
`); + }); + + test("renders matching routes (Remix config route)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/remix/config/route"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Remix Config Route

+
`); + }); + + test("renders matching routes (route with escaped leading dot)", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/.route-with-escaped-leading-dot"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Route With Escaped Leading Dot

+
`); + }); + + test("renders matching routes (nested)", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/dashboard"); + expect(await app.getHtml("#content")).toBe(`
+

Root

+

Dashboard Layout

+

Dashboard Index

+
`); + }); + } + + test("allows ignoredRouteFiles to be configured", async () => { + let response = await fixture.requestDocument("/ignored-route"); + + expect(response.status).toBe(404); + }); +}); + +test.describe("emits warnings for route conflicts", async () => { + let buildStdio = new PassThrough(); + let buildOutput: string; + + let originalConsoleLog = console.log; + let originalConsoleWarn = console.warn; + let originalConsoleError = console.error; + + test.beforeAll(async () => { + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + await createFixtureProject({ + buildStdio, + files: { + "routes/_dashboard._index.tsx": js` + export default function () { + return

routes/_dashboard._index

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

routes._index

; + } + `, + "app/routes/_landing._index.tsx": js` + export default function () { + return

routes/_landing._index

; + } + `, + }, + }); + + let chunks: Buffer[] = []; + buildOutput = await new Promise((resolve, reject) => { + buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + buildStdio.on("error", (err) => reject(err)); + buildStdio.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")) + ); + }); + }); + + test.afterAll(() => { + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }); + + test("warns about conflicting routes", () => { + console.log(buildOutput); + expect(buildOutput).toContain(`โš ๏ธ Route Path Collision: "/"`); + }); +}); + +test.describe("", () => { + let buildStdio = new PassThrough(); + let buildOutput: string; + + let originalConsoleLog = console.log; + let originalConsoleWarn = console.warn; + let originalConsoleError = console.error; + + test.beforeAll(async () => { + console.log = () => {}; + console.warn = () => {}; + console.error = () => {}; + await createFixtureProject({ + buildStdio, + files: { + "app/routes/_index/route.tsx": js``, + "app/routes/_index/utils.ts": js``, + }, + }); + + let chunks: Buffer[] = []; + buildOutput = await new Promise((resolve, reject) => { + buildStdio.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + buildStdio.on("error", (err) => reject(err)); + buildStdio.on("end", () => + resolve(Buffer.concat(chunks).toString("utf8")) + ); + }); + }); + + test.afterAll(() => { + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + }); + + test("doesn't emit a warning for nested index files with co-located files", () => { + expect(buildOutput).not.toContain(`Route Path Collision`); + }); +}); + +test.describe("pathless routes and route collisions", () => { + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/root.tsx": js` + import { Link, Outlet, Scripts, useMatches } from "react-router"; + + export default function App() { + let matches = 'Number of matches: ' + useMatches().length; + return ( + + + +

{matches}

+ + + + + ); + } + `, + "app/routes/nested._index.tsx": js` + export default function Index() { + return

Index

; + } + `, + "app/routes/nested._pathless.tsx": js` + import { Outlet } from "react-router"; + + export default function Layout() { + return ( + <> +
Pathless Layout
+ + + ); + } + `, + "app/routes/nested._pathless.foo.tsx": js` + export default function Foo() { + return

Foo

; + } + `, + "app/routes/nested._pathless2.tsx": js` + import { Outlet } from "react-router"; + + export default function Layout() { + return ( + <> +
Pathless 2 Layout
+ + + ); + } + `, + "app/routes/nested._pathless2.bar.tsx": js` + export default function Bar() { + return

Bar

; + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(async () => appFixture.close()); + + test.describe("with JavaScript", () => { + runTests(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runTests(); + }); + + /** + * Routes for this test look like this, for reference for the matches assertions: + * + * + * + * + * + * + * + * + * + * + */ + + function runTests() { + test("displays index page and not pathless layout page", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/nested"); + expect(await app.getHtml()).toMatch("Index"); + expect(await app.getHtml()).not.toMatch("Pathless Layout"); + expect(await app.getHtml()).toMatch("Number of matches: 2"); + }); + + test("displays page inside of pathless layout", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/nested/foo"); + expect(await app.getHtml()).not.toMatch("Index"); + expect(await app.getHtml()).toMatch("Pathless Layout"); + expect(await app.getHtml()).toMatch("Foo"); + expect(await app.getHtml()).toMatch("Number of matches: 3"); + }); + + // This also asserts that we support multiple sibling pathless route layouts + test("displays page inside of second pathless layout", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/nested/bar"); + expect(await app.getHtml()).not.toMatch("Index"); + expect(await app.getHtml()).toMatch("Pathless 2 Layout"); + expect(await app.getHtml()).toMatch("Bar"); + expect(await app.getHtml()).toMatch("Number of matches: 3"); + }); + } +}); diff --git a/integration/headers-test.ts b/integration/headers-test.ts new file mode 100644 index 0000000000..7571323c42 --- /dev/null +++ b/integration/headers-test.ts @@ -0,0 +1,419 @@ +import { test, expect } from "@playwright/test"; + +import { UNSAFE_ServerMode as ServerMode } from "react-router"; +import { createFixture, js } from "./helpers/create-fixture.js"; +import type { Fixture } from "./helpers/create-fixture.js"; + +test.describe.skip("headers export", () => { + let ROOT_HEADER_KEY = "X-Test"; + let ROOT_HEADER_VALUE = "SUCCESS"; + let ACTION_HKEY = "X-Test-Action"; + let ACTION_HVALUE = "SUCCESS"; + + let appFixture: Fixture; + + test.beforeAll(async () => { + appFixture = await createFixture( + { + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export const loader = () => ({}); + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { data } from "react-router"; + + export function loader() { + return data(null, { + headers: { + "${ROOT_HEADER_KEY}": "${ROOT_HEADER_VALUE}" + } + }) + } + + export function headers({ loaderHeaders }) { + return { + "${ROOT_HEADER_KEY}": loaderHeaders.get("${ROOT_HEADER_KEY}") + } + } + + export default function Index() { + return
Heyo!
+ } + `, + + "app/routes/action.tsx": js` + import { data } from "react-router"; + + export function action() { + return data(null, { + headers: { + "${ACTION_HKEY}": "${ACTION_HVALUE}" + } + }) + } + + export function headers({ actionHeaders }) { + return { + "${ACTION_HKEY}": actionHeaders.get("${ACTION_HKEY}") + } + } + + export default function Action() { return
} + `, + + "app/routes/parent.tsx": js` + export function headers({ actionHeaders, errorHeaders, loaderHeaders, parentHeaders }) { + return new Headers([ + ...(parentHeaders ? Array.from(parentHeaders.entries()) : []), + ...(actionHeaders ? Array.from(actionHeaders.entries()) : []), + ...(loaderHeaders ? Array.from(loaderHeaders.entries()) : []), + ...(errorHeaders ? Array.from(errorHeaders.entries()) : []), + ]); + } + + export function loader({ request }) { + if (new URL(request.url).searchParams.get('throw') === "parent") { + throw new Response(null, { + status: 400, + headers: { 'X-Parent-Loader': 'error' }, + }) + } + return new Response(null, { + headers: { 'X-Parent-Loader': 'success' }, + }) + } + + export async function action({ request }) { + let fd = await request.formData(); + if (fd.get('throw') === "parent") { + throw new Response(null, { + status: 400, + headers: { 'X-Parent-Action': 'error' }, + }) + } + return new Response(null, { + headers: { 'X-Parent-Action': 'success' }, + }) + } + + export default function Component() { return
} + + export function ErrorBoundary() { + return

Error!

+ } + `, + + "app/routes/parent.child.tsx": js` + export function loader({ request }) { + if (new URL(request.url).searchParams.get('throw') === "child") { + throw new Response(null, { + status: 400, + headers: { 'X-Child-Loader': 'error' }, + }) + } + return null + } + + export async function action({ request }) { + let fd = await request.formData(); + if (fd.get('throw') === "child") { + throw new Response(null, { + status: 400, + headers: { 'X-Child-Action': 'error' }, + }) + } + return null + } + + export default function Component() { return
} + `, + + "app/routes/parent.child.grandchild.tsx": js` + export function loader({ request }) { + throw new Response(null, { + status: 400, + headers: { 'X-Child-Grandchild': 'error' }, + }) + } + + export default function Component() { return
} + `, + + "app/routes/cookie.tsx": js` + import { data, Outlet } from "react-router"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has("parent-throw")) { + throw data(null, { headers: { "Set-Cookie": "parent-thrown-cookie=true" } }); + } + return null + }; + + export default function Parent() { + return ; + } + + export function ErrorBoundary() { + return

Caught!

; + } + `, + + "app/routes/cookie.child.tsx": js` + import { data } from "react-router"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has("throw")) { + throw data(null, { headers: { "Set-Cookie": "thrown-cookie=true" } }); + } + return data(null, { + headers: { "Set-Cookie": "normal-cookie=true" }, + }); + }; + + export default function Child() { + return

Child

; + } + `, + }, + }, + ServerMode.Test + ); + }); + + test("can use `action` headers", async () => { + let response = await appFixture.postDocument( + "/action", + new URLSearchParams() + ); + expect(response.headers.get(ACTION_HKEY)).toBe(ACTION_HVALUE); + }); + + test("can use the loader headers when all routes have loaders", async () => { + let response = await appFixture.requestDocument("/"); + expect(response.headers.get(ROOT_HEADER_KEY)).toBe(ROOT_HEADER_VALUE); + }); + + test("can use the loader headers when parents don't have loaders", async () => { + let HEADER_KEY = "X-Test"; + let HEADER_VALUE = "SUCCESS"; + + let fixture = await createFixture( + { + files: { + "app/root.tsx": js` + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { data } from "react-router"; + + export function loader() { + return data(null, { + headers: { + "${HEADER_KEY}": "${HEADER_VALUE}" + } + }) + } + + export function headers({ loaderHeaders }) { + return { + "${HEADER_KEY}": loaderHeaders.get("${HEADER_KEY}") + } + } + + export default function Index() { + return
Heyo!
+ } + `, + }, + }, + ServerMode.Test + ); + let response = await fixture.requestDocument("/"); + expect(response.headers.get(HEADER_KEY)).toBe(HEADER_VALUE); + }); + + test("returns headers from successful /parent GET requests", async () => { + let response = await appFixture.requestDocument("/parent"); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-loader", "success"], + ]) + ); + }); + + test("returns headers from successful /parent/child GET requests", async () => { + let response = await appFixture.requestDocument("/parent/child"); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-loader", "success"], + ]) + ); + }); + + test("returns headers from successful /parent POST requests", async () => { + let response = await appFixture.postDocument( + "/parent", + new URLSearchParams() + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-action", "success"], + ["x-parent-loader", "success"], + ]) + ); + }); + + test("returns headers from successful /parent/child POST requests", async () => { + let response = await appFixture.postDocument( + "/parent/child", + new URLSearchParams() + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-loader", "success"], + ]) + ); + }); + + test("returns headers from failed /parent GET requests", async () => { + let response = await appFixture.requestDocument("/parent?throw=parent"); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-loader", "error, error"], // Shows up in loaderHeaders and errorHeaders + ]) + ); + }); + + test("returns bubbled headers from failed /parent/child GET requests", async () => { + let response = await appFixture.requestDocument( + "/parent/child?throw=child" + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-child-loader", "error"], + ["x-parent-loader", "success"], + ]) + ); + }); + + test("ignores headers from successful non-rendered loaders", async () => { + let response = await appFixture.requestDocument( + "/parent/child?throw=parent" + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-loader", "error, error"], // Shows up in loaderHeaders and errorHeaders + ]) + ); + }); + + test("chooses higher thrown errors when multiple loaders throw", async () => { + let response = await appFixture.requestDocument( + "/parent/child/grandchild?throw=child" + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-child-loader", "error"], + ["x-parent-loader", "success"], + ]) + ); + }); + + test("returns headers from failed /parent POST requests", async () => { + let response = await appFixture.postDocument( + "/parent?throw=parent", + new URLSearchParams("throw=parent") + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-parent-action", "error, error"], // Shows up in actionHeaders and errorHeaders + ]) + ); + }); + + test("returns bubbled headers from failed /parent/child POST requests", async () => { + let response = await appFixture.postDocument( + "/parent/child", + new URLSearchParams("throw=child") + ); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["x-child-action", "error"], + ]) + ); + }); + + test("automatically includes cookie headers from normal responses", async () => { + let response = await appFixture.requestDocument("/cookie/child"); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["set-cookie", "normal-cookie=true"], + ]) + ); + }); + + test("automatically includes cookie headers from thrown responses", async () => { + let response = await appFixture.requestDocument("/cookie/child?throw"); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["set-cookie", "thrown-cookie=true"], + ]) + ); + }); + + test("does not duplicate thrown cookie headers from boundary route", async () => { + let response = await appFixture.requestDocument("/cookie?parent-throw"); + expect(JSON.stringify(Array.from(response.headers.entries()))).toBe( + JSON.stringify([ + ["content-type", "text/html"], + ["set-cookie", "parent-thrown-cookie=true"], + ]) + ); + }); +}); diff --git a/integration/helpers/cleanup.mjs b/integration/helpers/cleanup.mjs new file mode 100644 index 0000000000..f1b4f02054 --- /dev/null +++ b/integration/helpers/cleanup.mjs @@ -0,0 +1,27 @@ +import * as path from "node:path"; +import spawn from "cross-spawn"; + +if (process.env.CI) { + console.log("Skipping cleanup in CI"); + process.exit(); +} + +const pathsToRemove = [path.resolve(process.cwd(), ".tmp/integration")]; + +for (let pathToRemove of pathsToRemove) { + console.log(`Removing ${path.relative(process.cwd(), pathToRemove)}`); + let childProcess; + if (process.platform === "win32") { + childProcess = spawn("rmdir", ["/s", "/q", pathToRemove], { + stdio: "inherit", + }); + } else { + childProcess = spawn("rm", ["-rf", pathToRemove], { + stdio: "inherit", + }); + } + childProcess.on("error", (err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts new file mode 100644 index 0000000000..c4062c59d4 --- /dev/null +++ b/integration/helpers/create-fixture.ts @@ -0,0 +1,444 @@ +import type { Writable } from "node:stream"; +import { Readable } from "node:stream"; +import path from "node:path"; +import url from "node:url"; +import fse from "fs-extra"; +import express from "express"; +import getPort from "get-port"; +import stripIndent from "strip-indent"; +import { sync as spawnSync, spawn } from "cross-spawn"; +import type { JsonObject } from "type-fest"; + +import { + type ServerBuild, + createRequestHandler, + UNSAFE_ServerMode as ServerMode, + UNSAFE_decodeViaTurboStream as decodeViaTurboStream, +} from "react-router"; +import { createRequestHandler as createExpressHandler } from "@react-router/express"; +import { createReadableStreamFromReadable } from "@react-router/node"; + +import { viteConfig, reactRouterConfig } from "./vite.js"; + +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); +const root = path.join(__dirname, "../.."); +const TMP_DIR = path.join(root, ".tmp", "integration"); + +export interface FixtureInit { + buildStdio?: Writable; + files?: { [filename: string]: string }; + useReactRouterServe?: boolean; + spaMode?: boolean; + prerender?: boolean; + port?: number; +} + +export type Fixture = Awaited>; +export type AppFixture = Awaited>; + +export const js = String.raw; +export const mdx = String.raw; +export const css = String.raw; +export function json(value: JsonObject) { + return JSON.stringify(value, null, 2); +} + +export async function createFixture(init: FixtureInit, mode?: ServerMode) { + let projectDir = await createFixtureProject(init, mode); + let buildPath = url.pathToFileURL( + path.join(projectDir, "build/server/index.js") + ).href; + + let getBrowserAsset = async (asset: string) => { + return fse.readFile( + path.join(projectDir, "public", asset.replace(/^\//, "")), + "utf8" + ); + }; + + if (init.spaMode) { + return { + projectDir, + build: null, + isSpaMode: init.spaMode, + prerender: init.prerender, + requestDocument() { + let html = fse.readFileSync( + path.join(projectDir, "build/client/index.html") + ); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); + }, + requestResource() { + throw new Error("Cannot requestResource in SPA Mode tests"); + }, + requestSingleFetchData: () => { + throw new Error("Cannot requestSingleFetchData in SPA Mode tests"); + }, + postDocument: () => { + throw new Error("Cannot postDocument in SPA Mode tests"); + }, + getBrowserAsset, + useReactRouterServe: init.useReactRouterServe, + }; + } + + if (init.prerender) { + return { + projectDir, + build: null, + isSpaMode: init.spaMode, + prerender: init.prerender, + requestDocument(href: string) { + let file = new URL(href, "test://test").pathname + "/index.html"; + let html = fse.readFileSync( + path.join(projectDir, "build/client" + file) + ); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); + }, + requestResource(href: string) { + let data = fse.readFileSync( + path.join(projectDir, "build/client", href) + ); + return new Response(data); + }, + async requestSingleFetchData(href: string) { + let data = fse.readFileSync( + path.join(projectDir, "build/client", href) + ); + let stream = createReadableStreamFromReadable(Readable.from(data)); + return { + status: 200, + statusText: "OK", + headers: new Headers(), + data: (await decodeViaTurboStream(stream, global)).value, + }; + }, + postDocument: () => { + throw new Error("Cannot postDocument in Prerender tests"); + }, + getBrowserAsset, + useReactRouterServe: init.useReactRouterServe, + }; + } + + let app: ServerBuild = await import(buildPath); + let handler = createRequestHandler(app, mode || ServerMode.Production); + + let requestDocument = async (href: string, init?: RequestInit) => { + let url = new URL(href, "test://test"); + let request = new Request(url.toString(), { + ...init, + signal: init?.signal || new AbortController().signal, + }); + return handler(request); + }; + + let requestResource = async (href: string, init?: RequestInit) => { + init = init || {}; + init.signal = init.signal || new AbortController().signal; + let url = new URL(href, "test://test"); + let request = new Request(url.toString(), init); + return handler(request); + }; + + let requestSingleFetchData = async (href: string, init?: RequestInit) => { + init = init || {}; + init.signal = init.signal || new AbortController().signal; + let url = new URL(href, "test://test"); + let request = new Request(url.toString(), init); + let response = await handler(request); + return { + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: response.body + ? (await decodeViaTurboStream(response.body!, global)).value + : null, + }; + }; + + let postDocument = async (href: string, data: URLSearchParams | FormData) => { + return requestDocument(href, { + method: "POST", + body: data, + headers: { + "Content-Type": + data instanceof URLSearchParams + ? "application/x-www-form-urlencoded" + : "multipart/form-data", + }, + }); + }; + + return { + projectDir, + build: app, + isSpaMode: init.spaMode, + prerender: init.prerender, + requestDocument, + requestResource, + requestSingleFetchData, + postDocument, + getBrowserAsset, + useReactRouterServe: init.useReactRouterServe, + }; +} + +export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { + let startAppServer = async (): Promise<{ + port: number; + 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: { + 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, + }); + } + }); + }); + } + + if (fixture.isSpaMode) { + return new Promise(async (accept) => { + let port = await getPort(); + let app = express(); + app.use(express.static(path.join(fixture.projectDir, "build/client"))); + app.get("*", (_, res, next) => + res.sendFile(path.join(fixture.projectDir, "build/client/index.html")) + ); + let server = app.listen(port); + accept({ stop: server.close.bind(server), port }); + }); + } + + if (fixture.prerender) { + return new Promise(async (accept) => { + let port = await getPort(); + let app = express(); + app.use(express.static(path.join(fixture.projectDir, "build/client"))); + app.get("*", (req, res, next) => { + let file = req.path.endsWith(".data") + ? req.path + : req.path + "/index.html"; + res.sendFile( + path.join(fixture.projectDir, "build/client", file), + next + ); + }); + let server = app.listen(port); + accept({ stop: server.close.bind(server), port }); + }); + } + + if (!fixture.build) { + return Promise.reject( + new Error("Cannot start app server without a build") + ); + } + + return new Promise(async (accept) => { + let port = await getPort(); + let app = express(); + app.use(express.static(path.join(fixture.projectDir, "build/client"))); + + app.all( + "*", + createExpressHandler({ + build: fixture.build, + mode: mode || ServerMode.Production, + }) + ); + + let server = app.listen(port); + + accept({ stop: server.close.bind(server), port }); + }); + }; + + let start = async () => { + let { stop, port } = await startAppServer(); + + let serverUrl = `http://localhost:${port}`; + + return { + serverUrl, + /** + * Shuts down the fixture app, **you need to call this + * at the end of a test** or `afterAll` if the fixture is initialized in a + * `beforeAll` block. Also make sure to `app.close()` or else you'll + * have memory leaks. + */ + close: () => { + return stop(); + }, + }; + }; + + return start(); +} + +//////////////////////////////////////////////////////////////////////////////// + +export async function createFixtureProject( + init: FixtureInit = {}, + mode?: ServerMode +): Promise { + let template = "vite-template"; + let integrationTemplateDir = path.resolve(__dirname, template); + let projectName = `rr-${template}-${Math.random().toString(32).slice(2)}`; + let projectDir = path.join(TMP_DIR, projectName); + let port = init.port ?? (await getPort()); + + await fse.ensureDir(projectDir); + await fse.copy(integrationTemplateDir, projectDir); + + let hasViteConfig = Object.keys(init.files ?? {}).some((filename) => + filename.startsWith("vite.config.") + ); + + let hasReactRouterConfig = Object.keys(init.files ?? {}).some((filename) => + filename.startsWith("react-router.config.") + ); + + let { spaMode } = init; + + await writeTestFiles( + { + ...(hasViteConfig + ? {} + : { + "vite.config.js": await viteConfig.basic({ + port, + }), + }), + ...(hasReactRouterConfig + ? {} + : { + "react-router.config.ts": reactRouterConfig({ + ssr: !spaMode, + }), + }), + ...init.files, + }, + projectDir + ); + + build(projectDir, init.buildStdio, mode); + + return projectDir; +} + +function build(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 + // force the mode to be production for ESM configs when runtime mode is + // tested. + mode = mode === ServerMode.Test ? ServerMode.Production : mode; + + let reactRouterBin = "node_modules/@react-router/dev/dist/cli/index.js"; + + let buildArgs: string[] = [reactRouterBin, "build"]; + + 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`); + } +} + +async function writeTestFiles( + files: Record | undefined, + dir: string +) { + await Promise.all( + Object.keys(files ?? {}).map(async (filename) => { + let filePath = path.join(dir, filename); + await fse.ensureDir(path.dirname(filePath)); + let file = files![filename]; + + await fse.writeFile(filePath, stripIndent(file)); + }) + ); +} diff --git a/integration/helpers/playwright-fixture.ts b/integration/helpers/playwright-fixture.ts new file mode 100644 index 0000000000..07bfa2b92a --- /dev/null +++ b/integration/helpers/playwright-fixture.ts @@ -0,0 +1,348 @@ +import cp from "node:child_process"; +import type { Page, Response, Request } from "@playwright/test"; +import { test } from "@playwright/test"; +import { load } from "cheerio"; +import prettier from "prettier"; + +let cheerio = load(""); + +import type { AppFixture } from "./create-fixture.js"; + +export class PlaywrightFixture { + readonly page: Page; + readonly app: AppFixture; + + constructor(app: AppFixture, page: Page) { + this.page = page; + this.app = app; + } + + /** + * Visits the href with a document request. + * + * @param href The href you want to visit + * @param waitForHydration Wait for the page to full load/hydrate? + * - `undefined` to wait for the document `load` event + * - `true` wait for the network to be idle, so everything should be loaded + * and ready to go + * - `false` to wait only until the initial doc to be returned and the document + * to start loading (mostly useful for testing deferred responses) + */ + async goto(href: string, waitForHydration?: boolean): Promise { + let response = await this.page.goto(this.app.serverUrl + href, { + waitUntil: + waitForHydration === true + ? "networkidle" + : waitForHydration === false + ? "commit" + : "load", + }); + if (response == null) + throw new Error( + "Unexpected null response, possible about:blank request or same-URL redirect" + ); + return response; + } + + /** + * Finds a link on the page with a matching href, clicks it, and waits for + * the network to be idle before continuing. + * + * @param href The href of the link you want to click + * @param options `{ wait }` waits for the network to be idle before moving on + */ + async clickLink(href: string, options: { wait: boolean } = { wait: true }) { + let selector = `a[href="${href}"]`; + let el = await this.page.$(selector); + if (!el) { + throw new Error(`Could not find link for ${selector}`); + } + if (options.wait) { + await doAndWait(this.page, () => el!.click()); + } else { + await el.click(); + } + } + + /** + * Find the input element and fill for file uploads. + * + * @param inputSelector The selector of the input you want to fill + * @param filePaths The paths to the files you want to upload + */ + async uploadFile(inputSelector: string, ...filePaths: string[]) { + let el = await this.page.$(inputSelector); + if (!el) { + throw new Error(`Could not find input for: ${inputSelector}`); + } + await el.setInputFiles(filePaths); + } + + /** + * Finds the first submit button with `formAction` that matches the + * `action` supplied, clicks it, and optionally waits for the network to + * be idle before continuing. + * + * @param action The formAction of the button you want to click + * @param options `{ wait }` waits for the network to be idle before moving on + */ + async clickSubmitButton( + action: string, + options: { wait?: boolean; method?: string } = { wait: true } + ) { + let selector: string; + if (options.method) { + selector = `button[formAction="${action}"][formMethod="${options.method}"]`; + } else { + selector = `button[formAction="${action}"]`; + } + + let el = await this.page.$(selector); + if (!el) { + if (options.method) { + selector = `form[action="${action}"] button[type="submit"][formMethod="${options.method}"]`; + } else { + selector = `form[action="${action}"] button[type="submit"]`; + } + el = await this.page.$(selector); + if (!el) { + throw new Error(`Can't find button for: ${action}`); + } + } + if (options.wait) { + await doAndWait(this.page, () => el!.click()); + } else { + await el.click(); + } + } + + /** + * Clicks any element and waits for the network to be idle. + */ + async clickElement(selector: string) { + let el = await this.page.$(selector); + if (!el) { + throw new Error(`Can't find element for: ${selector}`); + } + await doAndWait(this.page, () => el!.click()); + } + + /** + * Perform any interaction and wait for the network to be idle: + * + * ```ts + * await app.waitForNetworkAfter(page, () => app.page.focus("#el")) + * ``` + */ + async waitForNetworkAfter(fn: () => Promise) { + await doAndWait(this.page, fn); + } + + /** + * "Clicks" the back button and optionally waits for the network to be + * idle (defaults to waiting). + */ + async goBack(options: { wait: boolean } = { wait: true }) { + if (options.wait) { + await doAndWait(this.page, () => this.page.goBack()); + } else { + await this.page.goBack(); + } + } + + /** + * "Clicks" the refresh button. + */ + async reload(options: { wait: boolean } = { wait: true }) { + if (options.wait) { + await doAndWait(this.page, () => this.page.reload()); + } else { + await this.page.reload(); + } + } + + /** + * Collects single fetch data responses from the network, usually after a + * link click or form submission. This is useful for asserting that specific + * loaders were called (or not). + */ + collectSingleFetchResponses() { + return this.collectResponses((url) => url.pathname.endsWith(".data")); + } + + /** + * Collects all responses from the network, usually after a link click or + * form submission. A filter can be provided to only collect responses + * that meet a certain criteria. + */ + collectResponses(filter?: (url: URL) => boolean) { + let responses: Response[] = []; + + this.page.on("response", (res) => { + if (!filter || filter(new URL(res.url()))) { + responses.push(res); + } + }); + + return responses; + } + + /** + * Get HTML from the page. Useful for asserting something rendered that + * you expected. + * + * @param selector CSS Selector for the element's HTML you want + */ + getHtml(selector?: string) { + return getHtml(this.page, selector); + } + + /** + * Get a cheerio instance of an element from the page. + * + * @param selector CSS Selector for the element's HTML you want + */ + async getElement(selector: string) { + return getElement(await getHtml(this.page), selector); + } + + /** + * Keeps the fixture running for as many seconds as you want so you can go + * poke around in the browser to see what's up. + * + * @param seconds How long you want the app to stay open + */ + async poke(seconds: number = 10, href: string = "/") { + let ms = seconds * 1000; + test.setTimeout(ms); + console.log( + `๐Ÿ™ˆ Poke around for ${seconds} seconds ๐Ÿ‘‰ ${this.app.serverUrl}` + ); + cp.exec(`open ${this.app.serverUrl}${href}`); + return new Promise((res) => setTimeout(res, ms)); + } +} + +export async function getHtml(page: Page, selector?: string) { + let html = await page.content(); + return selector ? selectHtml(html, selector) : prettyHtml(html); +} + +export function getElement(source: string, selector: string) { + let el = cheerio(selector, source); + if (!el.length) { + throw new Error(`No element matches selector "${selector}"`); + } + return el; +} + +export function selectHtml(source: string, selector: string) { + let el = getElement(source, selector); + return prettyHtml(cheerio.html(el)).trim(); +} + +export function prettyHtml(source: string) { + return prettier.format(source, { parser: "html" }); +} + +async function doAndWait( + page: Page, + action: () => Promise, + longPolls = 0 +) { + let DEBUG = !!process.env.DEBUG; + let networkSettledCallback: any; + let networkSettledPromise = new Promise((resolve) => { + networkSettledCallback = resolve; + }); + + let requestCounter = 0; + let actionDone = false; + let pending = new Set(); + + let maybeSettle = () => { + if (actionDone && requestCounter <= longPolls) networkSettledCallback(); + }; + + let onRequest = (request: Request) => { + ++requestCounter; + if (DEBUG) { + pending.add(request); + console.log(`+[${requestCounter}]: ${request.url()}`); + } + }; + let onRequestDone = (request: Request) => { + // Let the page handle responses asynchronously (via setTimeout(0)). + // + // Note: this might be changed to use delay, e.g. setTimeout(f, 100), + // when the page uses delay itself. + let evaluate = page.evaluate(() => { + return new Promise((resolve) => setTimeout(resolve, 0)); + }); + evaluate + .catch(() => null) + .then(() => { + --requestCounter; + maybeSettle(); + if (DEBUG) { + pending.delete(request); + console.log(`-[${requestCounter}]: ${request.url()}`); + } + }); + }; + + page.on("request", onRequest); + page.on("requestfinished", onRequestDone); + page.on("requestfailed", onRequestDone); + page.on("load", networkSettledCallback); // e.g. navigation with javascript disabled + + let timeoutId = DEBUG + ? setInterval(() => { + console.log(`${requestCounter} requests pending:`); + for (let request of pending) console.log(` ${request.url()}`); + }, 5000) + : undefined; + + let result = await action(); + actionDone = true; + maybeSettle(); + if (DEBUG) { + console.log(`action done, ${requestCounter} requests pending`); + } + await networkSettledPromise; + + // I wish I knew why but Safari seems to get all screwed up without this. + // When you run doAndWait (via clicking a blink or submitting a form) and + // then waitForSelector(). It finds the selector element but thinks it's + // hidden for some unknown reason. It's intermittent, but waiting for the + // next animation frame delaying slightly before the waitForSelector() calls + // seems to fix it ๐Ÿคทโ€โ™‚๏ธ + // + // Test timeout of 30000ms exceeded. + // + // Error: page.waitForSelector: Target closed + // =========================== logs =========================== + // waiting for locator('text=ROOT_BOUNDARY_TEXT') to be visible + // locator resolved to hidden
ROOT_BOUNDARY_TEXT
+ // locator resolved to hidden
ROOT_BOUNDARY_TEXT
+ // ... and so on until the test times out + let userAgent = await page.evaluate(() => navigator.userAgent); + if (/Safari\//i.test(userAgent) && !/Chrome\//i.test(userAgent)) { + await page.evaluate(() => new Promise((r) => requestAnimationFrame(r))); + } + + if (DEBUG) { + console.log(`action done, network settled`); + } + + page.removeListener("request", onRequest); + page.removeListener("requestfinished", onRequestDone); + page.removeListener("requestfailed", onRequestDone); + page.removeListener("load", networkSettledCallback); + + if (DEBUG && timeoutId) { + clearTimeout(timeoutId); + } + + return result; +} diff --git a/integration/helpers/vite-cloudflare-template/.gitignore b/integration/helpers/vite-cloudflare-template/.gitignore new file mode 100644 index 0000000000..86cec25fd4 --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/.gitignore @@ -0,0 +1,4 @@ +node_modules + +/build +.env diff --git a/integration/helpers/vite-cloudflare-template/app/entry.server.tsx b/integration/helpers/vite-cloudflare-template/app/entry.server.tsx new file mode 100644 index 0000000000..1a7591c7ec --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/app/entry.server.tsx @@ -0,0 +1,35 @@ +import type { AppLoadContext, EntryContext } from "react-router"; +import { ServerRouter } from "react-router"; +import { isbot } from "isbot"; +import { renderToReadableStream } from "react-dom/server"; + +export default async function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + routerContext: EntryContext, + loadContext: AppLoadContext +) { + const body = await renderToReadableStream( + , + { + signal: request.signal, + onError(error: unknown) { + // Log streaming rendering errors from inside the shell + console.error(error); + responseStatusCode = 500; + }, + } + ); + + const userAgent = request.headers.get("user-agent"); + if (userAgent && isbot(userAgent)) { + await body.allReady; + } + + responseHeaders.set("Content-Type", "text/html"); + return new Response(body, { + headers: responseHeaders, + status: responseStatusCode, + }); +} diff --git a/integration/helpers/vite-cloudflare-template/app/root.tsx b/integration/helpers/vite-cloudflare-template/app/root.tsx new file mode 100644 index 0000000000..11d5972955 --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/app/root.tsx @@ -0,0 +1,23 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} diff --git a/integration/helpers/vite-cloudflare-template/app/routes.ts b/integration/helpers/vite-cloudflare-template/app/routes.ts new file mode 100644 index 0000000000..4c05936cb6 --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/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/vite-cloudflare-template/app/routes/_index.tsx b/integration/helpers/vite-cloudflare-template/app/routes/_index.tsx new file mode 100644 index 0000000000..430bfc2b85 --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/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: "React Router + Cloudflare" }, + ]; +}; + +export default function Index() { + return ( +
+

Welcome to React Router + Cloudflare

+
+ ); +} diff --git a/integration/helpers/vite-cloudflare-template/package.json b/integration/helpers/vite-cloudflare-template/package.json new file mode 100644 index 0000000000..d52b527070 --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/package.json @@ -0,0 +1,35 @@ +{ + "name": "integration-vite-cloudflare-template", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "react-router dev", + "build": "react-router build", + "start": "wrangler pages dev ./build/client", + "typecheck": "tsc" + }, + "dependencies": { + "@react-router/cloudflare": "workspace:*", + "isbot": "^4.1.0", + "miniflare": "^3.20231030.4", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router": "workspace:*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20230518.0", + "@react-router/dev": "workspace:*", + "@react-router/fs-routes": "workspace:*", + "@react-router/remix-routes-option-adapter": "workspace:*", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "typescript": "^5.1.6", + "vite": "^5.1.0", + "wrangler": "^3.28.2" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/integration/helpers/vite-cloudflare-template/public/favicon.ico b/integration/helpers/vite-cloudflare-template/public/favicon.ico new file mode 100644 index 0000000000..5dbdfcddcb Binary files /dev/null and b/integration/helpers/vite-cloudflare-template/public/favicon.ico differ diff --git a/integration/helpers/vite-cloudflare-template/tsconfig.json b/integration/helpers/vite-cloudflare-template/tsconfig.json new file mode 100644 index 0000000000..6926eebeac --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": ["env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["vite/client"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "noEmit": true + } +} diff --git a/integration/helpers/vite-cloudflare-template/vite.config.ts b/integration/helpers/vite-cloudflare-template/vite.config.ts new file mode 100644 index 0000000000..bc993c155d --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/vite.config.ts @@ -0,0 +1,7 @@ +import { reactRouter } from "@react-router/dev/vite"; +import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [cloudflareDevProxy(), reactRouter()], +}); diff --git a/integration/helpers/vite-template/.gitignore b/integration/helpers/vite-template/.gitignore new file mode 100644 index 0000000000..c08251ce0e --- /dev/null +++ b/integration/helpers/vite-template/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +.env +.react-router diff --git a/integration/helpers/vite-template/app/root.tsx b/integration/helpers/vite-template/app/root.tsx new file mode 100644 index 0000000000..b36392b4dd --- /dev/null +++ b/integration/helpers/vite-template/app/root.tsx @@ -0,0 +1,19 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router"; + +export default function App() { + return ( + + + + + + + + + + + + + + ); +} diff --git a/integration/helpers/vite-template/app/routes.ts b/integration/helpers/vite-template/app/routes.ts new file mode 100644 index 0000000000..4c05936cb6 --- /dev/null +++ b/integration/helpers/vite-template/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/vite-template/app/routes/_index.tsx b/integration/helpers/vite-template/app/routes/_index.tsx new file mode 100644 index 0000000000..ecfc25c614 --- /dev/null +++ b/integration/helpers/vite-template/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 ( +
+

Welcome to React Router

+
+ ); +} diff --git a/integration/helpers/vite-template/env.d.ts b/integration/helpers/vite-template/env.d.ts new file mode 100644 index 0000000000..5e7dfe5dd9 --- /dev/null +++ b/integration/helpers/vite-template/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/integration/helpers/vite-template/package.json b/integration/helpers/vite-template/package.json new file mode 100644 index 0000000000..e76a042915 --- /dev/null +++ b/integration/helpers/vite-template/package.json @@ -0,0 +1,41 @@ +{ + "name": "integration-vite-template", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "dev": "react-router dev", + "build": "react-router build", + "start": "react-router-serve ./build/server/index.js", + "typecheck": "react-router typegen && tsc" + }, + "dependencies": { + "@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", + "express": "^4.19.2", + "isbot": "^5.1.11", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router": "workspace:*", + "serialize-javascript": "^6.0.1" + }, + "devDependencies": { + "@react-router/dev": "workspace:*", + "@react-router/fs-routes": "workspace:*", + "@react-router/remix-routes-option-adapter": "workspace:*", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "eslint": "^8.38.0", + "typescript": "^5.1.6", + "vite": "^5.1.0", + "vite-env-only": "^3.0.1", + "vite-tsconfig-paths": "^4.2.1" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/integration/helpers/vite-template/public/favicon.ico b/integration/helpers/vite-template/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/integration/helpers/vite-template/public/favicon.ico differ diff --git a/integration/helpers/vite-template/tsconfig.json b/integration/helpers/vite-template/tsconfig.json new file mode 100644 index 0000000000..0b28589917 --- /dev/null +++ b/integration/helpers/vite-template/tsconfig.json @@ -0,0 +1,28 @@ +{ + "include": [ + "env.d.ts", + "**/*.ts", + "**/*.tsx", + ".react-router/types/**/*.d.ts" + ], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "noEmit": true, + "rootDirs": [".", ".react-router/types/"] + } +} diff --git a/integration/helpers/vite-template/vite.config.ts b/integration/helpers/vite-template/vite.config.ts new file mode 100644 index 0000000000..f910ad4c18 --- /dev/null +++ b/integration/helpers/vite-template/vite.config.ts @@ -0,0 +1,7 @@ +import { reactRouter } from "@react-router/dev/vite"; +import { defineConfig } from "vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [reactRouter(), tsconfigPaths()], +}); diff --git a/integration/helpers/vite.ts b/integration/helpers/vite.ts new file mode 100644 index 0000000000..8fd8c4ac49 --- /dev/null +++ b/integration/helpers/vite.ts @@ -0,0 +1,421 @@ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import path from "node:path"; +import fs from "node:fs/promises"; +import type { Readable } from "node:stream"; +import url from "node:url"; +import { createRequire } from "node:module"; +import { platform } from "node:os"; +import fse from "fs-extra"; +import stripIndent from "strip-indent"; +import waitOn from "wait-on"; +import getPort from "get-port"; +import shell from "shelljs"; +import glob from "glob"; +import dedent from "dedent"; +import type { Page } from "@playwright/test"; +import { test as base, expect } from "@playwright/test"; +import type { Config } from "@react-router/dev/config"; + +const require = createRequire(import.meta.url); + +const reactRouterBin = "node_modules/@react-router/dev/dist/cli/index.js"; +const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); +const root = path.resolve(__dirname, "../.."); +const TMP_DIR = path.join(root, ".tmp/integration"); + +export const reactRouterConfig = ({ + ssr, + basename, + prerender, + appDirectory, +}: { + ssr?: boolean; + basename?: string; + prerender?: boolean | string[]; + appDirectory?: string; +}) => { + let config: Config = { + ssr, + basename, + prerender, + appDirectory, + }; + + return dedent` + import type { Config } from "@react-router/dev/config"; + + export default ${JSON.stringify(config)} satisfies Config; + `; +}; + +export const viteConfig = { + server: async (args: { port: number; fsAllow?: string[] }) => { + let { port, fsAllow } = args; + let hmrPort = await getPort(); + let text = dedent` + server: { + port: ${port}, + strictPort: true, + hmr: { port: ${hmrPort} }, + fs: { allow: ${fsAllow ? JSON.stringify(fsAllow) : "undefined"} } + }, + `; + return text; + }, + basic: async (args: { port: number; fsAllow?: string[] }) => { + return dedent` + import { reactRouter } from "@react-router/dev/vite"; + import { envOnlyMacros } from "vite-env-only"; + import tsconfigPaths from "vite-tsconfig-paths"; + + export default { + ${await viteConfig.server(args)} + plugins: [ + reactRouter(), + envOnlyMacros(), + tsconfigPaths() + ], + }; + `; + }, +}; + +export const EXPRESS_SERVER = (args: { + port: number; + loadContext?: Record; +}) => + String.raw` + import { createRequestHandler } from "@react-router/express"; + import express from "express"; + + let viteDevServer = + process.env.NODE_ENV === "production" + ? undefined + : await import("vite").then((vite) => + vite.createServer({ + server: { middlewareMode: true }, + }) + ); + + const app = express(); + + if (viteDevServer) { + app.use(viteDevServer.middlewares); + } else { + app.use( + "/assets", + express.static("build/client/assets", { immutable: true, maxAge: "1y" }) + ); + } + app.use(express.static("build/client", { maxAge: "1h" })); + + app.all( + "*", + createRequestHandler({ + build: viteDevServer + ? () => viteDevServer.ssrLoadModule("virtual:react-router/server-build") + : await import("./build/index.js"), + getLoadContext: () => (${JSON.stringify(args.loadContext ?? {})}), + }) + ); + + const port = ${args.port}; + app.listen(port, () => console.log('http://localhost:' + port)); + `; + +type TemplateName = "vite-template" | "vite-cloudflare-template"; + +export async function createProject( + files: Record = {}, + templateName: TemplateName = "vite-template" +) { + let projectName = `rr-${Math.random().toString(32).slice(2)}`; + let projectDir = path.join(TMP_DIR, projectName); + await fse.ensureDir(projectDir); + + // base template + let templateDir = path.resolve(__dirname, templateName); + await fse.copy(templateDir, projectDir, { errorOnExist: true }); + + // user-defined files + await Promise.all( + Object.entries(files).map(async ([filename, contents]) => { + let filepath = path.join(projectDir, filename); + await fse.ensureDir(path.dirname(filepath)); + await fse.writeFile(filepath, stripIndent(contents)); + }) + ); + + return projectDir; +} + +// Avoid "Warning: The 'NO_COLOR' env is ignored due to the 'FORCE_COLOR' env +// being set" in vite-ecosystem-ci which breaks empty stderr assertions. To fix +// this we always ensure that only NO_COLOR is set after spreading process.env. +const colorEnv = { + FORCE_COLOR: undefined, + NO_COLOR: "1", +} as const; + +export const build = ({ + cwd, + env = {}, +}: { + cwd: string; + env?: Record; +}) => { + let nodeBin = process.argv[0]; + + return spawnSync(nodeBin, [reactRouterBin, "build"], { + cwd, + env: { + ...process.env, + ...colorEnv, + ...env, + }, + }); +}; + +export const reactRouterServe = async ({ + cwd, + port, + serverBundle, + basename, +}: { + cwd: string; + port: number; + serverBundle?: string; + basename?: string; +}) => { + let nodeBin = process.argv[0]; + let serveProc = spawn( + nodeBin, + [ + "node_modules/@react-router/serve/dist/cli.js", + `build/server/${serverBundle ? serverBundle + "/" : ""}index.js`, + ], + { + cwd, + stdio: "pipe", + env: { NODE_ENV: "production", PORT: port.toFixed(0) }, + } + ); + await waitForServer(serveProc, { port, basename }); + return () => serveProc.kill(); +}; + +export const wranglerPagesDev = async ({ + cwd, + port, +}: { + cwd: string; + port: number; +}) => { + let nodeBin = process.argv[0]; + let wranglerBin = require.resolve("wrangler/bin/wrangler.js", { + paths: [cwd], + }); + + let proc = spawn( + nodeBin, + [wranglerBin, "pages", "dev", "./build/client", "--port", String(port)], + { + cwd, + stdio: "pipe", + env: { NODE_ENV: "production" }, + } + ); + await waitForServer(proc, { port, host: "127.0.0.1" }); + return () => proc.kill(); +}; + +type ServerArgs = { + cwd: string; + port: number; + env?: Record; + basename?: string; +}; + +const createDev = + (nodeArgs: string[]) => + async ({ cwd, port, env, basename }: ServerArgs): Promise<() => unknown> => { + let proc = node(nodeArgs, { cwd, env }); + await waitForServer(proc, { port, basename }); + return () => proc.kill(); + }; + +export const dev = createDev([reactRouterBin, "dev"]); +export const customDev = createDev(["./server.mjs"]); + +// Used for testing errors thrown on build when we don't want to start and +// wait for the server +export const viteDevCmd = ({ cwd }: { cwd: string }) => { + let nodeBin = process.argv[0]; + return spawnSync(nodeBin, [reactRouterBin, "dev"], { + cwd, + env: { ...process.env }, + }); +}; + +declare module "@playwright/test" { + interface Page { + errors: Error[]; + } +} + +export type Files = (args: { port: number }) => Promise>; +type Fixtures = { + page: Page; + dev: ( + files: Files, + templateName?: TemplateName + ) => Promise<{ + port: number; + cwd: string; + }>; + customDev: (files: Files) => Promise<{ + port: number; + cwd: string; + }>; + reactRouterServe: (files: Files) => Promise<{ + port: number; + cwd: string; + }>; + wranglerPagesDev: (files: Files) => Promise<{ + port: number; + cwd: string; + }>; +}; + +export const test = base.extend({ + page: async ({ page }, use) => { + page.errors = []; + page.on("pageerror", (error: Error) => page.errors.push(error)); + await use(page); + }, + // eslint-disable-next-line no-empty-pattern + dev: async ({}, use) => { + let stop: (() => unknown) | undefined; + await use(async (files, template) => { + let port = await getPort(); + let cwd = await createProject(await files({ port }), template); + stop = await dev({ cwd, port }); + return { port, cwd }; + }); + stop?.(); + }, + // eslint-disable-next-line no-empty-pattern + customDev: async ({}, use) => { + let stop: (() => unknown) | undefined; + await use(async (files) => { + let port = await getPort(); + let cwd = await createProject(await files({ port })); + stop = await customDev({ cwd, port }); + return { port, cwd }; + }); + stop?.(); + }, + // eslint-disable-next-line no-empty-pattern + reactRouterServe: async ({}, use) => { + let stop: (() => unknown) | undefined; + await use(async (files) => { + let port = await getPort(); + let cwd = await createProject(await files({ port })); + let { status } = build({ cwd }); + expect(status).toBe(0); + stop = await reactRouterServe({ cwd, port }); + return { port, cwd }; + }); + stop?.(); + }, + // eslint-disable-next-line no-empty-pattern + wranglerPagesDev: async ({}, use) => { + let stop: (() => unknown) | undefined; + await use(async (files) => { + let port = await getPort(); + let cwd = await createProject( + await files({ port }), + "vite-cloudflare-template" + ); + let { status } = build({ cwd }); + expect(status).toBe(0); + stop = await wranglerPagesDev({ cwd, port }); + return { port, cwd }; + }); + stop?.(); + }, +}); + +function node( + args: string[], + options: { cwd: string; env?: Record } +) { + let nodeBin = process.argv[0]; + + let proc = spawn(nodeBin, args, { + cwd: options.cwd, + env: { + ...process.env, + ...colorEnv, + ...options.env, + }, + stdio: "pipe", + }); + return proc; +} + +async function waitForServer( + proc: ChildProcess & { stdout: Readable; stderr: Readable }, + args: { port: number; host?: string; basename?: string } +) { + let devStdout = bufferize(proc.stdout); + let devStderr = bufferize(proc.stderr); + + await waitOn({ + resources: [ + `http://${args.host ?? "localhost"}:${args.port}${args.basename ?? "/"}`, + ], + timeout: platform() === "win32" ? 20000 : 10000, + }).catch((err) => { + let stdout = devStdout(); + let stderr = devStderr(); + proc.kill(); + throw new Error( + [ + err.message, + "", + "exit code: " + proc.exitCode, + "stdout: " + stdout ? `\n${stdout}\n` : "", + "stderr: " + stderr ? `\n${stderr}\n` : "", + ].join("\n") + ); + }); +} + +function bufferize(stream: Readable): () => string { + let buffer = ""; + stream.on("data", (data) => (buffer += data.toString())); + return () => buffer; +} + +export function createEditor(projectDir: string) { + return async (file: string, transform: (contents: string) => string) => { + let filepath = path.join(projectDir, file); + let contents = await fs.readFile(filepath, "utf8"); + await fs.writeFile(filepath, transform(contents), "utf8"); + }; +} + +export function grep(cwd: string, pattern: RegExp): string[] { + let assetFiles = glob.sync("**/*.@(js|jsx|ts|tsx)", { + cwd, + absolute: true, + }); + + let lines = shell + .grep("-l", pattern, assetFiles) + .stdout.trim() + .split("\n") + .filter((line) => line.length > 0); + return lines; +} diff --git a/integration/hook-useSubmit-test.ts b/integration/hook-useSubmit-test.ts new file mode 100644 index 0000000000..b28cc172e4 --- /dev/null +++ b/integration/hook-useSubmit-test.ts @@ -0,0 +1,135 @@ +import { test, expect } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("`useSubmit()` returned function", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/_index.tsx": js` + import { useLoaderData, useSubmit } from "react-router"; + + export function loader({ request }) { + let url = new URL(request.url); + return url.searchParams.toString() + } + + export default function Index() { + let submit = useSubmit(); + let handleClick = event => { + event.preventDefault() + submit(event.nativeEvent.submitter || event.currentTarget) + } + let data = useLoaderData(); + return ( + + + + + + +
{data}
+ + ) + } + `, + "app/routes/action.tsx": js` + import { useActionData, useSubmit } from "react-router"; + + export async function action({ request }) { + let contentType = request.headers.get('Content-Type'); + if (contentType.includes('application/json')) { + return { value: await request.json() }; + } + if (contentType.includes('text/plain')) { + return { value: await request.text() }; + } + let fd = await request.formData(); + return { value: new URLSearchParams(fd.entries()).toString() } + } + + export default function Component() { + let submit = useSubmit(); + let data = useActionData(); + return ( + <> + + + + {data ?

data: {JSON.stringify(data)}

: null} + + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("submits the submitter's value appended to the form data", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickElement("text=Prepare Third Task"); + await page.waitForLoadState("load"); + expect(await app.getHtml("pre")).toBe( + `
tasks=first&tasks=second&tasks=third
` + ); + }); + + test("submits json data", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action", true); + await app.clickElement("#submit-json"); + await page.waitForSelector("#action-data"); + expect(await app.getHtml()).toMatch('data: {"value":{"key":"value"}}'); + }); + + test("submits text data", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action", true); + await app.clickElement("#submit-text"); + await page.waitForSelector("#action-data"); + expect(await app.getHtml()).toMatch('data: {"value":"raw text"}'); + }); + + test("submits form data", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/action", true); + await app.clickElement("#submit-formData"); + await page.waitForSelector("#action-data"); + expect(await app.getHtml()).toMatch('data: {"value":"key=value"}'); + }); +}); diff --git a/integration/layout-route-test.ts b/integration/layout-route-test.ts new file mode 100644 index 0000000000..17e0aba5f4 --- /dev/null +++ b/integration/layout-route-test.ts @@ -0,0 +1,66 @@ +import { test } from "@playwright/test"; + +import { + createAppFixture, + createFixture, + js, +} from "./helpers/create-fixture.js"; +import type { AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +test.describe("pathless layout routes", () => { + let appFixture: AppFixture; + + test.beforeAll(async () => { + appFixture = await createAppFixture( + await createFixture({ + files: { + "app/routes/_layout.tsx": js` + import { Outlet } from "react-router"; + + export default () =>
; + `, + "app/routes/_layout._index.tsx": js` + export default () =>
Layout index
; + `, + "app/routes/_layout.subroute.tsx": js` + export default () =>
Layout subroute
; + `, + "app/routes/sandwiches._pathless.tsx": js` + import { Outlet } from "react-router"; + + export default () =>
; + `, + "app/routes/sandwiches._pathless._index.tsx": js` + export default () =>
Sandwiches pathless index
; + `, + }, + }) + ); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("should render pathless index route", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await page.waitForSelector("[data-testid='layout-route']"); + await page.waitForSelector("[data-testid='layout-index']"); + }); + + test("should render pathless sub route", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/subroute"); + await page.waitForSelector("[data-testid='layout-route']"); + await page.waitForSelector("[data-testid='layout-subroute']"); + }); + + test("should render pathless index as a sub route", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/sandwiches"); + await page.waitForSelector("[data-testid='sandwiches-pathless-route']"); + await page.waitForSelector("[data-testid='sandwiches-pathless-index']"); + }); +}); diff --git a/integration/link-test.ts b/integration/link-test.ts new file mode 100644 index 0000000000..1c7ffb3c23 --- /dev/null +++ b/integration/link-test.ts @@ -0,0 +1,626 @@ +import { test, expect } from "@playwright/test"; + +import { + css, + js, + createFixture, + createAppFixture, +} from "./helpers/create-fixture.js"; +import type { Fixture, AppFixture } from "./helpers/create-fixture.js"; +import { PlaywrightFixture } from "./helpers/playwright-fixture.js"; + +const fakeGists = [ + { + url: "https://api.github.com/gists/610613b54e5b34f8122d1ba4a3da21a9", + id: "610613b54e5b34f8122d1ba4a3da21a9", + files: { + "remix-server.jsx": { + filename: "remix-server.jsx", + }, + }, + owner: { + login: "ryanflorence", + id: 100200, + avatar_url: "https://avatars0.githubusercontent.com/u/100200?v=4", + }, + }, +]; + +test.describe("route module link export", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/favicon.ico": js``, + + "app/guitar.jpg": js``, + + "app/guitar-600.jpg": js``, + + "app/guitar-900.jpg": js``, + + "app/reset.css": css` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html { + font-size: 16px; + box-sizing: border-box; + } + `, + + "app/app.css": css` + body { + background-color: #eee; + color: #000; + } + `, + + "app/gists.css": css` + * { + color: dodgerblue; + } + `, + + "app/redText.css": css` + * { + color: red; + } + `, + + "app/blueText.css": css` + * { + color: blue; + } + `, + + "app/root.tsx": js` + import { + Link, + Links, + Meta, + Outlet, + Scripts, + useRouteError, + isRouteErrorResponse + } from "react-router"; + import resetHref from "./reset.css?url"; + import stylesHref from "./app.css?url"; + import favicon from "./favicon.ico"; + + export function links() { + return [ + { rel: "stylesheet", href: resetHref }, + { rel: "stylesheet", href: stylesHref }, + { rel: "stylesheet", href: "/resources/theme-css" }, + { rel: "shortcut icon", href: favicon }, + ]; + } + + export let handle = { + breadcrumb: () => Home, + }; + + export default function Root() { + return ( + + + + + + + + + + + + ); + } + + export function ErrorBoundary() { + let error = useRouteError(); + + if (isRouteErrorResponse()) { + switch (error.status) { + case 404: + return ( + + + + 404 Not Found + + + +
+

404 Not Found

+
+ + + + ); + default: + console.warn("Unexpected catch", error); + + return ( + + + + {error.status} Uh-oh! + + + +
+

+ {error.status} {error.statusText} +

+ {error.data ? ( +
+                              {JSON.stringify(error.data, null, 2)}
+                            
+ ) : null} +
+ + + + ); + } + } else { + console.error(error); + return ( + + + + Oops! + + + +
+

App Error Boundary

+
{error.message}
+
+ + + + ); + } + } + `, + + "app/routes/_index.tsx": js` + import { useEffect } from "react"; + import { Link } from "react-router"; + + export default function Index() { + return ( +
+
+

Cool App

+
+ +
+ ); + } + `, + + "app/routes/links.tsx": js` + import { useLoaderData, Link } from "react-router"; + import redTextHref from "~/redText.css?url"; + import blueTextHref from "~/blueText.css?url"; + import guitar from "~/guitar.jpg"; + export async function loader() { + return [ + { name: "Michael Jackson", id: "mjackson" }, + { name: "Ryan Florence", id: "ryanflorence" }, + ]; + } + export function links() { + return [ + { rel: "stylesheet", href: redTextHref }, + { + rel: "stylesheet", + href: blueTextHref, + media: "(prefers-color-scheme: beef)", + }, + { page: "/gists/mjackson" }, + { + rel: "preload", + as: "image", + href: guitar, + }, + ]; + } + export default function LinksPage() { + let users = useLoaderData(); + return ( +
+

Links Page

+ {users.map((user) => ( +
  • + + {user.name} + +
  • + ))} +
    +

    + a guitar Prefetched + because it's a preload. +

    +
    + ); + } + `, + + "app/routes/responsive-image-preload.tsx": js` + import { Link } from "react-router"; + import guitar600 from "~/guitar-600.jpg"; + import guitar900 from "~/guitar-900.jpg"; + + export function links() { + return [ + { + rel: "preload", + as: "image", + imageSrcSet: guitar600 + " 600w, " + guitar900 + " 900w", + imageSizes: "100vw", + }, + ]; + } + export default function LinksPage() { + return ( +
    +

    Responsive Guitar

    +

    + a guitar{" "} + Prefetched because it's a preload. +

    +
    + ); + } + `, + + "app/routes/gists.tsx": js` + import { data, Link, Outlet, useLoaderData, useNavigation } from "react-router"; + import stylesHref from "~/gists.css?url"; + export function links() { + return [{ rel: "stylesheet", href: stylesHref }]; + } + export async function loader() { + return data({ + users: [ + { id: "ryanflorence", name: "Ryan Florence" }, + { id: "mjackson", name: "Michael Jackson" }, + ], + }, { + headers: { + "Cache-Control": "public, max-age=60", + }, + }); + } + export function headers({ loaderHeaders }) { + return { + "Cache-Control": loaderHeaders.get("Cache-Control"), + }; + } + export let handle = { + breadcrumb: () => Gists, + }; + export default function Gists() { + let locationPending = useNavigation().location; + let { users } = useLoaderData(); + return ( +
    +
    +

    Gists

    +
      + {users.map((user) => ( +
    • + + {user.name} {locationPending ? "..." : null} + +
    • + ))} +
    +
    + +
    + ); + } + `, + + "app/routes/gists.$username.tsx": js` + import { data, redirect } from "react-router"; + import { Link, useLoaderData, useParams } from "react-router"; + export async function loader({ params }) { + let { username } = params; + if (username === "mjijackson") { + return redirect("/gists/mjackson", 302); + } + if (username === "_why") { + return data(null, { status: 404 }); + } + return ${JSON.stringify(fakeGists)}; + } + export function headers() { + return { + "Cache-Control": "public, max-age=300", + }; + } + export function meta({ data, params }) { + let { username } = params; + return [ + { + title: data + ? data.length + " gists from " + username + : "User " + username + " not found", + }, + { name: "description", content: "View all of the gists from " + username }, + ]; + } + export let handle = { + breadcrumb: ({ params }) => ( + {params.username} + ), + }; + export default function UserGists() { + let { username } = useParams(); + let data = useLoaderData(); + return ( +
    + {data ? ( + <> +

    All gists from {username}

    + + + ) : ( +

    No gists for {username}

    + )} +
    + ); + } + `, + + "app/routes/gists._index.tsx": js` + import { useLoaderData } from "react-router"; + export async function loader() { + return ${JSON.stringify(fakeGists)}; + } + export function headers() { + return { + "Cache-Control": "public, max-age=60", + }; + } + export function meta() { + return [ + { title: "Public Gists" }, + { name: "description", content: "View the latest gists from the public" }, + ]; + } + export let handle = { + breadcrumb: () => Public, + }; + export default function GistsIndex() { + let data = useLoaderData(); + return ( +
    + ); + } + `, + + "app/routes/resources.theme-css.tsx": js` + import { redirect } from "react-router"; + export async function loader({ request }) { + return new Response(":root { --nc-tx-1: #ffffff; --nc-tx-2: #eeeeee; }", + { + headers: { + "Content-Type": "text/css; charset=UTF-8", + "x-has-custom": "yes", + }, + } + ); + } + + `, + + "app/routes/parent.tsx": js` + import { Outlet } from "react-router"; + + export function links() { + return [ + { "data-test-id": "red" }, + ]; + } + + export default function Component() { + return
    ; + } + + export function ErrorBoundary() { + return

    Error Boundary

    ; + } + `, + + "app/routes/parent.child.tsx": js` + import { Outlet } from "react-router"; + + export function loader() { + throw new Response(null, { status: 404 }); + } + + export function links() { + return [ + { "data-test-id": "blue" }, + ]; + } + + export default function Component() { + return
    ; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("adds responsive image preload links to the document", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/responsive-image-preload"); + await page.waitForSelector('[data-test-id="/responsive-image-preload"]'); + let locator = page.locator("link[rel=preload][as=image]"); + expect(await locator.getAttribute("imagesizes")).toBe("100vw"); + }); + + test("waits for new styles to load before transitioning", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + + let cssResponses = app.collectResponses((url) => + url.pathname.endsWith(".css") + ); + + await page.click('a[href="/gists"]'); + await page.waitForSelector('[data-test-id="/gists/index"]'); + + let stylesheetResponses = cssResponses.filter((res) => { + // ignore prefetches + return res.request().resourceType() === "stylesheet"; + }); + + expect(stylesheetResponses.length).toEqual(1); + }); + + test("does not render errored child route links", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.click('a[href="/parent/child"]'); + await page.waitForSelector('[data-test-id="/parent:error-boundary"]'); + await page.waitForSelector('[data-test-id="red"]', { state: "attached" }); + await page.waitForSelector('[data-test-id="blue"]', { + state: "detached", + }); + }); + + test.describe("no js", () => { + test.use({ javaScriptEnabled: false }); + + test("adds links to the document", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let responses = app.collectResponses((url) => + url.pathname.endsWith(".css") + ); + + await app.goto("/links"); + await page.waitForSelector('[data-test-id="/links"]'); + expect(responses.length).toEqual(4); + }); + + test("adds responsive image preload links to the document", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/responsive-image-preload"); + await page.waitForSelector('[data-test-id="/responsive-image-preload"]'); + let locator = page.locator("link[rel=preload][as=image]"); + expect(await locator.getAttribute("imagesizes")).toBe("100vw"); + }); + + test("does not render errored child route links", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + await page.waitForSelector('[data-test-id="/parent:error-boundary"]'); + await page.waitForSelector('[data-test-id="red"]', { state: "attached" }); + await page.waitForSelector('[data-test-id="blue"]', { + state: "detached", + }); + }); + }); + + test.describe("script imports", () => { + // Disable JS for this test since we don't want it to hydrate and remove + // the initial " }; + expect(escapeHtml(JSON.stringify(evilObj))).toBe( + '{"evil":"\\u003cscript\\u003e\\u003c/script\\u003e"}' + ); + }); + + test("with angle brackets should parse back", () => { + let evilObj = { evil: "" }; + expect(JSON.parse(escapeHtml(JSON.stringify(evilObj)))).toMatchObject( + evilObj + ); + }); + + test("with ampersands should escape", () => { + let evilObj = { evil: "&" }; + expect(escapeHtml(JSON.stringify(evilObj))).toBe('{"evil":"\\u0026"}'); + }); + + test("with ampersands should parse back", () => { + let evilObj = { evil: "&" }; + expect(JSON.parse(escapeHtml(JSON.stringify(evilObj)))).toMatchObject( + evilObj + ); + }); + + test('with "LINE SEPARATOR" and "PARAGRAPH SEPARATOR" should escape', () => { + let evilObj = { evil: "\u2028\u2029" }; + expect(escapeHtml(JSON.stringify(evilObj))).toBe( + '{"evil":"\\u2028\\u2029"}' + ); + }); + + test('with "LINE SEPARATOR" and "PARAGRAPH SEPARATOR" should parse back', () => { + let evilObj = { evil: "\u2028\u2029" }; + expect(JSON.parse(escapeHtml(JSON.stringify(evilObj)))).toMatchObject( + evilObj + ); + }); + + test("escaped line terminators should work", () => { + expect(() => { + vm.runInNewContext( + "(" + escapeHtml(JSON.stringify({ evil: "\u2028\u2029" })) + ")" + ); + }).not.toThrow(); + }); +}); diff --git a/packages/react-router/__tests__/server-runtime/responses-test.ts b/packages/react-router/__tests__/server-runtime/responses-test.ts new file mode 100644 index 0000000000..44bb4ab8f4 --- /dev/null +++ b/packages/react-router/__tests__/server-runtime/responses-test.ts @@ -0,0 +1,90 @@ +/** + * @jest-environment node + */ + +import { redirect } from "../../lib/router/utils"; + +describe("json", () => { + it("sets the Content-Type header", () => { + let response = Response.json({}); + expect(response.headers.get("Content-Type")).toEqual("application/json"); + }); + + it("preserves existing headers, including Content-Type", () => { + let response = Response.json( + {}, + { + headers: { + "Content-Type": "application/json; charset=iso-8859-1", + "X-Remix": "is awesome", + }, + } + ); + + expect(response.headers.get("Content-Type")).toEqual( + "application/json; charset=iso-8859-1" + ); + expect(response.headers.get("X-Remix")).toEqual("is awesome"); + }); + + it("encodes the response body", async () => { + let response = Response.json({ hello: "remix" }); + expect(await response.json()).toEqual({ hello: "remix" }); + }); + + it("accepts status as a second parameter", () => { + let response = Response.json({}, { status: 201 }); + expect(response.status).toEqual(201); + }); + + it("infers input type", async () => { + let response = Response.json({ hello: "remix" }); + let result = await response.json(); + expect(result).toMatchObject({ hello: "remix" }); + }); + + it("disallows unserializables", () => { + // @ts-expect-error + expect(() => Response.json(124n)).toThrow(); + // @ts-expect-error + expect(() => Response.json({ field: 124n })).toThrow(); + }); +}); + +describe("redirect", () => { + it("sets the status to 302 by default", () => { + let response = redirect("/login"); + expect(response.status).toEqual(302); + }); + + it("sets the status to 302 when only headers are given", () => { + let response = redirect("/login", { + headers: { + "X-Remix": "is awesome", + }, + }); + expect(response.status).toEqual(302); + }); + + it("sets the Location header", () => { + let response = redirect("/login"); + expect(response.headers.get("Location")).toEqual("/login"); + }); + + it("preserves existing headers, but not Location", () => { + let response = redirect("/login", { + headers: { + Location: "/", + "X-Remix": "is awesome", + }, + }); + + expect(response.headers.get("Location")).toEqual("/login"); + expect(response.headers.get("X-Remix")).toEqual("is awesome"); + }); + + it("accepts status as a second parameter", () => { + let response = redirect("/profile", 301); + expect(response.status).toEqual(301); + }); +}); diff --git a/packages/react-router/__tests__/server-runtime/server-test.ts b/packages/react-router/__tests__/server-runtime/server-test.ts new file mode 100644 index 0000000000..ca5bd2f875 --- /dev/null +++ b/packages/react-router/__tests__/server-runtime/server-test.ts @@ -0,0 +1,2084 @@ +/** + * @jest-environment node + */ + +import type { StaticHandlerContext } from "react-router"; + +import { createRequestHandler } from "../../lib/server-runtime/server"; +import { ServerMode } from "../../lib/server-runtime/mode"; +import type { ServerBuild } from "../../lib/server-runtime/build"; +import { mockServerBuild } from "./utils"; + +function spyConsole() { + // https://github.com/facebook/react/issues/7047 + let spy: any = {}; + + beforeAll(() => { + spy.console = jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterAll(() => { + spy.console.mockRestore(); + }); + + return spy; +} + +describe.skip("server", () => { + let routeId = "root"; + let build: ServerBuild = { + entry: { + module: { + default: async (request) => { + return new Response(`${request.method}, ${request.url} COMPONENT`); + }, + }, + }, + routes: { + [routeId]: { + id: routeId, + path: "", + module: { + action: ({ request }) => + new Response(`${request.method} ${request.url} ACTION`), + loader: ({ request }) => + new Response(`${request.method} ${request.url} LOADER`), + default: () => "COMPONENT", + }, + }, + }, + assets: { + routes: { + [routeId]: { + hasAction: true, + hasErrorBoundary: false, + hasLoader: true, + id: routeId, + module: routeId, + path: "", + }, + }, + }, + future: {}, + } as unknown as ServerBuild; + + describe("createRequestHandler", () => { + let spy = spyConsole(); + + beforeEach(() => { + spy.console.mockClear(); + }); + + let allowThrough = [ + ["GET", "/"], + ["GET", "/?_data=root"], + ["POST", "/"], + ["POST", "/?_data=root"], + ["PUT", "/"], + ["PUT", "/?_data=root"], + ["DELETE", "/"], + ["DELETE", "/?_data=root"], + ["PATCH", "/"], + ["PATCH", "/?_data=root"], + ]; + it.each(allowThrough)( + `allows through %s request to %s`, + async (method, to) => { + let handler = createRequestHandler(build); + let response = await handler( + new Request(`http://localhost:3000${to}`, { + method, + }) + ); + + expect(response.status).toBe(200); + let text = await response.text(); + expect(text).toContain(method); + let expected = !to.includes("?_data=root") + ? "COMPONENT" + : method === "GET" + ? "LOADER" + : "ACTION"; + expect(text).toContain(expected); + expect(spy.console).not.toHaveBeenCalled(); + } + ); + + it("strips body for HEAD requests", async () => { + let handler = createRequestHandler(build); + let response = await handler( + new Request("http://localhost:3000/", { + method: "HEAD", + }) + ); + + expect(await response.text()).toBe(""); + }); + }); +}); + +describe("shared server runtime", () => { + let spy = spyConsole(); + + beforeEach(() => { + spy.console.mockClear(); + }); + + let baseUrl = "http://test.com"; + + describe("resource routes", () => { + test("calls resource route loader", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(() => { + return Response.json("resource"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("resource"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(resourceLoader.mock.calls.length).toBe(1); + }); + + test("calls sub resource route loader", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(() => { + return Response.json("resource"); + }); + let subResourceLoader = jest.fn(() => { + return Response.json("sub"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + "routes/resource.sub": { + loader: subResourceLoader, + path: "resource/sub", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource/sub`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("sub"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(resourceLoader.mock.calls.length).toBe(0); + expect(subResourceLoader.mock.calls.length).toBe(1); + }); + + test("resource route loader allows thrown responses", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(() => { + throw new Response("resource"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.text()).toBe("resource"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(resourceLoader.mock.calls.length).toBe(1); + }); + + test("resource route loader responds with generic error when thrown", async () => { + let error = new Error("should be logged when resource loader throws"); + let loader = jest.fn(() => { + throw error; + }); + let build = mockServerBuild({ + "routes/resource": { + loader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "get", + }); + + let result = await handler(request); + expect(await result.text()).toBe( + "Unexpected Server Error\n\nError: should be logged when resource loader throws" + ); + }); + + test("resource route loader responds with detailed error when thrown in development", async () => { + let error = new Error("should be logged when resource loader throws"); + let loader = jest.fn(() => { + throw error; + }); + let build = mockServerBuild({ + "routes/resource": { + loader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/resource`, { + method: "get", + }); + + let result = await handler(request); + expect((await result.text()).includes(error.message)).toBe(true); + expect(spy.console.mock.calls.length).toBe(1); + }); + + test("calls resource route action", async () => { + let rootAction = jest.fn(() => { + return "root"; + }); + let resourceAction = jest.fn(() => { + return Response.json("resource"); + }); + let build = mockServerBuild({ + root: { + default: {}, + action: rootAction, + }, + "routes/resource": { + action: resourceAction, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("resource"); + expect(rootAction.mock.calls.length).toBe(0); + expect(resourceAction.mock.calls.length).toBe(1); + }); + + test("calls sub resource route action", async () => { + let rootAction = jest.fn(() => { + return "root"; + }); + let resourceAction = jest.fn(() => { + return Response.json("resource"); + }); + let subResourceAction = jest.fn(() => { + return Response.json("sub"); + }); + let build = mockServerBuild({ + root: { + default: {}, + action: rootAction, + }, + "routes/resource": { + action: resourceAction, + path: "resource", + }, + "routes/resource.sub": { + action: subResourceAction, + path: "resource/sub", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource/sub`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("sub"); + expect(rootAction.mock.calls.length).toBe(0); + expect(resourceAction.mock.calls.length).toBe(0); + expect(subResourceAction.mock.calls.length).toBe(1); + }); + + test("resource route action allows thrown responses", async () => { + let rootAction = jest.fn(() => { + return "root"; + }); + let resourceAction = jest.fn(() => { + throw new Response("resource"); + }); + let build = mockServerBuild({ + root: { + default: {}, + action: rootAction, + }, + "routes/resource": { + action: resourceAction, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.text()).toBe("resource"); + expect(rootAction.mock.calls.length).toBe(0); + expect(resourceAction.mock.calls.length).toBe(1); + }); + + test("resource route action responds with generic error when thrown", async () => { + let error = new Error("should be logged when resource loader throws"); + let action = jest.fn(() => { + throw error; + }); + let build = mockServerBuild({ + "routes/resource": { + action, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "post", + }); + + let result = await handler(request); + expect(await result.text()).toBe( + "Unexpected Server Error\n\nError: should be logged when resource loader throws" + ); + }); + + test("resource route action responds with detailed error when thrown in development", async () => { + let message = "should be logged when resource loader throws"; + let action = jest.fn(() => { + throw new Error(message); + }); + let build = mockServerBuild({ + "routes/resource": { + action, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/resource`, { + method: "post", + }); + + let result = await handler(request); + expect((await result.text()).includes(message)).toBe(true); + expect(spy.console.mock.calls.length).toBe(1); + }); + + test("aborts request with reason", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(async () => { + await new Promise((r) => setTimeout(r, 10)); + return "resource"; + }); + let handleErrorSpy = jest.fn(); + let build = mockServerBuild( + { + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + }, + { + handleError: handleErrorSpy, + } + ); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/resource`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + expect(await result.text()).toMatchInlineSnapshot(` + "Unexpected Server Error + + AbortError: This operation was aborted" + `); + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0] instanceof DOMException).toBe( + true + ); + expect(handleErrorSpy.mock.calls[0][0].name).toBe("AbortError"); + expect(handleErrorSpy.mock.calls[0][0].message).toBe( + "This operation was aborted" + ); + expect(handleErrorSpy.mock.calls[0][1].request.method).toBe("GET"); + expect(handleErrorSpy.mock.calls[0][1].request.url).toBe( + "http://test.com/resource" + ); + }); + }); + + describe.skip("data requests", () => { + test("data request that does not match loader surfaces 400 error for boundary", async () => { + let build = mockServerBuild({ + root: { + default: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect((await result.json()).message).toBeTruthy(); + }); + + test("data request that does not match routeId surfaces 403 error for boundary", async () => { + let build = mockServerBuild({ + root: { + default: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + loader: () => null, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + // This bug wasn't that the router wasn't returning a 404 (it was), but + // that we weren't defensive when looking at match.params when we went + // to call handleDataRequest(), - and that threw it's own uncaught + // exception triggering a 500. We need to ensure that this build has a + // handleDataRequest implementation for this test to mean anything + expect(build.entry.module.handleDataRequest).toBeDefined(); + + let request = new Request(`${baseUrl}/?_data=routes/junk`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(403); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect((await result.json()).message).toBeTruthy(); + }); + + test("data request that does not match route surfaces 404 error for boundary", async () => { + let build = mockServerBuild({ + root: { + default: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + loader: () => null, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/junk?_data=routes/junk`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(404); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect((await result.json()).message).toBeTruthy(); + }); + + test("data request calls loader", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + loader: indexLoader, + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("index"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(1); + }); + + test("data request calls loader and responds with generic message and error header", async () => { + let rootLoader = jest.fn(() => { + throw new Error("test"); + }); + let testAction = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=root`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.json()).message).toBe("Unexpected Server Error"); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testAction.mock.calls.length).toBe(0); + }); + + test("data request calls loader and responds with detailed info and error header in development mode", async () => { + let message = + "data request loader error logged to console once in dev mode"; + let rootLoader = jest.fn(() => { + throw new Error(message); + }); + let testAction = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/test?_data=root`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.json()).message).toBe(message); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testAction.mock.calls.length).toBe(0); + expect(spy.console.mock.calls.length).toBe(1); + }); + + test("data request calls loader and responds with catch header", async () => { + let rootLoader = jest.fn(() => { + throw new Response("test", { status: 400 }); + }); + let testAction = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=root`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(await result.text()).toBe("test"); + expect(result.headers.get("X-Remix-Catch")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testAction.mock.calls.length).toBe(0); + }); + + test("data request calls action", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=routes/test`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("test"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(testAction.mock.calls.length).toBe(1); + }); + + test("data request calls action and responds with generic message and error header", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Error("test"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=routes/test`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.json()).message).toBe("Unexpected Server Error"); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(testAction.mock.calls.length).toBe(1); + }); + + test("data request calls action and responds with detailed info and error header in development mode", async () => { + let message = + "data request action error logged to console once in dev mode"; + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Error(message); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/test?_data=routes/test`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.json()).message).toBe(message); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(testAction.mock.calls.length).toBe(1); + expect(spy.console.mock.calls.length).toBe(1); + }); + + test("data request calls action and responds with catch header", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Response("test", { status: 400 }); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=routes/test`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(await result.text()).toBe("test"); + expect(result.headers.get("X-Remix-Catch")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(testAction.mock.calls.length).toBe(1); + }); + + test("data request calls layout action", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let rootAction = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + action: rootAction, + }, + "routes/_index": { + parentId: "root", + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?_data=root`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("root"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(rootAction.mock.calls.length).toBe(1); + }); + + test("data request calls index action", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + action: indexAction, + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index&_data=routes/_index`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("index"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexAction.mock.calls.length).toBe(1); + }); + + test("data request handleDataRequest redirects are handled", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + loader: indexLoader, + index: true, + }, + }); + build.entry.module.handleDataRequest.mockImplementation(async () => { + return new Response(null, { + status: 302, + headers: { + Location: "/redirect", + }, + }); + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(204); + expect(result.headers.get("X-Remix-Redirect")).toBe("/redirect"); + expect(result.headers.get("X-Remix-Status")).toBe("302"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(1); + }); + + test("aborts request", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let handleErrorSpy = jest.fn(); + let build = mockServerBuild( + { + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + loader: indexLoader, + index: true, + }, + }, + { + handleError: handleErrorSpy, + } + ); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + let error = await result.json(); + expect(error.message).toBe("This operation was aborted"); + expect( + error.stack.startsWith("AbortError: This operation was aborted") + ).toBe(true); + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0] instanceof DOMException).toBe( + true + ); + expect(handleErrorSpy.mock.calls[0][0].name).toBe("AbortError"); + expect(handleErrorSpy.mock.calls[0][0].message).toBe( + "This operation was aborted" + ); + expect(handleErrorSpy.mock.calls[0][1].request.method).toBe("GET"); + expect(handleErrorSpy.mock.calls[0][1].request.url).toBe( + "http://test.com/?_data=routes/_index" + ); + }); + }); + + describe("document requests", () => { + test("not found document request for no matches and no ErrorBoundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(404); + expect(rootLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(404); + }); + + test("sets root as catch boundary for not found document request", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(404); + expect(rootLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(404); + expect(context.loaderData).toEqual({}); + }); + + test("thrown loader responses bubble up", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(400); + expect(context.loaderData).toEqual({ + root: "root", + }); + }); + + test("thrown loader responses catch deep", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/_index"].status).toBe(400); + expect(context.loaderData).toEqual({ + root: "root", + }); + }); + + test("thrown action responses bubble up", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/test": { + parentId: "root", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(testAction.mock.calls.length).toBe(1); + // Should not call root loader since it is the boundary route + expect(rootLoader.mock.calls.length).toBe(0); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(400); + expect(context.loaderData).toEqual({ + root: null, + "routes/test": null, + }); + }); + + test("thrown action responses bubble up for index routes", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(indexAction.mock.calls.length).toBe(1); + // Should not call root loader since it is the boundary route + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(400); + expect(context.loaderData).toEqual({ + root: null, + "routes/_index": null, + }); + }); + + test("thrown action responses catch deep", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/test": { + parentId: "root", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(testAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/test"].status).toBe(400); + expect(context.loaderData).toEqual({ + root: "root", + "routes/test": null, + }); + }); + + test("thrown action responses catch deep for index routes", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(indexAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/_index"].status).toBe(400); + expect(context.loaderData).toEqual({ + root: "root", + "routes/_index": null, + }); + }); + + test("thrown loader response after thrown action response bubble up action throw to deepest loader boundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let layoutLoader = jest.fn(() => { + throw new Response("layout", { status: 401 }); + }); + let testAction = jest.fn(() => { + throw new Response("action", { status: 400 }); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/__layout": { + parentId: "root", + default: {}, + loader: layoutLoader, + ErrorBoundary: {}, + }, + "routes/__layout/test": { + parentId: "routes/__layout", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(testAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/__layout"].data).toBe("action"); + expect(context.loaderData).toEqual({ + root: "root", + "routes/__layout": null, + "routes/__layout/test": null, + }); + }); + + test("thrown loader response after thrown index action response bubble up action throw to deepest loader boundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let layoutLoader = jest.fn(() => { + throw new Response("layout", { status: 401 }); + }); + let indexAction = jest.fn(() => { + throw new Response("action", { status: 400 }); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/__layout": { + parentId: "root", + default: {}, + loader: layoutLoader, + ErrorBoundary: {}, + }, + "routes/__layout/index": { + parentId: "routes/__layout", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(indexAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/__layout"].data).toBe("action"); + expect(context.loaderData).toEqual({ + root: "root", + "routes/__layout": null, + "routes/__layout/index": null, + }); + }); + + test("loader errors bubble up", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + throw new Error("index"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root).toBeInstanceOf(Error); + expect(context.errors!.root.message).toBe("Unexpected Server Error"); + expect(context.errors!.root.stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + }); + }); + + test("loader errors catch deep", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + throw new Error("index"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/_index"]).toBeInstanceOf(Error); + expect(context.errors!["routes/_index"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/_index"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + }); + }); + + test("action errors bubble up", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Error("test"); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/test": { + parentId: "root", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(testAction.mock.calls.length).toBe(1); + // Should not call root loader since it is the boundary route + expect(rootLoader.mock.calls.length).toBe(0); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root).toBeInstanceOf(Error); + expect(context.errors!.root.message).toBe("Unexpected Server Error"); + expect(context.errors!.root.stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: null, + "routes/test": null, + }); + }); + + test("action errors bubble up for index routes", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + throw new Error("index"); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(indexAction.mock.calls.length).toBe(1); + // Should not call root loader since it is the boundary route + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root).toBeInstanceOf(Error); + expect(context.errors!.root.message).toBe("Unexpected Server Error"); + expect(context.errors!.root.stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: null, + "routes/_index": null, + }); + }); + + test("action errors catch deep", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Error("test"); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/test": { + parentId: "root", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(testAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/test"]).toBeInstanceOf(Error); + expect(context.errors!["routes/test"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/test"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + "routes/test": null, + }); + }); + + test("action errors catch deep for index routes", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + throw new Error("index"); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(indexAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/_index"]).toBeInstanceOf(Error); + expect(context.errors!["routes/_index"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/_index"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + "routes/_index": null, + }); + }); + + test("loader errors after action error bubble up action error to deepest loader boundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let layoutLoader = jest.fn(() => { + throw new Error("layout"); + }); + let testAction = jest.fn(() => { + throw new Error("action"); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/__layout": { + parentId: "root", + default: {}, + loader: layoutLoader, + ErrorBoundary: {}, + }, + "routes/__layout/test": { + parentId: "routes/__layout", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(testAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/__layout"]).toBeInstanceOf(Error); + expect(context.errors!["routes/__layout"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/__layout"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + "routes/__layout": null, + "routes/__layout/test": null, + }); + }); + + test("loader errors after index action error bubble up action error to deepest loader boundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let layoutLoader = jest.fn(() => { + throw new Error("layout"); + }); + let indexAction = jest.fn(() => { + throw new Error("action"); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/__layout": { + parentId: "root", + default: {}, + loader: layoutLoader, + ErrorBoundary: {}, + }, + "routes/__layout/index": { + parentId: "routes/__layout", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(indexAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/__layout"]).toBeInstanceOf(Error); + expect(context.errors!["routes/__layout"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/__layout"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + "routes/__layout": null, + "routes/__layout/index": null, + }); + }); + + test("calls handleDocumentRequest again with new error when handleDocumentRequest throws", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let calledBefore = false; + let ogHandleDocumentRequest = build.entry.module.default; + build.entry.module.default = jest.fn(function () { + if (!calledBefore) { + throw new Error("thrown"); + } + calledBefore = true; + return ogHandleDocumentRequest.call(null, ...arguments); + }) as any; + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/404`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(0); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(2); + let context = calls[1][3].staticHandlerContext; + expect(context.errors.root).toBeTruthy(); + expect(context.errors!.root.message).toBe("thrown"); + expect(context.loaderData).toEqual({}); + }); + + test("unwraps responses thrown from handleDocumentRequest", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let ogHandleDocumentRequest = build.entry.module.default; + build.entry.module.default = function ( + _: Request, + responseStatusCode: number + ) { + if (responseStatusCode === 200) { + throw new Response("Uh oh!", { + status: 400, + statusText: "Bad Request", + }); + } + return ogHandleDocumentRequest.call(null, ...arguments); + } as any; + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(400); + }); + + test("returns generic message if handleDocumentRequest throws a second time", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + default: {}, + loader: indexLoader, + }, + }); + let lastThrownError; + build.entry.module.default = jest.fn(function () { + lastThrownError = new Error("rofl"); + throw lastThrownError; + }) as any; + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(await result.text()).toBe( + "Unexpected Server Error\n\nError: rofl" + ); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(0); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(2); + }); + + test("returns more detailed message if handleDocumentRequest throws a second time in development mode", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + path: "/", + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let errorMessage = + "thrown from handleDocumentRequest and expected to be logged in console only once"; + let lastThrownError; + build.entry.module.default = jest.fn(function () { + lastThrownError = new Error(errorMessage); + errorMessage = "second error thrown from handleDocumentRequest"; + throw lastThrownError; + }) as any; + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/`); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.text()).includes(errorMessage)).toBe(true); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(2); + expect(spy.console.mock.calls).toEqual([ + [ + new Error( + "thrown from handleDocumentRequest and expected to be logged in console only once" + ), + ], + [new Error("second error thrown from handleDocumentRequest")], + ]); + }); + + test("aborts request", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(async () => { + await new Promise((r) => setTimeout(r, 10)); + return "index"; + }); + let handleErrorSpy = jest.fn(); + let build = mockServerBuild( + { + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: indexLoader, + index: true, + default: {}, + }, + }, + { + handleError: handleErrorSpy, + } + ); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + expect(build.entry.module.default.mock.calls.length).toBe(0); + + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0] instanceof DOMException).toBe( + true + ); + expect(handleErrorSpy.mock.calls[0][0].name).toBe("AbortError"); + expect(handleErrorSpy.mock.calls[0][0].message).toBe( + "This operation was aborted" + ); + expect(handleErrorSpy.mock.calls[0][1].request.method).toBe("GET"); + expect(handleErrorSpy.mock.calls[0][1].request.url).toBe( + "http://test.com/" + ); + }); + }); + + test("provides load context to server entrypoint", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + default: {}, + loader: indexLoader, + }, + }); + + build.entry.module.default = jest.fn( + async ( + request, + responseStatusCode, + responseHeaders, + entryContext, + loadContext + ) => + new Response(JSON.stringify(loadContext), { + status: responseStatusCode, + headers: responseHeaders, + }) + ); + + let handler = createRequestHandler(build, ServerMode.Development); + let request = new Request(`${baseUrl}/`, { method: "get" }); + let loadContext = { "load-context": "load-value" }; + + let result = await handler(request, loadContext); + expect(await result.text()).toBe(JSON.stringify(loadContext)); + }); +}); diff --git a/packages/react-router/__tests__/server-runtime/sessions-test.ts b/packages/react-router/__tests__/server-runtime/sessions-test.ts new file mode 100644 index 0000000000..13e16644a3 --- /dev/null +++ b/packages/react-router/__tests__/server-runtime/sessions-test.ts @@ -0,0 +1,237 @@ +/** + * @jest-environment node + */ + +import { createSession, isSession } from "../../lib/server-runtime/sessions"; +import { createCookieSessionStorage } from "../../lib/server-runtime/sessions/cookieStorage"; +import { createMemorySessionStorage } from "../../lib/server-runtime/sessions/memoryStorage"; + +function getCookieFromSetCookie(setCookie: string): string { + return setCookie.split(/;\s*/)[0]; +} + +describe("Session", () => { + it("has an empty id by default", () => { + expect(createSession().id).toEqual(""); + }); + + it("correctly stores and retrieves values", () => { + let session = createSession(); + + session.set("user", "mjackson"); + session.flash("error", "boom"); + + expect(session.has("user")).toBe(true); + expect(session.get("user")).toBe("mjackson"); + // Normal values should remain in the session after get() + expect(session.has("user")).toBe(true); + expect(session.get("user")).toBe("mjackson"); + + expect(session.has("error")).toBe(true); + expect(session.get("error")).toBe("boom"); + // Flash values disappear after the first get() + expect(session.has("error")).toBe(false); + expect(session.get("error")).toBeUndefined(); + + session.unset("user"); + + expect(session.has("user")).toBe(false); + expect(session.get("user")).toBeUndefined(); + }); +}); + +describe("isSession", () => { + it("returns `true` for Session objects", () => { + expect(isSession(createSession())).toBe(true); + }); + + it("returns `false` for non-Session objects", () => { + expect(isSession({})).toBe(false); + expect(isSession([])).toBe(false); + expect(isSession("")).toBe(false); + expect(isSession(true)).toBe(false); + }); +}); + +describe("In-memory session storage", () => { + it("persists session data across requests", async () => { + let { getSession, commitSession } = createMemorySessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + + expect(session.get("user")).toEqual("mjackson"); + }); + + it("uses random hash keys as session ids", async () => { + let { getSession, commitSession } = createMemorySessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + expect(session.id).toMatch(/^[a-z0-9]{8}$/); + }); +}); + +describe("Cookie session storage", () => { + it("persists session data across requests", async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + + expect(session.get("user")).toEqual("mjackson"); + }); + + it("returns an empty session for cookies that are not signed properly", async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + + expect(session.get("user")).toEqual("mjackson"); + + let setCookie = await commitSession(session); + session = await getSession( + // Tamper with the session cookie... + getCookieFromSetCookie(setCookie).slice(0, -1) + ); + + expect(session.get("user")).toBeUndefined(); + }); + + it('"makes the default path of cookies to be /', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + expect(setCookie).toContain("Path=/"); + }); + + it("throws an error when the cookie size exceeds 4096 bytes", async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + let longString = new Array(4097).fill("a").join(""); + session.set("over4096bytes", longString); + await expect(() => commitSession(session)).rejects.toThrow(); + }); + + it("destroys sessions using a past date", async () => { + let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let { getSession, destroySession } = createCookieSessionStorage({ + cookie: { + secrets: ["secret1"], + }, + }); + let session = await getSession(); + let setCookie = await destroySession(session); + expect(setCookie).toMatchInlineSnapshot( + `"__session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` + ); + spy.mockRestore(); + }); + + it("destroys sessions that leverage maxAge", async () => { + let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let { getSession, destroySession } = createCookieSessionStorage({ + cookie: { + maxAge: 60 * 60, // 1 hour + secrets: ["secret1"], + }, + }); + let session = await getSession(); + let setCookie = await destroySession(session); + expect(setCookie).toMatchInlineSnapshot( + `"__session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` + ); + spy.mockRestore(); + }); + + describe("warnings when providing options you may not want to", () => { + let spy = spyConsole(); + + it("warns against using `expires` when creating the session", async () => { + createCookieSessionStorage({ + cookie: { + secrets: ["secret1"], + expires: new Date(Date.now() + 60_000), + }, + }); + + expect(spy.console).toHaveBeenCalledTimes(1); + expect(spy.console).toHaveBeenCalledWith( + 'The "__session" cookie has an "expires" property set. This will cause the expires value to not be updated when the session is committed. Instead, you should set the expires value when serializing the cookie. You can use `commitSession(session, { expires })` if using a session storage object, or `cookie.serialize("value", { expires })` if you\'re using the cookie directly.' + ); + }); + + it("warns when not passing secrets when creating the session", async () => { + createCookieSessionStorage({ cookie: {} }); + + expect(spy.console).toHaveBeenCalledTimes(1); + expect(spy.console).toHaveBeenCalledWith( + 'The "__session" cookie is not signed, but session cookies should be signed to prevent tampering on the client before they are sent back to the server. See https://remix.run/utils/cookies#signing-cookies for more information.' + ); + }); + }); + + describe("when a new secret shows up in the rotation", () => { + it("unsigns old session cookies using the old secret and encodes new cookies using the new secret", async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + + expect(session.get("user")).toEqual("mjackson"); + + // A new secret enters the rotation... + let storage = createCookieSessionStorage({ + cookie: { secrets: ["secret2", "secret1"] }, + }); + getSession = storage.getSession; + commitSession = storage.commitSession; + + // Old cookies should still work with the old secret. + session = await storage.getSession(getCookieFromSetCookie(setCookie)); + expect(session.get("user")).toEqual("mjackson"); + + // New cookies should be signed using the new secret. + let setCookie2 = await storage.commitSession(session); + expect(setCookie2).not.toEqual(setCookie); + }); + }); +}); + +function spyConsole() { + // https://github.com/facebook/react/issues/7047 + let spy: any = {}; + + beforeAll(() => { + spy.console = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + beforeEach(() => { + spy.console.mockClear(); + }); + + afterAll(() => { + spy.console.mockRestore(); + }); + + return spy; +} diff --git a/packages/react-router/__tests__/server-runtime/utils.ts b/packages/react-router/__tests__/server-runtime/utils.ts new file mode 100644 index 0000000000..cac6802bf5 --- /dev/null +++ b/packages/react-router/__tests__/server-runtime/utils.ts @@ -0,0 +1,113 @@ +import prettier from "prettier"; + +import type { + ActionFunction, + HandleErrorFunction, + HeadersFunction, + LoaderFunction, +} from "../../lib/server-runtime"; +import type { FutureConfig } from "../../lib/server-runtime/entry"; +import type { + EntryRoute, + ServerRoute, + ServerRouteManifest, +} from "../../lib/server-runtime/routes"; + +export function mockServerBuild( + routes: Record< + string, + { + parentId?: string; + index?: true; + path?: string; + default?: any; + ErrorBoundary?: any; + action?: ActionFunction; + headers?: HeadersFunction; + loader?: LoaderFunction; + } + >, + opts: { + future?: Partial; + handleError?: HandleErrorFunction; + } = {} +) { + return { + future: { + ...opts.future, + }, + assets: { + entry: { + imports: [""], + module: "", + }, + routes: Object.entries(routes).reduce((p, [id, config]) => { + let route: EntryRoute = { + hasAction: !!config.action, + hasErrorBoundary: !!config.ErrorBoundary, + hasLoader: !!config.loader, + id, + module: "", + index: config.index, + path: config.path, + parentId: config.parentId, + }; + return { + ...p, + [id]: route, + }; + }, {}), + url: "", + version: "", + }, + entry: { + module: { + default: jest.fn( + async ( + request, + responseStatusCode, + responseHeaders, + entryContext, + loadContext + ) => + new Response(null, { + status: responseStatusCode, + headers: responseHeaders, + }) + ), + handleDataRequest: jest.fn(async (response) => response), + handleError: opts.handleError, + }, + }, + routes: Object.entries(routes).reduce( + (p, [id, config]) => { + let route: Omit = { + id, + index: config.index, + path: config.path, + parentId: config.parentId, + module: { + default: config.default, + ErrorBoundary: config.ErrorBoundary, + action: config.action, + headers: config.headers, + loader: config.loader, + }, + }; + return { + ...p, + [id]: route, + }; + }, + {} + ), + }; +} + +export function prettyHtml(source: string): string { + return prettier.format(source, { parser: "html" }); +} + +export function isEqual( + arg: A extends B ? (B extends A ? true : false) : false +): void {} diff --git a/packages/react-router/__tests__/setup.ts b/packages/react-router/__tests__/setup.ts index dc6ff99164..8c208adb43 100644 --- a/packages/react-router/__tests__/setup.ts +++ b/packages/react-router/__tests__/setup.ts @@ -1,19 +1,35 @@ -import { fetch, Request, Response, Headers } from "@remix-run/web-fetch"; - // https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#configuring-your-testing-environment globalThis.IS_REACT_ACT_ENVIRONMENT = true; +if (!globalThis.TextEncoder || !globalThis.TextDecoder) { + const { TextDecoder, TextEncoder } = require("node:util"); + globalThis.TextEncoder = TextEncoder; + globalThis.TextDecoder = TextDecoder; +} + +if (!globalThis.ReadableStream || !globalThis.WritableStream) { + const { ReadableStream, WritableStream } = require("node:stream/web"); + globalThis.ReadableStream = ReadableStream; + globalThis.WritableStream = WritableStream; +} + if (!globalThis.fetch) { - // Built-in lib.dom.d.ts expects `fetch(Request | string, ...)` but the web - // fetch API allows a URL so @remix-run/web-fetch defines - // `fetch(string | URL | Request, ...)` - // @ts-expect-error + const { fetch, FormData, Request, Response, Headers } = require("undici"); + globalThis.fetch = fetch; - // Same as above, lib.dom.d.ts doesn't allow a URL to the Request constructor - // @ts-expect-error globalThis.Request = Request; - // web-std/fetch Response does not currently implement Response.error() - // @ts-expect-error globalThis.Response = Response; globalThis.Headers = Headers; + + globalThis.FormData = globalThis.FormData || FormData; +} + +if (!globalThis.TextEncoderStream) { + const { TextEncoderStream } = require("node:stream/web"); + globalThis.TextEncoderStream = TextEncoderStream; +} + +if (!globalThis.TransformStream) { + const { TransformStream } = require("node:stream/web"); + globalThis.TransformStream = TransformStream; } diff --git a/packages/react-router/__tests__/useNavigate-test.tsx b/packages/react-router/__tests__/useNavigate-test.tsx index 95499722d5..66823db747 100644 --- a/packages/react-router/__tests__/useNavigate-test.tsx +++ b/packages/react-router/__tests__/useNavigate-test.tsx @@ -14,7 +14,7 @@ import { } from "react-router"; describe("useNavigate", () => { - it("navigates to the new location", () => { + it("navigates to the new location", async () => { function Home() { let navigate = useNavigate(); @@ -44,7 +44,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -54,7 +54,7 @@ describe("useNavigate", () => { `); }); - it("navigates to the new location when no pathname is provided", () => { + it("navigates to the new location when no pathname is provided", async () => { function Home() { let location = useLocation(); let navigate = useNavigate(); @@ -94,7 +94,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -111,7 +111,7 @@ describe("useNavigate", () => { `); }); - it("navigates to the new location when no pathname is provided (with a basename)", () => { + it("navigates to the new location when no pathname is provided (with a basename)", async () => { function Home() { let location = useLocation(); let navigate = useNavigate(); @@ -151,7 +151,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -168,7 +168,7 @@ describe("useNavigate", () => { `); }); - it("navigates to the new location with empty query string when no query string is provided", () => { + it("navigates to the new location with empty query string when no query string is provided", async () => { function Home() { let location = useLocation(); let navigate = useNavigate(); @@ -208,7 +208,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -301,12 +301,11 @@ describe("useNavigate", () => { ); }); - it("allows useNavigate usage in a mixed RouterProvider/ scenario", () => { + it("allows useNavigate usage in a mixed RouterProvider/ scenario", async () => { const router = createMemoryRouter([ { path: "/*", Component() { - // eslint-disable-next-line @typescript-eslint/no-unused-vars let navigate = useNavigate(); let location = useLocation(); return ( @@ -379,7 +378,7 @@ describe("useNavigate", () => { let button = renderer.root.findByProps({ children: "Navigate from RouterProvider", }); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); expect(router.state.location.pathname).toBe("/page"); expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -403,7 +402,7 @@ describe("useNavigate", () => { button = renderer.root.findByProps({ children: "Navigate from RouterProvider", }); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); expect(router.state.location.pathname).toBe("/"); expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -427,7 +426,7 @@ describe("useNavigate", () => { button = renderer.root.findByProps({ children: "Navigate /page from Routes", }); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); expect(router.state.location.pathname).toBe("/page"); expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -451,7 +450,7 @@ describe("useNavigate", () => { button = renderer.root.findByProps({ children: "Navigate /home from Routes", }); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); expect(router.state.location.pathname).toBe("/"); expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -530,7 +529,9 @@ describe("useNavigate", () => { function Home() { let navigate = useNavigate(); - React.useEffect(() => navigate("/about"), [navigate]); + React.useEffect(() => { + navigate("/about"); + }, [navigate]); return

    Home

    ; } @@ -566,7 +567,9 @@ describe("useNavigate", () => { } function Child({ onChildRendered }) { - React.useEffect(() => onChildRendered()); + React.useEffect(() => { + onChildRendered(); + }); return null; } @@ -617,7 +620,9 @@ describe("useNavigate", () => { index: true, Component() { let navigate = useNavigate(); - React.useEffect(() => navigate("/about"), [navigate]); + React.useEffect(() => { + navigate("/about"); + }, [navigate]); return

    Home

    ; }, }, @@ -664,7 +669,9 @@ describe("useNavigate", () => { }); function Child({ onChildRendered }) { - React.useEffect(() => onChildRendered()); + React.useEffect(() => { + onChildRendered(); + }); return null; } @@ -679,7 +686,7 @@ describe("useNavigate", () => { }); describe("with state", () => { - it("adds the state to location.state", () => { + it("adds the state to location.state", async () => { function Home() { let navigate = useNavigate(); @@ -713,7 +720,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -727,7 +734,7 @@ describe("useNavigate", () => { describe("when relative navigation is handled via React Context", () => { describe("with an absolute href", () => { - it("navigates to the correct URL", () => { + it("navigates to the correct URL", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -745,7 +752,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -757,7 +764,7 @@ describe("useNavigate", () => { }); describe("with a relative href (relative=route)", () => { - it("navigates to the correct URL", () => { + it("navigates to the correct URL", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -775,7 +782,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -785,7 +792,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from an index routes", () => { + it("handles upward navigation from an index routes", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -802,7 +809,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -812,7 +819,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside a pathless layout route", () => { + it("handles upward navigation from inside a pathless layout route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -832,7 +839,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -842,7 +849,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + it("handles upward navigation from inside multiple pathless layout routes + index route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -868,7 +875,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -878,7 +885,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + it("handles upward navigation from inside multiple pathless layout routes + path route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -904,7 +911,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -914,7 +921,7 @@ describe("useNavigate", () => { `); }); - it("handles parent navigation from inside multiple pathless layout routes", () => { + it("handles parent navigation from inside multiple pathless layout routes", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -953,7 +960,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -963,7 +970,7 @@ describe("useNavigate", () => { `); }); - it("handles relative navigation from nested index route", () => { + it("handles relative navigation from nested index route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -983,7 +990,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -995,7 +1002,7 @@ describe("useNavigate", () => { }); describe("with a relative href (relative=path)", () => { - it("navigates to the correct URL", () => { + it("navigates to the correct URL", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1013,7 +1020,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1023,7 +1030,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from an index routes", () => { + it("handles upward navigation from an index routes", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1043,7 +1050,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1053,7 +1060,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside a pathless layout route", () => { + it("handles upward navigation from inside a pathless layout route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1073,7 +1080,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1083,7 +1090,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + it("handles upward navigation from inside multiple pathless layout routes + index route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1111,7 +1118,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1121,7 +1128,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + it("handles upward navigation from inside multiple pathless layout routes + path route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1149,7 +1156,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1159,7 +1166,7 @@ describe("useNavigate", () => { `); }); - it("handles relative navigation from nested index route", () => { + it("handles relative navigation from nested index route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1182,7 +1189,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1192,7 +1199,7 @@ describe("useNavigate", () => { `); }); - it("preserves search params and hash", () => { + it("preserves search params and hash", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -1225,7 +1232,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1368,7 +1375,7 @@ describe("useNavigate", () => { describe("when relative navigation is handled via @remix-run/router", () => { describe("with an absolute href", () => { - it("navigates to the correct URL", () => { + it("navigates to the correct URL", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1386,7 +1393,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1398,7 +1405,7 @@ describe("useNavigate", () => { }); describe("with a relative href (relative=route)", () => { - it("navigates to the correct URL", () => { + it("navigates to the correct URL", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1419,7 +1426,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1429,7 +1436,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from an index routes", () => { + it("handles upward navigation from an index routes", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1449,7 +1456,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1459,7 +1466,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside a pathless layout route", () => { + it("handles upward navigation from inside a pathless layout route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1482,7 +1489,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1492,7 +1499,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + it("handles upward navigation from inside multiple pathless layout routes + index route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1521,7 +1528,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1531,7 +1538,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + it("handles upward navigation from inside multiple pathless layout routes + path route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1560,7 +1567,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1570,7 +1577,7 @@ describe("useNavigate", () => { `); }); - it("handles parent navigation from inside multiple pathless layout routes", () => { + it("handles parent navigation from inside multiple pathless layout routes", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1612,7 +1619,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1622,7 +1629,7 @@ describe("useNavigate", () => { `); }); - it("handles relative navigation from nested index route", () => { + it("handles relative navigation from nested index route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1645,7 +1652,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1657,7 +1664,7 @@ describe("useNavigate", () => { }); describe("with a relative href (relative=path)", () => { - it("navigates to the correct URL", () => { + it("navigates to the correct URL", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1678,7 +1685,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1688,7 +1695,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from an index routes", () => { + it("handles upward navigation from an index routes", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1711,7 +1718,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1721,7 +1728,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside a pathless layout route", () => { + it("handles upward navigation from inside a pathless layout route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1744,7 +1751,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1754,7 +1761,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + index route", () => { + it("handles upward navigation from inside multiple pathless layout routes + index route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1783,7 +1790,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1793,7 +1800,7 @@ describe("useNavigate", () => { `); }); - it("handles upward navigation from inside multiple pathless layout routes + path route", () => { + it("handles upward navigation from inside multiple pathless layout routes + path route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1822,7 +1829,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1832,7 +1839,7 @@ describe("useNavigate", () => { `); }); - it("handles relative navigation from nested index route", () => { + it("handles relative navigation from nested index route", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1858,7 +1865,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -1868,7 +1875,7 @@ describe("useNavigate", () => { `); }); - it("preserves search params and hash", () => { + it("preserves search params and hash", async () => { let router = createMemoryRouter( createRoutesFromElements( <> @@ -1904,7 +1911,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -2055,7 +2062,7 @@ describe("useNavigate", () => { describe("with a basename", () => { describe("in a MemoryRouter", () => { - it("in a root route", () => { + it("in a root route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -2082,7 +2089,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -2092,7 +2099,7 @@ describe("useNavigate", () => { `); }); - it("in a descendant route", () => { + it("in a descendant route", async () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { renderer = TestRenderer.create( @@ -2126,7 +2133,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -2138,7 +2145,7 @@ describe("useNavigate", () => { }); describe("in a RouterProvider", () => { - it("in a root route", () => { + it("in a root route", async () => { let router = createMemoryRouter( [ { @@ -2169,7 +2176,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` @@ -2179,7 +2186,7 @@ describe("useNavigate", () => { `); }); - it("in a descendant route", () => { + it("in a descendant route", async () => { let router = createMemoryRouter( [ { @@ -2216,7 +2223,7 @@ describe("useNavigate", () => { // @ts-expect-error let button = renderer.root.findByType("button"); - TestRenderer.act(() => button.props.onClick()); + await TestRenderer.act(() => button.props.onClick()); // @ts-expect-error expect(renderer.toJSON()).toMatchInlineSnapshot(` diff --git a/packages/react-router/__tests__/useResolvedPath-test.tsx b/packages/react-router/__tests__/useResolvedPath-test.tsx index 729503a8fa..7f183b4f7a 100644 --- a/packages/react-router/__tests__/useResolvedPath-test.tsx +++ b/packages/react-router/__tests__/useResolvedPath-test.tsx @@ -92,9 +92,6 @@ describe("useResolvedPath", () => { }); describe("in a splat route", () => { - // Note: This test asserts long-standing buggy behavior fixed by enabling - // the future.v7_relativeSplatPath flag. See: - // https://github.com/remix-run/react-router/issues/11052#issuecomment-1836589329 it("resolves . to the route path", () => { let renderer: TestRenderer.ReactTestRenderer; TestRenderer.act(() => { @@ -111,7 +108,7 @@ describe("useResolvedPath", () => { expect(renderer.toJSON()).toMatchInlineSnapshot(`
    -          {"pathname":"/users","search":"","hash":""}
    +          {"pathname":"/users/mj","search":"","hash":""}
             
    `); }); @@ -123,7 +120,7 @@ describe("useResolvedPath", () => { - } /> + } /> @@ -234,15 +231,33 @@ describe("useResolvedPath", () => { }); }); + function LogResolvedPathInfo({ desc }) { + return ( + <> + {`--- Routes: ${desc} ---`} + {`useLocation(): ${useLocation().pathname}`} + {`useResolvedPath('.'): ${useResolvedPath(".").pathname}`} + {`useResolvedPath('..'): ${useResolvedPath("..").pathname}`} + {`useResolvedPath('..', { relative: 'path' }): ${ + useResolvedPath("..", { relative: "path" }).pathname + }`} + {`useResolvedPath('baz/qux'): ${useResolvedPath("baz/qux").pathname}`} + {`useResolvedPath('./baz/qux'): ${ + useResolvedPath("./baz/qux").pathname + }\n`} + + ); + } + // See: https://github.com/remix-run/react-router/issues/11052#issuecomment-1836589329 - describe("future.v7_relativeSplatPath", () => { + it("resolves splat route relative paths the same as other routes", async () => { function App({ enableFlag }: { enableFlag: boolean }) { let routeConfigs = [ { routes: ( } + element={} /> ), }, @@ -250,7 +265,9 @@ describe("useResolvedPath", () => { routes: ( } + element={ + + } /> ), }, @@ -260,7 +277,7 @@ describe("useResolvedPath", () => { + } /> @@ -270,7 +287,7 @@ describe("useResolvedPath", () => { routes: ( } + element={} /> ), }, @@ -280,7 +297,7 @@ describe("useResolvedPath", () => { + } /> @@ -291,11 +308,7 @@ describe("useResolvedPath", () => { return ( <> {routeConfigs.map((config, idx) => ( - + {config.routes} ))} @@ -303,160 +316,87 @@ describe("useResolvedPath", () => { ); } - function Component({ desc }) { - return ( - <> - {`--- Routes: ${desc} ---`} - {`useLocation(): ${useLocation().pathname}`} - {`useResolvedPath('.'): ${useResolvedPath(".").pathname}`} - {`useResolvedPath('..'): ${useResolvedPath("..").pathname}`} - {`useResolvedPath('..', { relative: 'path' }): ${ - useResolvedPath("..", { relative: "path" }).pathname - }`} - {`useResolvedPath('baz/qux'): ${useResolvedPath("baz/qux").pathname}`} - {`useResolvedPath('./baz/qux'): ${ - useResolvedPath("./baz/qux").pathname - }\n`} - - ); - } - - it("when disabled, resolves splat route relative paths differently than other routes", async () => { - let { container } = render(); - let html = getHtml(container); - html = html ? html.replace(/</g, "<").replace(/>/g, ">") : html; - expect(html).toMatchInlineSnapshot(` - "
    - --- Routes: --- - useLocation(): /foo/bar - useResolvedPath('.'): /foo/bar - useResolvedPath('..'): / - useResolvedPath('..', { relative: 'path' }): /foo - useResolvedPath('baz/qux'): /foo/bar/baz/qux - useResolvedPath('./baz/qux'): /foo/bar/baz/qux - - --- Routes: --- - useLocation(): /foo/bar - useResolvedPath('.'): /foo/bar - useResolvedPath('..'): / - useResolvedPath('..', { relative: 'path' }): /foo - useResolvedPath('baz/qux'): /foo/bar/baz/qux - useResolvedPath('./baz/qux'): /foo/bar/baz/qux - - --- Routes: --- - useLocation(): /foo/bar - useResolvedPath('.'): /foo/bar - useResolvedPath('..'): /foo - useResolvedPath('..', { relative: 'path' }): /foo - useResolvedPath('baz/qux'): /foo/bar/baz/qux - useResolvedPath('./baz/qux'): /foo/bar/baz/qux - - --- Routes: --- - useLocation(): /foo/bar - useResolvedPath('.'): /foo - useResolvedPath('..'): / - useResolvedPath('..', { relative: 'path' }): / - useResolvedPath('baz/qux'): /foo/baz/qux - useResolvedPath('./baz/qux'): /foo/baz/qux - - --- Routes: --- - useLocation(): /foo/bar - useResolvedPath('.'): /foo - useResolvedPath('..'): /foo - useResolvedPath('..', { relative: 'path' }): / - useResolvedPath('baz/qux'): /foo/baz/qux - useResolvedPath('./baz/qux'): /foo/baz/qux - -
    " - `); - }); - - it("when enabled, resolves splat route relative paths differently than other routes", async () => { - let { container } = render(); - let html = getHtml(container); - html = html ? html.replace(/</g, "<").replace(/>/g, ">") : html; - expect(html).toMatchInlineSnapshot(` - "
    - --- Routes: --- - useLocation(): /foo/bar - useResolvedPath('.'): /foo/bar - useResolvedPath('..'): / - useResolvedPath('..', { relative: 'path' }): /foo - useResolvedPath('baz/qux'): /foo/bar/baz/qux - useResolvedPath('./baz/qux'): /foo/bar/baz/qux - - --- Routes: --- - useLocation(): /foo/bar - useResolvedPath('.'): /foo/bar - useResolvedPath('..'): / - useResolvedPath('..', { relative: 'path' }): /foo - useResolvedPath('baz/qux'): /foo/bar/baz/qux - useResolvedPath('./baz/qux'): /foo/bar/baz/qux - - --- Routes: --- - useLocation(): /foo/bar - useResolvedPath('.'): /foo/bar - useResolvedPath('..'): /foo - useResolvedPath('..', { relative: 'path' }): /foo - useResolvedPath('baz/qux'): /foo/bar/baz/qux - useResolvedPath('./baz/qux'): /foo/bar/baz/qux - - --- Routes: --- - useLocation(): /foo/bar - useResolvedPath('.'): /foo/bar - useResolvedPath('..'): / - useResolvedPath('..', { relative: 'path' }): /foo - useResolvedPath('baz/qux'): /foo/bar/baz/qux - useResolvedPath('./baz/qux'): /foo/bar/baz/qux - - --- Routes: --- - useLocation(): /foo/bar - useResolvedPath('.'): /foo/bar - useResolvedPath('..'): /foo - useResolvedPath('..', { relative: 'path' }): /foo - useResolvedPath('baz/qux'): /foo/bar/baz/qux - useResolvedPath('./baz/qux'): /foo/bar/baz/qux - -
    " - `); - }); + let { container } = render(); + let html = getHtml(container); + html = html ? html.replace(/</g, "<").replace(/>/g, ">") : html; + expect(html).toMatchInlineSnapshot(` + "
    + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): / + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): / + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): /foo + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): / + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): /foo + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + +
    " + `); + }); - // gh-issue #11629 - it("when enabled, '.' resolves to the current path including any splat paths nested in pathless routes", () => { - let { container } = render( - - - - - - } - /> - + // gh-issue #11629 + it("'.' resolves to the current path including any splat paths nested in pathless routes", () => { + let { container } = render( + + + + + + } + /> - - - ); - let html = getHtml(container); - html = html ? html.replace(/</g, "<").replace(/>/g, ">") : html; - expect(html).toMatchInlineSnapshot(` - "
    - --- Routes: --- - useLocation(): /foo/bar - useResolvedPath('.'): /foo/bar - useResolvedPath('..'): /foo - useResolvedPath('..', { relative: 'path' }): /foo - useResolvedPath('baz/qux'): /foo/bar/baz/qux - useResolvedPath('./baz/qux'): /foo/bar/baz/qux - -
    " - `); - }); +
    +
    +
    + ); + let html = getHtml(container); + html = html ? html.replace(/</g, "<").replace(/>/g, ">") : html; + expect(html).toMatchInlineSnapshot(` + "
    + --- Routes: --- + useLocation(): /foo/bar + useResolvedPath('.'): /foo/bar + useResolvedPath('..'): /foo + useResolvedPath('..', { relative: 'path' }): /foo + useResolvedPath('baz/qux'): /foo/bar/baz/qux + useResolvedPath('./baz/qux'): /foo/bar/baz/qux + +
    " + `); }); }); diff --git a/packages/react-router/__tests__/utils/MemoryNavigate.tsx b/packages/react-router/__tests__/utils/MemoryNavigate.tsx index 9f418cd619..146e059011 100644 --- a/packages/react-router/__tests__/utils/MemoryNavigate.tsx +++ b/packages/react-router/__tests__/utils/MemoryNavigate.tsx @@ -1,7 +1,7 @@ -import type { FormMethod } from "@remix-run/router"; -import { joinPaths } from "@remix-run/router"; +import type { HTMLFormMethod } from "../../lib/router/utils"; +import { joinPaths } from "../../lib/router/utils"; import * as React from "react"; -import { UNSAFE_DataRouterContext } from "react-router"; +import { UNSAFE_DataRouterContext } from "../../index"; export default function MemoryNavigate({ to, @@ -10,7 +10,7 @@ export default function MemoryNavigate({ children, }: { to: string; - formMethod?: FormMethod; + formMethod?: HTMLFormMethod; formData?: FormData; children: React.ReactNode; }) { diff --git a/packages/react-router/dom-export.ts b/packages/react-router/dom-export.ts new file mode 100644 index 0000000000..4c0d218ed4 --- /dev/null +++ b/packages/react-router/dom-export.ts @@ -0,0 +1,3 @@ +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.ts b/packages/react-router/index.ts index 56a7cadc19..4375b7765f 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -1,215 +1,115 @@ -import * as React from "react"; -import type { - ActionFunction, - ActionFunctionArgs, +// Expose old @remix-run/router API y2 +export type { InitialEntry, Location, Path, To } from "./lib/router/history"; +export type { + HydrationState, + StaticHandler, + GetScrollPositionFunction, + GetScrollRestorationKeyFunction, + StaticHandlerContext, + Fetcher, + Navigation, + NavigationStates, + RelativeRoutingType, Blocker, BlockerFunction, - unstable_DataStrategyFunction, - unstable_DataStrategyFunctionArgs, - unstable_DataStrategyMatch, - unstable_DataStrategyResult, + Router as DataRouter, + RouterState, + RouterInit, + RouterSubscriber, + RouterNavigateOptions, + RouterFetchOptions, + RevalidationState, +} from "./lib/router/router"; +export type { + ActionFunction, + ActionFunctionArgs, + DataStrategyFunction, + DataStrategyFunctionArgs, + DataStrategyMatch, + DataStrategyResult, + DataWithResponseInit as UNSAFE_DataWithResponseInit, ErrorResponse, - Fetcher, - HydrationState, - InitialEntry, - JsonFunction, + FormEncType, + FormMethod, + HTMLFormMethod, LazyRouteFunction, LoaderFunction, LoaderFunctionArgs, - Location, - Navigation, ParamParseKey, Params, - Path, PathMatch, PathParam, PathPattern, RedirectFunction, - RelativeRoutingType, - Router as RemixRouter, - FutureConfig as RouterFutureConfig, ShouldRevalidateFunction, ShouldRevalidateFunctionArgs, - To, UIMatch, - unstable_AgnosticPatchRoutesOnNavigationFunction, -} from "@remix-run/router"; -import { - AbortedDeferredError, +} from "./lib/router/utils"; + +export { Action as NavigationType, - createMemoryHistory, createPath, - createRouter, - defer, + parsePath, +} from "./lib/router/history"; +export { + IDLE_NAVIGATION, + IDLE_FETCHER, + IDLE_BLOCKER, +} from "./lib/router/router"; +export { + data, generatePath, isRouteErrorResponse, - json, matchPath, matchRoutes, - parsePath, redirect, redirectDocument, replace, resolvePath, - UNSAFE_warning as warning, -} from "@remix-run/router"; +} from "./lib/router/utils"; -import type { - AwaitProps, - FutureConfig, - IndexRouteProps, - LayoutRouteProps, - MemoryRouterProps, - NavigateProps, - OutletProps, - PathRouteProps, - RouteProps, - RouterProps, - RouterProviderProps, - RoutesProps, -} from "./lib/components"; -import { - Await, - MemoryRouter, - Navigate, - Outlet, - Route, - Router, - RouterProvider, - Routes, - createRoutesFromChildren, - renderMatches, -} from "./lib/components"; -import type { +// Expose react-router public API +export type { DataRouteMatch, DataRouteObject, IndexRouteObject, NavigateOptions, Navigator, NonIndexRouteObject, + PatchRoutesOnNavigationFunction, + PatchRoutesOnNavigationFunctionArgs, RouteMatch, RouteObject, } from "./lib/context"; -import { - DataRouterContext, - DataRouterStateContext, - LocationContext, - NavigationContext, - RouteContext, -} from "./lib/context"; -import type { NavigateFunction } from "./lib/hooks"; -import { - useActionData, - useAsyncError, - useAsyncValue, - useBlocker, - useHref, - useInRouterContext, - useLoaderData, - useLocation, - useMatch, - useMatches, - useNavigate, - useNavigation, - useNavigationType, - useOutlet, - useOutletContext, - useParams, - useResolvedPath, - useRevalidator, - useRouteError, - useRouteId, - useRouteLoaderData, - useRoutes, - useRoutesImpl, -} from "./lib/hooks"; - -// Exported for backwards compatibility, but not being used internally anymore -type Hash = string; -type Pathname = string; -type Search = string; - -// Expose react-router public API export type { - ActionFunction, - ActionFunctionArgs, AwaitProps, - DataRouteMatch, - DataRouteObject, - unstable_DataStrategyFunction, - unstable_DataStrategyFunctionArgs, - unstable_DataStrategyMatch, - unstable_DataStrategyResult, - ErrorResponse, - Fetcher, - FutureConfig, - Hash, - IndexRouteObject, IndexRouteProps, - JsonFunction, LayoutRouteProps, - LazyRouteFunction, - LoaderFunction, - LoaderFunctionArgs, - Location, MemoryRouterProps, - NavigateFunction, - NavigateOptions, NavigateProps, - Navigation, - Navigator, - NonIndexRouteObject, OutletProps, - ParamParseKey, - Params, - Path, - PathMatch, - PathParam, - PathPattern, PathRouteProps, - Pathname, - RedirectFunction, - RelativeRoutingType, - RouteMatch, - RouteObject, RouteProps, RouterProps, RouterProviderProps, RoutesProps, - Search, - ShouldRevalidateFunction, - ShouldRevalidateFunctionArgs, - To, - UIMatch, - Blocker, - BlockerFunction, -}; +} from "./lib/components"; +export type { NavigateFunction } from "./lib/hooks"; export { - AbortedDeferredError, Await, MemoryRouter, Navigate, - NavigationType, Outlet, Route, Router, RouterProvider, Routes, - createPath, + createMemoryRouter, createRoutesFromChildren, createRoutesFromChildren as createRoutesFromElements, - defer, - generatePath, - isRouteErrorResponse, - json, - matchPath, - matchRoutes, - parsePath, - redirect, - redirectDocument, - replace, renderMatches, - resolvePath, +} from "./lib/components"; +export { useBlocker, useActionData, useAsyncError, @@ -231,98 +131,140 @@ export { useRouteError, useRouteLoaderData, useRoutes, -}; +} from "./lib/hooks"; + +// Expose old RR DOM API +export type { + BrowserRouterProps, + HashRouterProps, + HistoryRouterProps, + LinkProps, + NavLinkProps, + NavLinkRenderProps, + FetcherFormProps, + FormProps, + ScrollRestorationProps, + SetURLSearchParams, + SubmitFunction, + FetcherSubmitFunction, + FetcherWithComponents, +} from "./lib/dom/lib"; +export { + createBrowserRouter, + createHashRouter, + BrowserRouter, + HashRouter, + Link, + HistoryRouter as unstable_HistoryRouter, + NavLink, + Form, + ScrollRestoration, + useLinkClickHandler, + useSearchParams, + useSubmit, + useFormAction, + useFetcher, + useFetchers, + useBeforeUnload, + usePrompt as unstable_usePrompt, + useViewTransitionState, +} from "./lib/dom/lib"; +export type { + FetcherSubmitOptions, + ParamKeyValuePair, + SubmitOptions, + URLSearchParamsInit, + SubmitTarget, +} from "./lib/dom/dom"; +export { createSearchParams } from "./lib/dom/dom"; +export type { + StaticRouterProps, + StaticRouterProviderProps, +} from "./lib/dom/server"; +export { + createStaticHandler, + createStaticRouter, + StaticRouter, + StaticRouterProvider, +} from "./lib/dom/server"; +export { + Meta, + Links, + Scripts, + PrefetchPageLinks, +} from "./lib/dom/ssr/components"; +export type { ScriptsProps } from "./lib/dom/ssr/components"; +export type { EntryContext } from "./lib/dom/ssr/entry"; +export type { + ClientActionFunction, + ClientActionFunctionArgs, + ClientLoaderFunction, + ClientLoaderFunctionArgs, + MetaArgs, + MetaDescriptor, + MetaFunction, + LinksFunction, +} from "./lib/dom/ssr/routeModules"; +export type { ServerRouterProps } from "./lib/dom/ssr/server"; +export { ServerRouter } from "./lib/dom/ssr/server"; +export type { RoutesTestStubProps } from "./lib/dom/ssr/routes-test-stub"; +export { createRoutesStub } from "./lib/dom/ssr/routes-test-stub"; + +// Expose old @remix-run/server-runtime API, minus duplicate APIs +export { createCookie, isCookie } from "./lib/server-runtime/cookies"; + +export { createRequestHandler } from "./lib/server-runtime/server"; +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 { setDevServerHooks as unstable_setDevServerHooks } from "./lib/server-runtime/dev"; -function mapRouteProperties(route: RouteObject) { - let updates: Partial & { hasErrorBoundary: boolean } = { - // Note: this check also occurs in createRoutesFromChildren so update - // there if you change this -- please and thank you! - hasErrorBoundary: route.ErrorBoundary != null || route.errorElement != null, - }; +export type { IsCookieFunction } from "./lib/server-runtime/cookies"; +export type { CreateRequestHandlerFunction } from "./lib/server-runtime/server"; +export type { IsSessionFunction } from "./lib/server-runtime/sessions"; - if (route.Component) { - if (__DEV__) { - if (route.element) { - warning( - false, - "You should not include both `Component` and `element` on your route - " + - "`Component` will be used." - ); - } - } - Object.assign(updates, { - element: React.createElement(route.Component), - Component: undefined, - }); - } +export type { + HandleDataRequestFunction, + HandleDocumentRequestFunction, + HandleErrorFunction, + ServerBuild, + ServerEntryModule, +} from "./lib/server-runtime/build"; + +export type { + Cookie, + CookieOptions, + CookieParseOptions, + CookieSerializeOptions, + CookieSignatureOptions, +} from "./lib/server-runtime/cookies"; - if (route.HydrateFallback) { - if (__DEV__) { - if (route.hydrateFallbackElement) { - warning( - false, - "You should not include both `HydrateFallback` and `hydrateFallbackElement` on your route - " + - "`HydrateFallback` will be used." - ); - } - } - Object.assign(updates, { - hydrateFallbackElement: React.createElement(route.HydrateFallback), - HydrateFallback: undefined, - }); - } +export type { AppLoadContext } from "./lib/server-runtime/data"; - if (route.ErrorBoundary) { - if (__DEV__) { - if (route.errorElement) { - warning( - false, - "You should not include both `ErrorBoundary` and `errorElement` on your route - " + - "`ErrorBoundary` will be used." - ); - } - } - Object.assign(updates, { - errorElement: React.createElement(route.ErrorBoundary), - ErrorBoundary: undefined, - }); - } +export type { + PageLinkDescriptor, + HtmlLinkDescriptor, + LinkDescriptor, +} from "./lib/router/links"; - return updates; -} +export type { + HeadersArgs, + HeadersFunction, +} from "./lib/server-runtime/routeModules"; -export interface unstable_PatchRoutesOnNavigationFunction - extends unstable_AgnosticPatchRoutesOnNavigationFunction {} +export type { RequestHandler } from "./lib/server-runtime/server"; -export function createMemoryRouter( - routes: RouteObject[], - opts?: { - basename?: string; - future?: Partial>; - hydrationData?: HydrationState; - initialEntries?: InitialEntry[]; - initialIndex?: number; - unstable_dataStrategy?: unstable_DataStrategyFunction; - unstable_patchRoutesOnNavigation?: unstable_PatchRoutesOnNavigationFunction; - } -): RemixRouter { - return createRouter({ - basename: opts?.basename, - future: { - ...opts?.future, - v7_prependBasename: true, - }, - history: createMemoryHistory({ - initialEntries: opts?.initialEntries, - initialIndex: opts?.initialIndex, - }), - hydrationData: opts?.hydrationData, - routes, - mapRouteProperties, - unstable_dataStrategy: opts?.unstable_dataStrategy, - unstable_patchRoutesOnNavigation: opts?.unstable_patchRoutesOnNavigation, - }).initialize(); -} +export type { + Session, + SessionData, + SessionIdStorageStrategy, + SessionStorage, + FlashSessionData, +} from "./lib/server-runtime/sessions"; /////////////////////////////////////////////////////////////////////////////// // DANGER! PLEASE READ ME! @@ -337,14 +279,71 @@ export function createMemoryRouter( // extreme caution and only if you understand the consequences. Godspeed. /////////////////////////////////////////////////////////////////////////////// +/** @internal */ +export { + createBrowserHistory as UNSAFE_createBrowserHistory, + invariant as UNSAFE_invariant, +} from "./lib/router/history"; + +/** @internal */ +export { createRouter as UNSAFE_createRouter } from "./lib/router/router"; + +/** @internal */ +export { ErrorResponseImpl as UNSAFE_ErrorResponseImpl } from "./lib/router/utils"; + /** @internal */ export { DataRouterContext as UNSAFE_DataRouterContext, DataRouterStateContext as UNSAFE_DataRouterStateContext, + FetchersContext as UNSAFE_FetchersContext, LocationContext as UNSAFE_LocationContext, NavigationContext as UNSAFE_NavigationContext, RouteContext as UNSAFE_RouteContext, - mapRouteProperties as UNSAFE_mapRouteProperties, - useRouteId as UNSAFE_useRouteId, - useRoutesImpl as UNSAFE_useRoutesImpl, -}; + ViewTransitionContext as UNSAFE_ViewTransitionContext, +} from "./lib/context"; + +/** @internal */ +export { mapRouteProperties as UNSAFE_mapRouteProperties } from "./lib/components"; + +/** @internal */ +export { FrameworkContext as UNSAFE_FrameworkContext } from "./lib/dom/ssr/components"; + +/** @internal */ +export type { AssetsManifest as UNSAFE_AssetsManifest } from "./lib/dom/ssr/entry"; + +/** @internal */ +export { deserializeErrors as UNSAFE_deserializeErrors } from "./lib/dom/ssr/errors"; + +/** @internal */ +export { RemixErrorBoundary as UNSAFE_RemixErrorBoundary } from "./lib/dom/ssr/errorBoundaries"; + +/** @internal */ +export { + getPatchRoutesOnNavigationFunction as UNSAFE_getPatchRoutesOnNavigationFunction, + useFogOFWarDiscovery as UNSAFE_useFogOFWarDiscovery, +} from "./lib/dom/ssr/fog-of-war"; + +/** @internal */ +export type { RouteModules as UNSAFE_RouteModules } from "./lib/dom/ssr/routeModules"; + +/** @internal */ +export { + createClientRoutes as UNSAFE_createClientRoutes, + createClientRoutesWithHMRRevalidationOptOut as UNSAFE_createClientRoutesWithHMRRevalidationOptOut, + shouldHydrateRouteLoader as UNSAFE_shouldHydrateRouteLoader, +} from "./lib/dom/ssr/routes"; + +/** @internal */ +export { getSingleFetchDataStrategy as UNSAFE_getSingleFetchDataStrategy } from "./lib/dom/ssr/single-fetch"; + +/** @internal */ +export { + decodeViaTurboStream as UNSAFE_decodeViaTurboStream, + SingleFetchRedirectSymbol as UNSAFE_SingleFetchRedirectSymbol, +} from "./lib/dom/ssr/single-fetch"; + +/** @internal */ +export { ServerMode as UNSAFE_ServerMode } from "./lib/server-runtime/mode"; + +/** @internal */ +export { useScrollRestoration as UNSAFE_useScrollRestoration } from "./lib/dom/lib"; diff --git a/packages/react-router/jest-transformer.js b/packages/react-router/jest-transformer.js deleted file mode 100644 index 7658127ad4..0000000000 --- a/packages/react-router/jest-transformer.js +++ /dev/null @@ -1,10 +0,0 @@ -const babelJest = require("babel-jest"); - -module.exports = babelJest.createTransformer({ - presets: [ - ["@babel/preset-env", { loose: true }], - "@babel/preset-react", - "@babel/preset-typescript", - ], - plugins: ["babel-plugin-dev-expression"], -}); diff --git a/packages/react-router/jest.config.js b/packages/react-router/jest.config.js index b0d9306936..dc45566ce5 100644 --- a/packages/react-router/jest.config.js +++ b/packages/react-router/jest.config.js @@ -1,22 +1,7 @@ +/** @type {import('jest').Config} */ module.exports = { + ...require("../../jest/jest.config.shared"), + setupFiles: ["/__tests__/setup.ts"], + setupFilesAfterEnv: ["@testing-library/jest-dom"], testEnvironment: "jsdom", - testMatch: ["**/__tests__/*-test.[jt]s?(x)"], - transform: { - "\\.[jt]sx?$": "./jest-transformer.js", - }, - globals: { - __DEV__: true, - }, - setupFiles: ["./__tests__/setup.ts"], - moduleNameMapper: { - "^@remix-run/router$": "/../router/index.ts", - "^@remix-run/web-blob$": require.resolve("@remix-run/web-blob"), - "^@remix-run/web-fetch$": require.resolve("@remix-run/web-fetch"), - "^@remix-run/web-form-data$": require.resolve("@remix-run/web-form-data"), - "^@remix-run/web-stream$": require.resolve("@remix-run/web-stream"), - "^@web3-storage/multipart-parser$": require.resolve( - "@web3-storage/multipart-parser" - ), - "^react-router$": "/index.ts", - }, }; diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 958cb5a773..d9a31f1533 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -1,43 +1,53 @@ +import * as React from "react"; + import type { InitialEntry, - LazyRouteFunction, Location, MemoryHistory, - RelativeRoutingType, - Router as RemixRouter, - RouterState, - RouterSubscriber, To, - TrackedPromise, -} from "@remix-run/router"; +} from "./router/history"; import { - AbortedDeferredError, Action as NavigationType, createMemoryHistory, - UNSAFE_getResolveToMatches as getResolveToMatches, - UNSAFE_invariant as invariant, + invariant, parsePath, - resolveTo, - stripBasename, - UNSAFE_warning as warning, -} from "@remix-run/router"; -import * as React from "react"; + warning, +} from "./router/history"; +import type { + FutureConfig, + HydrationState, + RelativeRoutingType, + Router as DataRouter, + RouterState, + RouterSubscriber, +} from "./router/router"; +import { createRouter } from "./router/router"; +import type { + DataStrategyFunction, + LazyRouteFunction, + TrackedPromise, +} from "./router/utils"; +import { getResolveToMatches, resolveTo, stripBasename } from "./router/utils"; import type { DataRouteObject, IndexRouteObject, Navigator, NonIndexRouteObject, + PatchRoutesOnNavigationFunction, RouteMatch, RouteObject, + ViewTransitionContextObject, } from "./context"; import { AwaitContext, DataRouterContext, DataRouterStateContext, + FetchersContext, LocationContext, NavigationContext, RouteContext, + ViewTransitionContext, } from "./context"; import { _renderMatches, @@ -49,79 +59,324 @@ import { useRoutes, useRoutesImpl, } from "./hooks"; +import type { ViewTransition } from "./dom/global"; +import { warnOnce } from "./server-runtime/warnings"; -export interface FutureConfig { - v7_relativeSplatPath: boolean; - v7_startTransition: boolean; -} +// Provided by the build system +declare const __DEV__: boolean; +const ENABLE_DEV_WARNINGS = __DEV__; -export interface RouterProviderProps { - fallbackElement?: React.ReactNode; - router: RemixRouter; - // Only accept future flags relevant to rendering behavior - // routing flags should be accessed via router.future - future?: Partial>; +/** + * @private + */ +export function mapRouteProperties(route: RouteObject) { + let updates: Partial & { hasErrorBoundary: boolean } = { + // Note: this check also occurs in createRoutesFromChildren so update + // there if you change this -- please and thank you! + hasErrorBoundary: + route.hasErrorBoundary || + route.ErrorBoundary != null || + route.errorElement != null, + }; + + if (route.Component) { + if (ENABLE_DEV_WARNINGS) { + if (route.element) { + warning( + false, + "You should not include both `Component` and `element` on your route - " + + "`Component` will be used." + ); + } + } + Object.assign(updates, { + element: React.createElement(route.Component), + Component: undefined, + }); + } + + if (route.HydrateFallback) { + if (ENABLE_DEV_WARNINGS) { + if (route.hydrateFallbackElement) { + warning( + false, + "You should not include both `HydrateFallback` and `hydrateFallbackElement` on your route - " + + "`HydrateFallback` will be used." + ); + } + } + Object.assign(updates, { + hydrateFallbackElement: React.createElement(route.HydrateFallback), + HydrateFallback: undefined, + }); + } + + if (route.ErrorBoundary) { + if (ENABLE_DEV_WARNINGS) { + if (route.errorElement) { + warning( + false, + "You should not include both `ErrorBoundary` and `errorElement` on your route - " + + "`ErrorBoundary` will be used." + ); + } + } + Object.assign(updates, { + errorElement: React.createElement(route.ErrorBoundary), + ErrorBoundary: undefined, + }); + } + + return updates; } /** - Webpack + React 17 fails to compile on any of the following because webpack - complains that `startTransition` doesn't exist in `React`: - * import { startTransition } from "react" - * import * as React from from "react"; - "startTransition" in React ? React.startTransition(() => setState()) : setState() - * import * as React from from "react"; - "startTransition" in React ? React["startTransition"](() => setState()) : setState() - - Moving it to a constant such as the following solves the Webpack/React 17 issue: - * import * as React from from "react"; - const START_TRANSITION = "startTransition"; - START_TRANSITION in React ? React[START_TRANSITION](() => setState()) : setState() - - However, that introduces webpack/terser minification issues in production builds - in React 18 where minification/obfuscation ends up removing the call of - React.startTransition entirely from the first half of the ternary. Grabbing - this exported reference once up front resolves that issue. - - See https://github.com/remix-run/react-router/issues/10579 -*/ -const START_TRANSITION = "startTransition"; -const startTransitionImpl = React[START_TRANSITION]; + * @category Routers + */ +export function createMemoryRouter( + routes: RouteObject[], + opts?: { + basename?: string; + future?: Partial; + hydrationData?: HydrationState; + initialEntries?: InitialEntry[]; + initialIndex?: number; + dataStrategy?: DataStrategyFunction; + patchRoutesOnNavigation?: PatchRoutesOnNavigationFunction; + } +): DataRouter { + return createRouter({ + basename: opts?.basename, + future: opts?.future, + history: createMemoryHistory({ + initialEntries: opts?.initialEntries, + initialIndex: opts?.initialIndex, + }), + hydrationData: opts?.hydrationData, + routes, + mapRouteProperties, + dataStrategy: opts?.dataStrategy, + patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, + }).initialize(); +} + +class Deferred { + status: "pending" | "resolved" | "rejected" = "pending"; + promise: Promise; + // @ts-expect-error - no initializer + resolve: (value: T) => void; + // @ts-expect-error - no initializer + reject: (reason?: unknown) => void; + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = (value) => { + if (this.status === "pending") { + this.status = "resolved"; + resolve(value); + } + }; + this.reject = (reason) => { + if (this.status === "pending") { + this.status = "rejected"; + reject(reason); + } + }; + }); + } +} + +// Copied from react-dom types +export interface RouterProviderProps { + router: DataRouter; + flushSync?: (fn: () => unknown) => undefined; +} /** * Given a Remix Router instance, render the appropriate UI */ export function RouterProvider({ - fallbackElement, router, - future, + flushSync: reactDomFlushSyncImpl, }: RouterProviderProps): React.ReactElement { let [state, setStateImpl] = React.useState(router.state); - let { v7_startTransition } = future || {}; + let [pendingState, setPendingState] = React.useState(); + let [vtContext, setVtContext] = React.useState({ + isTransitioning: false, + }); + let [renderDfd, setRenderDfd] = React.useState>(); + let [transition, setTransition] = React.useState(); + let [interruption, setInterruption] = React.useState<{ + state: RouterState; + currentLocation: Location; + nextLocation: Location; + }>(); + let fetcherData = React.useRef>(new Map()); let setState = React.useCallback( - (newState: RouterState) => { - if (v7_startTransition && startTransitionImpl) { - startTransitionImpl(() => setStateImpl(newState)); + ( + newState: RouterState, + { deletedFetchers, flushSync, viewTransitionOpts } + ) => { + deletedFetchers.forEach((key) => fetcherData.current.delete(key)); + newState.fetchers.forEach((fetcher, key) => { + if (fetcher.data !== undefined) { + fetcherData.current.set(key, fetcher.data); + } + }); + + warnOnce( + flushSync === false || reactDomFlushSyncImpl != null, + "You provided the `flushSync` option to a router update, " + + "but you are not using the `` from `react-router/dom` " + + "so `ReactDOM.flushSync()` is unavailable. Please update your app " + + 'to `import { RouterProvider } from "react-router/dom"` and ensure ' + + "you have `react-dom` installed as a dependency to use the " + + "`flushSync` option." + ); + + let isViewTransitionAvailable = + router.window != null && + router.window.document != null && + typeof router.window.document.startViewTransition === "function"; + + warnOnce( + viewTransitionOpts == null || isViewTransitionAvailable, + "You provided the `viewTransition` option to a router update, " + + "but you do not appear to be running in a DOM environment as " + + "`window.startViewTransition` is not available." + ); + + // If this isn't a view transition or it's not available in this browser, + // just update and be done with it + if (!viewTransitionOpts || !isViewTransitionAvailable) { + if (reactDomFlushSyncImpl && flushSync) { + reactDomFlushSyncImpl(() => setStateImpl(newState)); + } else { + React.startTransition(() => setStateImpl(newState)); + } + return; + } + + // flushSync + startViewTransition + if (reactDomFlushSyncImpl && flushSync) { + // Flush through the context to mark DOM elements as transition=ing + reactDomFlushSyncImpl(() => { + // Cancel any pending transitions + if (transition) { + renderDfd && renderDfd.resolve(); + transition.skipTransition(); + } + setVtContext({ + isTransitioning: true, + flushSync: true, + currentLocation: viewTransitionOpts.currentLocation, + nextLocation: viewTransitionOpts.nextLocation, + }); + }); + + // Update the DOM + let t = router.window!.document.startViewTransition(() => { + reactDomFlushSyncImpl(() => setStateImpl(newState)); + }); + + // Clean up after the animation completes + t.finished.finally(() => { + reactDomFlushSyncImpl(() => { + setRenderDfd(undefined); + setTransition(undefined); + setPendingState(undefined); + setVtContext({ isTransitioning: false }); + }); + }); + + reactDomFlushSyncImpl(() => setTransition(t)); + return; + } + + // startTransition + startViewTransition + if (transition) { + // Interrupting an in-progress transition, cancel and let everything flush + // out, and then kick off a new transition from the interruption state + renderDfd && renderDfd.resolve(); + transition.skipTransition(); + setInterruption({ + state: newState, + currentLocation: viewTransitionOpts.currentLocation, + nextLocation: viewTransitionOpts.nextLocation, + }); } else { - setStateImpl(newState); + // Completed navigation update with opted-in view transitions, let 'er rip + setPendingState(newState); + setVtContext({ + isTransitioning: true, + flushSync: false, + currentLocation: viewTransitionOpts.currentLocation, + nextLocation: viewTransitionOpts.nextLocation, + }); } }, - [setStateImpl, v7_startTransition] + [router.window, reactDomFlushSyncImpl, transition, renderDfd] ); // Need to use a layout effect here so we are subscribed early enough to // pick up on any render-driven redirects/navigations (useEffect/) React.useLayoutEffect(() => router.subscribe(setState), [router, setState]); + // When we start a view transition, create a Deferred we can use for the + // eventual "completed" render React.useEffect(() => { - warning( - fallbackElement == null || !router.future.v7_partialHydration, - "`` is deprecated when using " + - "`v7_partialHydration`, use a `HydrateFallback` component instead" - ); - // Only log this once on initial mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + if (vtContext.isTransitioning && !vtContext.flushSync) { + setRenderDfd(new Deferred()); + } + }, [vtContext]); + + // Once the deferred is created, kick off startViewTransition() to update the + // DOM and then wait on the Deferred to resolve (indicating the DOM update has + // happened) + React.useEffect(() => { + if (renderDfd && pendingState && router.window) { + let newState = pendingState; + let renderPromise = renderDfd.promise; + let transition = router.window.document.startViewTransition(async () => { + React.startTransition(() => setStateImpl(newState)); + await renderPromise; + }); + transition.finished.finally(() => { + setRenderDfd(undefined); + setTransition(undefined); + setPendingState(undefined); + setVtContext({ isTransitioning: false }); + }); + setTransition(transition); + } + }, [pendingState, renderDfd, router.window]); + + // When the new location finally renders and is committed to the DOM, this + // effect will run to resolve the transition + React.useEffect(() => { + if ( + renderDfd && + pendingState && + state.location.key === pendingState.location.key + ) { + renderDfd.resolve(); + } + }, [renderDfd, transition, state.location, pendingState]); + + // If we get interrupted with a new navigation during a transition, we skip + // the active transition, let it cleanup, then kick it off again here + React.useEffect(() => { + if (!vtContext.isTransitioning && interruption) { + setPendingState(interruption.state); + setVtContext({ + isTransitioning: true, + flushSync: false, + currentLocation: interruption.currentLocation, + nextLocation: interruption.nextLocation, + }); + setInterruption(undefined); + } + }, [vtContext.isTransitioning, interruption]); let navigator = React.useMemo((): Navigator => { return { @@ -164,25 +419,22 @@ export function RouterProvider({ <> - - {state.initialized || router.future.v7_partialHydration ? ( - - ) : ( - fallbackElement - )} - + + + + + + + {null} @@ -190,37 +442,41 @@ export function RouterProvider({ ); } +// Memoize to avoid re-renders when updating `ViewTransitionContext` +const MemoizedDataRoutes = React.memo(DataRoutes); + function DataRoutes({ routes, future, state, }: { routes: DataRouteObject[]; - future: RemixRouter["future"]; + future: DataRouter["future"]; state: RouterState; }): React.ReactElement | null { return useRoutesImpl(routes, undefined, state, future); } +/** + * @category Types + */ export interface MemoryRouterProps { basename?: string; children?: React.ReactNode; initialEntries?: InitialEntry[]; initialIndex?: number; - future?: Partial; } /** * A `` that stores all entries in memory. * - * @see https://reactrouter.com/router-components/memory-router + * @category Router Components */ export function MemoryRouter({ basename, children, initialEntries, initialIndex, - future, }: MemoryRouterProps): React.ReactElement { let historyRef = React.useRef(); if (historyRef.current == null) { @@ -236,14 +492,11 @@ export function MemoryRouter({ action: history.action, location: history.location, }); - let { v7_startTransition } = future || {}; let setState = React.useCallback( (newState: { action: NavigationType; location: Location }) => { - v7_startTransition && startTransitionImpl - ? startTransitionImpl(() => setStateImpl(newState)) - : setStateImpl(newState); + React.startTransition(() => setStateImpl(newState)); }, - [setStateImpl, v7_startTransition] + [setStateImpl] ); React.useLayoutEffect(() => history.listen(setState), [history, setState]); @@ -255,11 +508,13 @@ export function MemoryRouter({ location={state.location} navigationType={state.action} navigator={history} - future={future} /> ); } +/** + * @category Types + */ export interface NavigateProps { to: To; replace?: boolean; @@ -268,13 +523,13 @@ export interface NavigateProps { } /** - * Changes the current location. + * A component-based version of {@link useNavigate} to use in a [`React.Component + * Class`](https://reactjs.org/docs/react-component.html) where hooks are not + * able to be used. * - * Note: This API is mostly useful in React.Component subclasses that are not - * able to use hooks. In functional components, we recommend you use the - * `useNavigate` hook instead. + * It's recommended to avoid using this component in favor of {@link useNavigate} * - * @see https://reactrouter.com/components/navigate + * @category Components */ export function Navigate({ to, @@ -289,7 +544,7 @@ export function Navigate({ ` may be used only in the context of a component.` ); - let { future, static: isStatic } = React.useContext(NavigationContext); + let { static: isStatic } = React.useContext(NavigationContext); warning( !isStatic, @@ -306,33 +561,60 @@ export function Navigate({ // StrictMode they navigate to the same place let path = resolveTo( to, - getResolveToMatches(matches, future.v7_relativeSplatPath), + getResolveToMatches(matches), locationPathname, relative === "path" ); let jsonPath = JSON.stringify(path); - React.useEffect( - () => navigate(JSON.parse(jsonPath), { replace, state, relative }), - [navigate, jsonPath, relative, replace, state] - ); + React.useEffect(() => { + navigate(JSON.parse(jsonPath), { replace, state, relative }); + }, [navigate, jsonPath, relative, replace, state]); return null; } +/** + * @category Types + */ export interface OutletProps { + /** + Provides a context value to the element tree below the outlet. Use when the parent route needs to provide values to child routes. + + ```tsx + + ``` + + Access the context with {@link useOutletContext}. + */ context?: unknown; } /** - * Renders the child route's element, if there is one. - * - * @see https://reactrouter.com/components/outlet + Renders the matching child route of a parent route or nothing if no child route matches. + + ```tsx + import { Outlet } from "react-router" + + export default function SomeParent() { + return ( +
    +

    Parent Content

    + +
    + ); + } + ``` + + @category Components */ export function Outlet(props: OutletProps): React.ReactElement | null { return useOutlet(props.context); } +/** + * @category Types + */ export interface PathRouteProps { caseSensitive?: NonIndexRouteObject["caseSensitive"]; path?: NonIndexRouteObject["path"]; @@ -353,8 +635,14 @@ export interface PathRouteProps { ErrorBoundary?: React.ComponentType | null; } +/** + * @category Types + */ export interface LayoutRouteProps extends PathRouteProps {} +/** + * @category Types + */ export interface IndexRouteProps { caseSensitive?: IndexRouteObject["caseSensitive"]; path?: IndexRouteObject["path"]; @@ -378,9 +666,12 @@ export interface IndexRouteProps { export type RouteProps = PathRouteProps | LayoutRouteProps | IndexRouteProps; /** - * Declares an element that should be rendered at a certain URL path. + * Configures an element to render when a pattern matches the current location. + * It must be rendered within a {@link Routes} element. Note that these routes + * do not participate in data loading, actions, code splitting, or any other + * route module features. * - * @see https://reactrouter.com/components/route + * @category Components */ export function Route(_props: RouteProps): React.ReactElement | null { invariant( @@ -390,6 +681,9 @@ export function Route(_props: RouteProps): React.ReactElement | null { ); } +/** + * @category Types + */ export interface RouterProps { basename?: string; children?: React.ReactNode; @@ -397,7 +691,6 @@ export interface RouterProps { navigationType?: NavigationType; navigator: Navigator; static?: boolean; - future?: Partial>; } /** @@ -407,7 +700,7 @@ export interface RouterProps { * router that is more specific to your environment such as a `` * in web browsers or a `` for server rendering. * - * @see https://reactrouter.com/router-components/router + * @category Components */ export function Router({ basename: basenameProp = "/", @@ -416,7 +709,6 @@ export function Router({ navigationType = NavigationType.Pop, navigator, static: staticProp = false, - future, }: RouterProps): React.ReactElement | null { invariant( !useInRouterContext(), @@ -432,12 +724,9 @@ export function Router({ basename, navigator, static: staticProp, - future: { - v7_relativeSplatPath: false, - ...future, - }, + future: {}, }), - [basename, future, navigator, staticProp] + [basename, navigator, staticProp] ); if (typeof locationProp === "string") { @@ -489,16 +778,37 @@ export function Router({ ); } +/** + * @category Types + */ export interface RoutesProps { + /** + * Nested {@link Route} elements + */ children?: React.ReactNode; + + /** + * The location to match against. Defaults to the current location. + */ location?: Partial | string; } /** - * A container for a nested tree of `` elements that renders the branch - * that best matches the current location. - * - * @see https://reactrouter.com/components/routes + Renders a branch of {@link Route | ``} that best matches the current + location. Note that these routes do not participate in data loading, actions, + code splitting, or any other route module features. + + ```tsx + import { Routes, Route } from "react-router" + + + } /> + } /> + }> + + ``` + + @category Components */ export function Routes({ children, @@ -507,21 +817,161 @@ export function Routes({ return useRoutes(createRoutesFromChildren(children), location); } -export interface AwaitResolveRenderFunction { - (data: Awaited): React.ReactNode; +export interface AwaitResolveRenderFunction { + (data: Awaited): React.ReactNode; } -export interface AwaitProps { - children: React.ReactNode | AwaitResolveRenderFunction; +/** + * @category Types + */ +export interface AwaitProps { + /** + When using a function, the resolved value is provided as the parameter. + + ```tsx [2] + + {(resolvedReviews) => } + + ``` + + When using React elements, {@link useAsyncValue} will provide the + resolved value: + + ```tsx [2] + + + + + function Reviews() { + const resolvedReviews = useAsyncValue() + return
    ...
    + } + ``` + */ + children: React.ReactNode | AwaitResolveRenderFunction; + + /** + The error element renders instead of the children when the promise rejects. + + ```tsx + Oops
    } + resolve={reviewsPromise} + > + + + ``` + + To provide a more contextual error, you can use the {@link useAsyncError} in a + child component + + ```tsx + } + resolve={reviewsPromise} + > + + + + function ReviewsError() { + const error = useAsyncError() + return
    Error loading reviews: {error.message}
    + } + ``` + + If you do not provide an errorElement, the rejected value will bubble up to + the nearest route-level {@link NonIndexRouteObject#ErrorBoundary | ErrorBoundary} and be accessible + via {@link useRouteError} hook. + */ errorElement?: React.ReactNode; - resolve: TrackedPromise | any; + + /** + Takes a promise returned from a {@link LoaderFunction | loader} value to be resolved and rendered. + + ```jsx + import { useLoaderData, Await } from "react-router" + + export async function loader() { + let reviews = getReviews() // not awaited + let book = await getBook() + return { + book, + reviews, // this is a promise + } + } + + export default function Book() { + const { + book, + reviews, // this is the same promise + } = useLoaderData() + + return ( +
    +

    {book.title}

    +

    {book.description}

    + }> + + + + +
    + ); + } + ``` + */ + resolve: Resolve; } /** - * Component to use for rendering lazily loaded data from returning defer() - * in a loader function - */ -export function Await({ children, errorElement, resolve }: AwaitProps) { +Used to render promise values with automatic error handling. + +```tsx +import { Await, useLoaderData } from "react-router"; + +export function loader() { + // not awaited + const reviews = getReviews() + // awaited (blocks the transition) + const book = await fetch("/api/book").then((res) => res.json()) + return { book, reviews } +} + +function Book() { + const { book, reviews } = useLoaderData(); + return ( +
    +

    {book.title}

    +

    {book.description}

    + }> + Could not load reviews ๐Ÿ˜ฌ
    + } + children={(resolvedReviews) => ( + + )} + /> + +
    + ); +} +``` + +**Note:** `` expects to be rendered inside of a `` + +@category Components + +*/ +export function Await({ + children, + errorElement, + resolve, +}: AwaitProps) { return ( {children} @@ -544,8 +994,6 @@ enum AwaitRenderStatus { error, } -const neverSettledPromise = new Promise(() => {}); - class AwaitErrorBoundary extends React.Component< AwaitErrorBoundaryProps, AwaitErrorBoundaryState @@ -607,14 +1055,6 @@ class AwaitErrorBoundary extends React.Component< ); } - if ( - status === AwaitRenderStatus.error && - promise._error instanceof AbortedDeferredError - ) { - // Freeze the UI by throwing a never resolved promise - throw neverSettledPromise; - } - if (status === AwaitRenderStatus.error && !errorElement) { // No errorElement, throw to the nearest route-level error boundary throw promise._error; @@ -658,7 +1098,7 @@ function ResolveAwait({ * either a `` element or an array of them. Used internally by * `` to create a route config from its children. * - * @see https://reactrouter.com/utils/create-routes-from-children + * @category Utils */ export function createRoutesFromChildren( children: React.ReactNode, @@ -705,9 +1145,12 @@ export function createRoutesFromChildren( path: element.props.path, loader: element.props.loader, action: element.props.action, + hydrateFallbackElement: element.props.hydrateFallbackElement, + HydrateFallback: element.props.HydrateFallback, errorElement: element.props.errorElement, ErrorBoundary: element.props.ErrorBoundary, hasErrorBoundary: + element.props.hasErrorBoundary === true || element.props.ErrorBoundary != null || element.props.errorElement != null, shouldRevalidate: element.props.shouldRevalidate, @@ -730,6 +1173,8 @@ export function createRoutesFromChildren( /** * Renders the result of `matchRoutes()` into a React element. + * + * @category Utils */ export function renderMatches( matches: RouteMatch[] | null diff --git a/packages/react-router/lib/context.ts b/packages/react-router/lib/context.ts index a98168217a..7ebf8fb33c 100644 --- a/packages/react-router/lib/context.ts +++ b/packages/react-router/lib/context.ts @@ -1,18 +1,24 @@ import * as React from "react"; import type { - AgnosticIndexRouteObject, - AgnosticNonIndexRouteObject, - AgnosticRouteMatch, History, - LazyRouteFunction, - Location, Action as NavigationType, + Location, + To, +} from "./router/history"; +import type { RelativeRoutingType, Router, StaticHandlerContext, - To, +} from "./router/router"; +import type { + AgnosticIndexRouteObject, + AgnosticNonIndexRouteObject, + AgnosticPatchRoutesOnNavigationFunction, + AgnosticPatchRoutesOnNavigationFunctionArgs, + AgnosticRouteMatch, + LazyRouteFunction, TrackedPromise, -} from "@remix-run/router"; +} from "./router/utils"; // Create react-specific types from the agnostic types in @remix-run/router to // export from react-router @@ -70,6 +76,12 @@ export interface RouteMatch< export interface DataRouteMatch extends RouteMatch {} +export type PatchRoutesOnNavigationFunctionArgs = + AgnosticPatchRoutesOnNavigationFunctionArgs; + +export type PatchRoutesOnNavigationFunction = + AgnosticPatchRoutesOnNavigationFunction; + export interface DataRouterContextObject // Omit `future` since those can be pulled from the `router` // `NavigationContext` needs future since it doesn't have a `router` in all cases @@ -80,29 +92,48 @@ export interface DataRouterContextObject export const DataRouterContext = React.createContext(null); -if (__DEV__) { - DataRouterContext.displayName = "DataRouter"; -} +DataRouterContext.displayName = "DataRouter"; export const DataRouterStateContext = React.createContext< Router["state"] | null >(null); -if (__DEV__) { - DataRouterStateContext.displayName = "DataRouterState"; -} +DataRouterStateContext.displayName = "DataRouterState"; + +export type ViewTransitionContextObject = + | { + isTransitioning: false; + } + | { + isTransitioning: true; + flushSync: boolean; + currentLocation: Location; + nextLocation: Location; + }; + +export const ViewTransitionContext = + React.createContext({ + isTransitioning: false, + }); +ViewTransitionContext.displayName = "ViewTransition"; + +// TODO: (v7) Change the useFetcher data from `any` to `unknown` +export type FetchersContextObject = Map; + +export const FetchersContext = React.createContext( + new Map() +); +FetchersContext.displayName = "Fetchers"; export const AwaitContext = React.createContext(null); -if (__DEV__) { - AwaitContext.displayName = "Await"; -} +AwaitContext.displayName = "Await"; export interface NavigateOptions { replace?: boolean; state?: any; preventScrollReset?: boolean; relative?: RelativeRoutingType; - unstable_flushSync?: boolean; - unstable_viewTransition?: boolean; + flushSync?: boolean; + viewTransition?: boolean; } /** @@ -127,18 +158,15 @@ interface NavigationContextObject { basename: string; navigator: Navigator; static: boolean; - future: { - v7_relativeSplatPath: boolean; - }; + // TODO: Re-introduce a singular `FutureConfig` once we land our first + // future.unstable_ or future.v8_ flag + future: {}; } export const NavigationContext = React.createContext( null! ); - -if (__DEV__) { - NavigationContext.displayName = "Navigation"; -} +NavigationContext.displayName = "Navigation"; interface LocationContextObject { location: Location; @@ -148,10 +176,7 @@ interface LocationContextObject { export const LocationContext = React.createContext( null! ); - -if (__DEV__) { - LocationContext.displayName = "Location"; -} +LocationContext.displayName = "Location"; export interface RouteContextObject { outlet: React.ReactElement | null; @@ -164,13 +189,7 @@ export const RouteContext = React.createContext({ matches: [], isDataRoute: false, }); - -if (__DEV__) { - RouteContext.displayName = "Route"; -} +RouteContext.displayName = "Route"; export const RouteErrorContext = React.createContext(null); - -if (__DEV__) { - RouteErrorContext.displayName = "RouteError"; -} +RouteErrorContext.displayName = "RouteError"; diff --git a/packages/react-router/lib/dom-export/dom-router-provider.tsx b/packages/react-router/lib/dom-export/dom-router-provider.tsx new file mode 100644 index 0000000000..7a3040aee0 --- /dev/null +++ b/packages/react-router/lib/dom-export/dom-router-provider.tsx @@ -0,0 +1,11 @@ +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +import type { RouterProviderProps as BaseRouterProviderProps } from "react-router"; +import { RouterProvider as BaseRouterProvider } from "react-router"; + +export type RouterProviderProps = Omit; + +export function RouterProvider(props: Omit) { + return ; +} diff --git a/packages/react-router/lib/dom-export/hydrated-router.tsx b/packages/react-router/lib/dom-export/hydrated-router.tsx new file mode 100644 index 0000000000..af8ae89b04 --- /dev/null +++ b/packages/react-router/lib/dom-export/hydrated-router.tsx @@ -0,0 +1,281 @@ +import * as React from "react"; + +import type { + UNSAFE_AssetsManifest as AssetsManifest, + UNSAFE_RouteModules as RouteModules, + DataRouter, + HydrationState, +} from "react-router"; +import { + UNSAFE_invariant as invariant, + UNSAFE_FrameworkContext as FrameworkContext, + UNSAFE_decodeViaTurboStream as decodeViaTurboStream, + UNSAFE_RemixErrorBoundary as RemixErrorBoundary, + UNSAFE_createBrowserHistory as createBrowserHistory, + UNSAFE_createClientRoutes as createClientRoutes, + UNSAFE_createRouter as createRouter, + UNSAFE_deserializeErrors as deserializeErrors, + UNSAFE_getSingleFetchDataStrategy as getSingleFetchDataStrategy, + UNSAFE_getPatchRoutesOnNavigationFunction as getPatchRoutesOnNavigationFunction, + UNSAFE_shouldHydrateRouteLoader as shouldHydrateRouteLoader, + UNSAFE_useFogOFWarDiscovery as useFogOFWarDiscovery, + UNSAFE_mapRouteProperties as mapRouteProperties, + UNSAFE_createClientRoutesWithHMRRevalidationOptOut as createClientRoutesWithHMRRevalidationOptOut, + matchRoutes, +} from "react-router"; +import { RouterProvider } from "./dom-router-provider"; + +type SSRInfo = { + context: NonNullable<(typeof window)["__reactRouterContext"]>; + routeModules: RouteModules; + manifest: AssetsManifest; + stateDecodingPromise: + | (Promise & { + value?: unknown; + error?: unknown; + }) + | undefined; + router: DataRouter | undefined; + routerInitialized: boolean; +}; + +let ssrInfo: SSRInfo | null = null; +let router: DataRouter | null = null; + +function initSsrInfo(): void { + if ( + !ssrInfo && + window.__reactRouterContext && + window.__reactRouterManifest && + window.__reactRouterRouteModules + ) { + ssrInfo = { + context: window.__reactRouterContext, + manifest: window.__reactRouterManifest, + routeModules: window.__reactRouterRouteModules, + stateDecodingPromise: undefined, + router: undefined, + routerInitialized: false, + }; + } +} + +function createHydratedRouter(): DataRouter { + initSsrInfo(); + + if (!ssrInfo) { + throw new Error( + "You must be using the SSR features of React Router in order to skip " + + "passing a `router` prop to ``" + ); + } + + // We need to suspend until the initial state snapshot is decoded into + // window.__reactRouterContext.state + + let localSsrInfo = ssrInfo; + // Note: `stateDecodingPromise` is not coupled to `router` - we'll reach this + // code potentially many times waiting for our state to arrive, but we'll + // then only get past here and create the `router` one time + if (!ssrInfo.stateDecodingPromise) { + let stream = ssrInfo.context.stream; + invariant(stream, "No stream found for single fetch decoding"); + ssrInfo.context.stream = undefined; + ssrInfo.stateDecodingPromise = decodeViaTurboStream(stream, window) + .then((value) => { + ssrInfo!.context.state = + value.value as typeof localSsrInfo.context.state; + localSsrInfo.stateDecodingPromise!.value = true; + }) + .catch((e) => { + localSsrInfo.stateDecodingPromise!.error = e; + }); + } + if (ssrInfo.stateDecodingPromise.error) { + throw ssrInfo.stateDecodingPromise.error; + } + if (!ssrInfo.stateDecodingPromise.value) { + throw ssrInfo.stateDecodingPromise; + } + + let routes = createClientRoutes( + ssrInfo.manifest.routes, + ssrInfo.routeModules, + ssrInfo.context.state, + ssrInfo.context.isSpaMode + ); + + let hydrationData: HydrationState | undefined = undefined; + if (!ssrInfo.context.isSpaMode) { + // Create a shallow clone of `loaderData` we can mutate for partial hydration. + // When a route exports a `clientLoader` and a `HydrateFallback`, the SSR will + // render the fallback so we need the client to do the same for hydration. + // The server loader data has already been exposed to these route `clientLoader`'s + // in `createClientRoutes` above, so we need to clear out the version we pass to + // `createBrowserRouter` so it initializes and runs the client loaders. + hydrationData = { + ...ssrInfo.context.state, + loaderData: { ...ssrInfo.context.state.loaderData }, + }; + let initialMatches = matchRoutes( + routes, + window.location, + window.__reactRouterContext?.basename + ); + if (initialMatches) { + for (let match of initialMatches) { + let routeId = match.route.id; + let route = ssrInfo.routeModules[routeId]; + let manifestRoute = ssrInfo.manifest.routes[routeId]; + // Clear out the loaderData to avoid rendering the route component when the + // route opted into clientLoader hydration and either: + // * gave us a HydrateFallback + // * or doesn't have a server loader and we have no data to render + if ( + route && + manifestRoute && + shouldHydrateRouteLoader( + manifestRoute, + route, + ssrInfo.context.isSpaMode + ) && + (route.HydrateFallback || !manifestRoute.hasLoader) + ) { + delete hydrationData.loaderData![routeId]; + } else if (manifestRoute && !manifestRoute.hasLoader) { + // Since every Remix route gets a `loader` on the client side to load + // the route JS module, we need to add a `null` value to `loaderData` + // for any routes that don't have server loaders so our partial + // hydration logic doesn't kick off the route module loaders during + // hydration + hydrationData.loaderData![routeId] = null; + } + } + } + + if (hydrationData && hydrationData.errors) { + // TODO: De-dup this or remove entirely in v7 where single fetch is the + // only approach and we have already serialized or deserialized on the server + hydrationData.errors = deserializeErrors(hydrationData.errors); + } + } + + // We don't use createBrowserRouter here because we need fine-grained control + // over initialization to support synchronous `clientLoader` flows. + let router = createRouter({ + routes, + history: createBrowserHistory(), + basename: ssrInfo.context.basename, + hydrationData, + mapRouteProperties, + dataStrategy: getSingleFetchDataStrategy( + ssrInfo.manifest, + ssrInfo.routeModules, + () => router + ), + patchRoutesOnNavigation: getPatchRoutesOnNavigationFunction( + ssrInfo.manifest, + ssrInfo.routeModules, + ssrInfo.context.isSpaMode, + ssrInfo.context.basename + ), + }); + ssrInfo.router = router; + + // We can call initialize() immediately if the router doesn't have any + // loaders to run on hydration + if (router.state.initialized) { + ssrInfo.routerInitialized = true; + router.initialize(); + } + + // @ts-ignore + router.createRoutesForHMR = + /* spacer so ts-ignore does not affect the right hand of the assignment */ + createClientRoutesWithHMRRevalidationOptOut; + window.__reactRouterDataRouter = router; + + return router; +} + +/** + * @category Router Components + */ +export function HydratedRouter() { + if (!router) { + router = createHydratedRouter(); + } + + // 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. + 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); + } + } + + let [location, setLocation] = React.useState(router.state.location); + + React.useLayoutEffect(() => { + // If we had to run clientLoaders on hydration, we delay initialization until + // after we've hydrated to avoid hydration issues from synchronous client loaders + if (ssrInfo && ssrInfo.router && !ssrInfo.routerInitialized) { + ssrInfo.routerInitialized = true; + ssrInfo.router.initialize(); + } + }, []); + + React.useLayoutEffect(() => { + if (ssrInfo && ssrInfo.router) { + return ssrInfo.router.subscribe((newState) => { + if (newState.location !== location) { + setLocation(newState.location); + } + }); + } + }, [location]); + + invariant(ssrInfo, "ssrInfo unavailable for HydratedRouter"); + + useFogOFWarDiscovery( + router, + ssrInfo.manifest, + ssrInfo.routeModules, + ssrInfo.context.isSpaMode + ); + + // We need to include a wrapper RemixErrorBoundary here in case the root error + // boundary also throws and we need to bubble up outside of the router entirely. + // Then we need a stateful location here so the user can back-button navigate + // out of there + return ( + // This fragment is important to ensure we match the JSX + // structure so that useId values hydrate correctly + <> + + + + + + {/* + This fragment is important to ensure we match the JSX + structure so that useId values hydrate correctly + */} + <> + + ); +} diff --git a/packages/react-router-dom/dom.ts b/packages/react-router/lib/dom/dom.ts similarity index 91% rename from packages/react-router-dom/dom.ts rename to packages/react-router/lib/dom/dom.ts index ca2ac9a767..24ec0a6945 100644 --- a/packages/react-router-dom/dom.ts +++ b/packages/react-router/lib/dom/dom.ts @@ -1,9 +1,7 @@ -import type { - FormEncType, - HTMLFormMethod, - RelativeRoutingType, -} from "@remix-run/router"; -import { stripBasename, UNSAFE_warning as warning } from "@remix-run/router"; +import { warning } from "../router/history"; +import type { RelativeRoutingType } from "../router/router"; +import type { FormEncType, HTMLFormMethod } from "../router/utils"; +import { stripBasename } from "../router/utils"; export const defaultMethod: HTMLFormMethod = "get"; const defaultEncType: FormEncType = "application/x-www-form-urlencoded"; @@ -53,25 +51,30 @@ export type URLSearchParamsInit = | URLSearchParams; /** - * Creates a URLSearchParams object using the given initializer. - * - * This is identical to `new URLSearchParams(init)` except it also - * supports arrays as values in the object form of the initializer - * instead of just strings. This is convenient when you need multiple - * values for a given key, but don't want to use an array initializer. - * - * For example, instead of: - * - * let searchParams = new URLSearchParams([ - * ['sort', 'name'], - * ['sort', 'price'] - * ]); - * - * you can do: - * - * let searchParams = createSearchParams({ - * sort: ['name', 'price'] - * }); + Creates a URLSearchParams object using the given initializer. + + This is identical to `new URLSearchParams(init)` except it also + supports arrays as values in the object form of the initializer + instead of just strings. This is convenient when you need multiple + values for a given key, but don't want to use an array initializer. + + For example, instead of: + + ```tsx + let searchParams = new URLSearchParams([ + ['sort', 'name'], + ['sort', 'price'] + ]); + ``` + you can do: + + ``` + let searchParams = createSearchParams({ + sort: ['name', 'price'] + }); + ``` + + @category Utils */ export function createSearchParams( init: URLSearchParamsInit = "" @@ -188,7 +191,7 @@ interface SharedSubmitOptions { /** * Enable flushSync for this submission's state updates */ - unstable_flushSync?: boolean; + flushSync?: boolean; } /** @@ -225,7 +228,7 @@ export interface SubmitOptions extends FetcherSubmitOptions { /** * Enable view transitions on this submission navigation */ - unstable_viewTransition?: boolean; + viewTransition?: boolean; } const supportedFormEncTypes: Set = new Set([ diff --git a/packages/react-router/lib/dom/global.ts b/packages/react-router/lib/dom/global.ts new file mode 100644 index 0000000000..30c54dd0b2 --- /dev/null +++ b/packages/react-router/lib/dom/global.ts @@ -0,0 +1,50 @@ +import type { HydrationState, Router as DataRouter } from "../router/router"; +import type { AssetsManifest, FutureConfig } from "./ssr/entry"; +import type { RouteModules } from "./ssr/routeModules"; + +export type WindowReactRouterContext = { + basename?: string; + state: HydrationState; + criticalCss?: string; + future: FutureConfig; + isSpaMode: boolean; + stream: ReadableStream | undefined; + streamController: ReadableStreamDefaultController; + // The number of active deferred keys rendered on the server + a?: number; + dev?: { + port?: number; + hmrRuntime?: string; + }; +}; + +export interface ViewTransition { + finished: Promise; + ready: Promise; + updateCallbackDone: Promise; + skipTransition(): void; +} + +declare global { + // TODO: v7 - Can this go away in favor of "just use remix"? + var __staticRouterHydrationData: HydrationState | undefined; + // v6 SPA info + var __reactRouterVersion: string; + interface Document { + startViewTransition(cb: () => Promise | void): ViewTransition; + } + var __reactRouterContext: WindowReactRouterContext | undefined; + var __reactRouterManifest: AssetsManifest | undefined; + var __reactRouterRouteModules: RouteModules | undefined; + var __reactRouterDataRouter: DataRouter | undefined; + var __reactRouterHdrActive: boolean; + var __reactRouterClearCriticalCss: (() => void) | undefined; + var $RefreshRuntime$: + | { + performReactRefresh: () => void; + } + | undefined; +} + +// https://stackoverflow.com/a/59499895 +export {}; diff --git a/packages/react-router-dom/index.tsx b/packages/react-router/lib/dom/lib.tsx similarity index 55% rename from packages/react-router-dom/index.tsx rename to packages/react-router/lib/dom/lib.tsx index 5edf5889b2..413bd4527e 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -1,75 +1,47 @@ -/** - * NOTE: If you refactor this to split up the modules into separate files, - * you'll need to update the rollup config for react-router-dom-v5-compat. - */ import * as React from "react"; -import * as ReactDOM from "react-dom"; + import type { - DataRouteObject, - FutureConfig, + BrowserHistory, + HashHistory, + History, + Action as NavigationType, Location, - NavigateOptions, - NavigationType, - Navigator, - RelativeRoutingType, - RouteObject, - RouterProps, - RouterProviderProps, To, - unstable_DataStrategyFunction, - unstable_PatchRoutesOnNavigationFunction, -} from "react-router"; +} from "../router/history"; import { - Router, + createBrowserHistory, + createHashHistory, createPath, - useHref, - useLocation, - useMatches, - useNavigate, - useNavigation, - useResolvedPath, - useBlocker, - UNSAFE_DataRouterContext as DataRouterContext, - UNSAFE_DataRouterStateContext as DataRouterStateContext, - UNSAFE_NavigationContext as NavigationContext, - UNSAFE_RouteContext as RouteContext, - UNSAFE_mapRouteProperties as mapRouteProperties, - UNSAFE_useRouteId as useRouteId, - UNSAFE_useRoutesImpl as useRoutesImpl, -} from "react-router"; + invariant, + warning, +} from "../router/history"; import type { - BrowserHistory, + BlockerFunction, Fetcher, - FormEncType, - FormMethod, - FutureConfig as RouterFutureConfig, + FutureConfig, GetScrollRestorationKeyFunction, - HashHistory, - History, - HTMLFormMethod, HydrationState, - Router as RemixRouter, - V7_FormMethod, - RouterState, - RouterSubscriber, - BlockerFunction, -} from "@remix-run/router"; + RelativeRoutingType, + Router as DataRouter, +} from "../router/router"; +import { IDLE_FETCHER, createRouter } from "../router/router"; +import type { + DataStrategyFunction, + FormEncType, + HTMLFormMethod, + UIMatch, +} from "../router/utils"; import { - createRouter, - createBrowserHistory, - createHashHistory, + ErrorResponseImpl, joinPaths, - stripBasename, - UNSAFE_ErrorResponseImpl as ErrorResponseImpl, - UNSAFE_invariant as invariant, - UNSAFE_warning as warning, matchPath, - IDLE_FETCHER, -} from "@remix-run/router"; + stripBasename, +} from "../router/utils"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type * as _ from "./global"; import type { SubmitOptions, - ParamKeyValuePair, URLSearchParamsInit, SubmitTarget, FetcherSubmitOptions, @@ -82,157 +54,51 @@ import { shouldProcessLinkClick, } from "./dom"; -//////////////////////////////////////////////////////////////////////////////// -//#region Re-exports -//////////////////////////////////////////////////////////////////////////////// - -export type { - FormEncType, - FormMethod, - GetScrollRestorationKeyFunction, - ParamKeyValuePair, - SubmitOptions, - URLSearchParamsInit, - V7_FormMethod, -}; -export { createSearchParams, ErrorResponseImpl as UNSAFE_ErrorResponseImpl }; - -// Note: Keep in sync with react-router exports! -export type { - ActionFunction, - ActionFunctionArgs, - AwaitProps, - Blocker, - BlockerFunction, - DataRouteMatch, - DataRouteObject, - unstable_DataStrategyFunction, - unstable_DataStrategyFunctionArgs, - unstable_DataStrategyMatch, - unstable_DataStrategyResult, - ErrorResponse, - Fetcher, - FutureConfig, - Hash, - IndexRouteObject, - IndexRouteProps, - JsonFunction, - LazyRouteFunction, - LayoutRouteProps, - LoaderFunction, - LoaderFunctionArgs, - Location, - MemoryRouterProps, - NavigateFunction, - NavigateOptions, - NavigateProps, - Navigation, - Navigator, - NonIndexRouteObject, - OutletProps, - Params, - ParamParseKey, - Path, - PathMatch, - Pathname, - PathParam, - PathPattern, - PathRouteProps, - RedirectFunction, - RelativeRoutingType, - RouteMatch, +import type { + DiscoverBehavior, + PrefetchBehavior, + ScriptsProps, +} from "./ssr/components"; +import { + PrefetchPageLinks, + FrameworkContext, + mergeRefs, + usePrefetchBehavior, +} from "./ssr/components"; +import { Router, mapRouteProperties } from "../components"; +import type { RouteObject, - RouteProps, - RouterProps, - RouterProviderProps, - RoutesProps, - Search, - ShouldRevalidateFunction, - ShouldRevalidateFunctionArgs, - To, - UIMatch, - unstable_PatchRoutesOnNavigationFunction, -} from "react-router"; -export { - AbortedDeferredError, - Await, - MemoryRouter, - Navigate, - NavigationType, - Outlet, - Route, - Router, - Routes, - createMemoryRouter, - createPath, - createRoutesFromChildren, - createRoutesFromElements, - defer, - isRouteErrorResponse, - generatePath, - json, - matchPath, - matchRoutes, - parsePath, - redirect, - redirectDocument, - replace, - renderMatches, - resolvePath, - useActionData, - useAsyncError, - useAsyncValue, + NavigateOptions, + PatchRoutesOnNavigationFunction, +} from "../context"; +import { + DataRouterContext, + DataRouterStateContext, + FetchersContext, + NavigationContext, + RouteContext, + ViewTransitionContext, +} from "../context"; +import { useBlocker, useHref, - useInRouterContext, - useLoaderData, useLocation, - useMatch, useMatches, useNavigate, useNavigation, - useNavigationType, - useOutlet, - useOutletContext, - useParams, useResolvedPath, - useRevalidator, - useRouteError, - useRouteLoaderData, - useRoutes, -} from "react-router"; - -/////////////////////////////////////////////////////////////////////////////// -// DANGER! PLEASE READ ME! -// We provide these exports as an escape hatch in the event that you need any -// routing data that we don't provide an explicit API for. With that said, we -// want to cover your use case if we can, so if you feel the need to use these -// we want to hear from you. Let us know what you're building and we'll do our -// best to make sure we can support you! -// -// We consider these exports an implementation detail and do not guarantee -// against any breaking changes, regardless of the semver release. Use with -// extreme caution and only if you understand the consequences. Godspeed. -/////////////////////////////////////////////////////////////////////////////// - -/** @internal */ -export { - UNSAFE_DataRouterContext, - UNSAFE_DataRouterStateContext, - UNSAFE_NavigationContext, - UNSAFE_LocationContext, - UNSAFE_RouteContext, - UNSAFE_useRouteId, -} from "react-router"; -//#endregion + useRouteId, +} from "../hooks"; +import type { SerializeFrom } from "../types/route-data"; -declare global { - var __staticRouterHydrationData: HydrationState | undefined; - var __reactRouterVersion: string; - interface Document { - startViewTransition(cb: () => Promise | void): ViewTransition; - } -} +//////////////////////////////////////////////////////////////////////////////// +//#region Global Stuff +//////////////////////////////////////////////////////////////////////////////// + +const isBrowser = + typeof window !== "undefined" && + typeof window.document !== "undefined" && + typeof window.document.createElement !== "undefined"; // HEY YOU! DON'T TOUCH THIS VARIABLE! // @@ -243,12 +109,17 @@ declare global { // 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 -const REACT_ROUTER_VERSION = "0"; +declare global { + const REACT_ROUTER_VERSION: string; +} try { - window.__reactRouterVersion = REACT_ROUTER_VERSION; + if (isBrowser) { + window.__reactRouterVersion = REACT_ROUTER_VERSION; + } } catch (e) { // no-op } +//#endregion //////////////////////////////////////////////////////////////////////////////// //#region Routers @@ -256,49 +127,49 @@ try { interface DOMRouterOpts { basename?: string; - future?: Partial>; + future?: Partial; hydrationData?: HydrationState; - unstable_dataStrategy?: unstable_DataStrategyFunction; - unstable_patchRoutesOnNavigation?: unstable_PatchRoutesOnNavigationFunction; + dataStrategy?: DataStrategyFunction; + patchRoutesOnNavigation?: PatchRoutesOnNavigationFunction; window?: Window; } +/** + * @category Routers + */ export function createBrowserRouter( routes: RouteObject[], opts?: DOMRouterOpts -): RemixRouter { +): DataRouter { return createRouter({ basename: opts?.basename, - future: { - ...opts?.future, - v7_prependBasename: true, - }, + future: opts?.future, history: createBrowserHistory({ window: opts?.window }), hydrationData: opts?.hydrationData || parseHydrationData(), routes, mapRouteProperties, - unstable_dataStrategy: opts?.unstable_dataStrategy, - unstable_patchRoutesOnNavigation: opts?.unstable_patchRoutesOnNavigation, + dataStrategy: opts?.dataStrategy, + patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, }).initialize(); } +/** + * @category Routers + */ export function createHashRouter( routes: RouteObject[], opts?: DOMRouterOpts -): RemixRouter { +): DataRouter { return createRouter({ basename: opts?.basename, - future: { - ...opts?.future, - v7_prependBasename: true, - }, + future: opts?.future, history: createHashHistory({ window: opts?.window }), hydrationData: opts?.hydrationData || parseHydrationData(), routes, mapRouteProperties, - unstable_dataStrategy: opts?.unstable_dataStrategy, - unstable_patchRoutesOnNavigation: opts?.unstable_patchRoutesOnNavigation, + dataStrategy: opts?.dataStrategy, + patchRoutesOnNavigation: opts?.patchRoutesOnNavigation, window: opts?.window, }).initialize(); } @@ -315,11 +186,11 @@ function parseHydrationData(): HydrationState | undefined { } function deserializeErrors( - errors: RemixRouter["state"]["errors"] -): RemixRouter["state"]["errors"] { + errors: DataRouter["state"]["errors"] +): DataRouter["state"]["errors"] { if (!errors) return null; let entries = Object.entries(errors); - let serialized: RemixRouter["state"]["errors"] = {}; + let serialized: DataRouter["state"]["errors"] = {}; for (let [key, val] of entries) { // Hey you! If you change this, please change the corresponding logic in // serializeErrors in react-router-dom/server.tsx :) @@ -364,424 +235,27 @@ function deserializeErrors( //#endregion -//////////////////////////////////////////////////////////////////////////////// -//#region Contexts -//////////////////////////////////////////////////////////////////////////////// - -type ViewTransitionContextObject = - | { - isTransitioning: false; - } - | { - isTransitioning: true; - flushSync: boolean; - currentLocation: Location; - nextLocation: Location; - }; - -const ViewTransitionContext = React.createContext({ - isTransitioning: false, -}); -if (__DEV__) { - ViewTransitionContext.displayName = "ViewTransition"; -} - -export { ViewTransitionContext as UNSAFE_ViewTransitionContext }; - -// TODO: (v7) Change the useFetcher data from `any` to `unknown` -type FetchersContextObject = Map; - -const FetchersContext = React.createContext(new Map()); -if (__DEV__) { - FetchersContext.displayName = "Fetchers"; -} - -export { FetchersContext as UNSAFE_FetchersContext }; - -//#endregion - //////////////////////////////////////////////////////////////////////////////// //#region Components //////////////////////////////////////////////////////////////////////////////// /** - Webpack + React 17 fails to compile on any of the following because webpack - complains that `startTransition` doesn't exist in `React`: - * import { startTransition } from "react" - * import * as React from from "react"; - "startTransition" in React ? React.startTransition(() => setState()) : setState() - * import * as React from from "react"; - "startTransition" in React ? React["startTransition"](() => setState()) : setState() - - Moving it to a constant such as the following solves the Webpack/React 17 issue: - * import * as React from from "react"; - const START_TRANSITION = "startTransition"; - START_TRANSITION in React ? React[START_TRANSITION](() => setState()) : setState() - - However, that introduces webpack/terser minification issues in production builds - in React 18 where minification/obfuscation ends up removing the call of - React.startTransition entirely from the first half of the ternary. Grabbing - this exported reference once up front resolves that issue. - - See https://github.com/remix-run/react-router/issues/10579 -*/ -const START_TRANSITION = "startTransition"; -const startTransitionImpl = React[START_TRANSITION]; -const FLUSH_SYNC = "flushSync"; -const flushSyncImpl = ReactDOM[FLUSH_SYNC]; -const USE_ID = "useId"; -const useIdImpl = React[USE_ID]; - -function startTransitionSafe(cb: () => void) { - if (startTransitionImpl) { - startTransitionImpl(cb); - } else { - cb(); - } -} - -function flushSyncSafe(cb: () => void) { - if (flushSyncImpl) { - flushSyncImpl(cb); - } else { - cb(); - } -} - -interface ViewTransition { - finished: Promise; - ready: Promise; - updateCallbackDone: Promise; - skipTransition(): void; -} - -class Deferred { - status: "pending" | "resolved" | "rejected" = "pending"; - promise: Promise; - // @ts-expect-error - no initializer - resolve: (value: T) => void; - // @ts-expect-error - no initializer - reject: (reason?: unknown) => void; - constructor() { - this.promise = new Promise((resolve, reject) => { - this.resolve = (value) => { - if (this.status === "pending") { - this.status = "resolved"; - resolve(value); - } - }; - this.reject = (reason) => { - if (this.status === "pending") { - this.status = "rejected"; - reject(reason); - } - }; - }); - } -} - -/** - * Given a Remix Router instance, render the appropriate UI + * @category Types */ -export function RouterProvider({ - fallbackElement, - router, - future, -}: RouterProviderProps): React.ReactElement { - let [state, setStateImpl] = React.useState(router.state); - let [pendingState, setPendingState] = React.useState(); - let [vtContext, setVtContext] = React.useState({ - isTransitioning: false, - }); - let [renderDfd, setRenderDfd] = React.useState>(); - let [transition, setTransition] = React.useState(); - let [interruption, setInterruption] = React.useState<{ - state: RouterState; - currentLocation: Location; - nextLocation: Location; - }>(); - let fetcherData = React.useRef>(new Map()); - let { v7_startTransition } = future || {}; - - let optInStartTransition = React.useCallback( - (cb: () => void) => { - if (v7_startTransition) { - startTransitionSafe(cb); - } else { - cb(); - } - }, - [v7_startTransition] - ); - - let setState = React.useCallback( - ( - newState: RouterState, - { - deletedFetchers, - unstable_flushSync: flushSync, - unstable_viewTransitionOpts: viewTransitionOpts, - } - ) => { - deletedFetchers.forEach((key) => fetcherData.current.delete(key)); - newState.fetchers.forEach((fetcher, key) => { - if (fetcher.data !== undefined) { - fetcherData.current.set(key, fetcher.data); - } - }); - - let isViewTransitionUnavailable = - router.window == null || - router.window.document == null || - typeof router.window.document.startViewTransition !== "function"; - - // If this isn't a view transition or it's not available in this browser, - // just update and be done with it - if (!viewTransitionOpts || isViewTransitionUnavailable) { - if (flushSync) { - flushSyncSafe(() => setStateImpl(newState)); - } else { - optInStartTransition(() => setStateImpl(newState)); - } - return; - } - - // flushSync + startViewTransition - if (flushSync) { - // Flush through the context to mark DOM elements as transition=ing - flushSyncSafe(() => { - // Cancel any pending transitions - if (transition) { - renderDfd && renderDfd.resolve(); - transition.skipTransition(); - } - setVtContext({ - isTransitioning: true, - flushSync: true, - currentLocation: viewTransitionOpts.currentLocation, - nextLocation: viewTransitionOpts.nextLocation, - }); - }); - - // Update the DOM - let t = router.window!.document.startViewTransition(() => { - flushSyncSafe(() => setStateImpl(newState)); - }); - - // Clean up after the animation completes - t.finished.finally(() => { - flushSyncSafe(() => { - setRenderDfd(undefined); - setTransition(undefined); - setPendingState(undefined); - setVtContext({ isTransitioning: false }); - }); - }); - - flushSyncSafe(() => setTransition(t)); - return; - } - - // startTransition + startViewTransition - if (transition) { - // Interrupting an in-progress transition, cancel and let everything flush - // out, and then kick off a new transition from the interruption state - renderDfd && renderDfd.resolve(); - transition.skipTransition(); - setInterruption({ - state: newState, - currentLocation: viewTransitionOpts.currentLocation, - nextLocation: viewTransitionOpts.nextLocation, - }); - } else { - // Completed navigation update with opted-in view transitions, let 'er rip - setPendingState(newState); - setVtContext({ - isTransitioning: true, - flushSync: false, - currentLocation: viewTransitionOpts.currentLocation, - nextLocation: viewTransitionOpts.nextLocation, - }); - } - }, - [router.window, transition, renderDfd, fetcherData, optInStartTransition] - ); - - // Need to use a layout effect here so we are subscribed early enough to - // pick up on any render-driven redirects/navigations (useEffect/) - React.useLayoutEffect(() => router.subscribe(setState), [router, setState]); - - // When we start a view transition, create a Deferred we can use for the - // eventual "completed" render - React.useEffect(() => { - if (vtContext.isTransitioning && !vtContext.flushSync) { - setRenderDfd(new Deferred()); - } - }, [vtContext]); - - // Once the deferred is created, kick off startViewTransition() to update the - // DOM and then wait on the Deferred to resolve (indicating the DOM update has - // happened) - React.useEffect(() => { - if (renderDfd && pendingState && router.window) { - let newState = pendingState; - let renderPromise = renderDfd.promise; - let transition = router.window.document.startViewTransition(async () => { - optInStartTransition(() => setStateImpl(newState)); - await renderPromise; - }); - transition.finished.finally(() => { - setRenderDfd(undefined); - setTransition(undefined); - setPendingState(undefined); - setVtContext({ isTransitioning: false }); - }); - setTransition(transition); - } - }, [optInStartTransition, pendingState, renderDfd, router.window]); - - // When the new location finally renders and is committed to the DOM, this - // effect will run to resolve the transition - React.useEffect(() => { - if ( - renderDfd && - pendingState && - state.location.key === pendingState.location.key - ) { - renderDfd.resolve(); - } - }, [renderDfd, transition, state.location, pendingState]); - - // If we get interrupted with a new navigation during a transition, we skip - // the active transition, let it cleanup, then kick it off again here - React.useEffect(() => { - if (!vtContext.isTransitioning && interruption) { - setPendingState(interruption.state); - setVtContext({ - isTransitioning: true, - flushSync: false, - currentLocation: interruption.currentLocation, - nextLocation: interruption.nextLocation, - }); - setInterruption(undefined); - } - }, [vtContext.isTransitioning, interruption]); - - React.useEffect(() => { - warning( - fallbackElement == null || !router.future.v7_partialHydration, - "`` is deprecated when using " + - "`v7_partialHydration`, use a `HydrateFallback` component instead" - ); - // Only log this once on initial mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - let navigator = React.useMemo((): Navigator => { - return { - createHref: router.createHref, - encodeLocation: router.encodeLocation, - go: (n) => router.navigate(n), - push: (to, state, opts) => - router.navigate(to, { - state, - preventScrollReset: opts?.preventScrollReset, - }), - replace: (to, state, opts) => - router.navigate(to, { - replace: true, - state, - preventScrollReset: opts?.preventScrollReset, - }), - }; - }, [router]); - - let basename = router.basename || "/"; - - let dataRouterContext = React.useMemo( - () => ({ - router, - navigator, - static: false, - basename, - }), - [router, navigator, basename] - ); - - let routerFuture = React.useMemo( - () => ({ - v7_relativeSplatPath: router.future.v7_relativeSplatPath, - }), - [router.future.v7_relativeSplatPath] - ); - - // The fragment and {null} here are important! We need them to keep React 18's - // useId happy when we are server-rendering since we may have a