diff --git a/.changeset/calm-frogs-tie.md b/.changeset/calm-frogs-tie.md new file mode 100644 index 0000000000..f7f7b34b94 --- /dev/null +++ b/.changeset/calm-frogs-tie.md @@ -0,0 +1,5 @@ +--- +"@react-router/cloudflare": major +--- + +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. diff --git a/.changeset/chilled-masks-search.md b/.changeset/chilled-masks-search.md new file mode 100644 index 0000000000..2dfd04cf76 --- /dev/null +++ b/.changeset/chilled-masks-search.md @@ -0,0 +1,12 @@ +--- +"react-router-dom": major +"react-router": major +--- + +Remove the original `defer` implementation in favor of using raw promises via single fetch and `turbo-stream`. This removes these exports from React Router: + +- `defer` +- `AbortedDeferredError` +- `type TypedDeferredData` +- `UNSAFE_DeferredData` +- `UNSAFE_DEFERRED_SYMBOL`, diff --git a/.changeset/collapse-packages.md b/.changeset/collapse-packages.md new file mode 100644 index 0000000000..0903b92255 --- /dev/null +++ b/.changeset/collapse-packages.md @@ -0,0 +1,8 @@ +--- +"react-router": major +--- + +- Collapse `@remix-run/router` into `react-router` +- Collapse `react-router-dom` into `react-router` +- Collapse `@remix-run/server-runtime` into `react-router` +- Collapse `@remix-run/testing` into `react-router` diff --git a/.changeset/config.json b/.changeset/config.json index 8e1dec4247..79f97d374d 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -9,8 +9,10 @@ [ "react-router", "react-router-dom", - "react-router-dom-v5-compat", - "react-router-native" + "@react-router/dev", + "@react-router/express", + "@react-router/node", + "@react-router/serve" ] ], "linked": [], @@ -18,5 +20,8 @@ "baseBranch": "dev", "updateInternalDependencies": "patch", "bumpVersionsWithWorkspaceProtocolOnly": true, - "ignore": [] + "ignore": ["integration", "integration-*", "@playground/*"], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/.changeset/create-remix-router.md b/.changeset/create-remix-router.md new file mode 100644 index 0000000000..8c13e9371f --- /dev/null +++ b/.changeset/create-remix-router.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": major +--- + +Use `createRemixRouter`/`RouterProvider` in `entry.client` instead of `RemixBrowser` diff --git a/.changeset/curvy-teachers-explain.md b/.changeset/curvy-teachers-explain.md new file mode 100644 index 0000000000..941857bb46 --- /dev/null +++ b/.changeset/curvy-teachers-explain.md @@ -0,0 +1,11 @@ +--- +"@react-router/server-runtime": major +"react-router-dom": major +"@react-router/express": major +"react-router": major +"@react-router/serve": major +"@react-router/node": major +"@react-router/dev": major +--- + +Remove single_fetch future flag. diff --git a/.changeset/drop-node-16.md b/.changeset/drop-node-16.md new file mode 100644 index 0000000000..be3759da73 --- /dev/null +++ b/.changeset/drop-node-16.md @@ -0,0 +1,5 @@ +--- +"react-router": major +--- + +Drop support for Node 16, React Router SSR now requires Node 18 or higher diff --git a/.changeset/early-beds-obey.md b/.changeset/early-beds-obey.md new file mode 100644 index 0000000000..bac0cbcbf6 --- /dev/null +++ b/.changeset/early-beds-obey.md @@ -0,0 +1,6 @@ +--- +"react-router-dom": major +"react-router": major +--- + +Remove `future.v7_startTransition` flag diff --git a/.changeset/expose-promises.md b/.changeset/expose-promises.md new file mode 100644 index 0000000000..43bf5fbd63 --- /dev/null +++ b/.changeset/expose-promises.md @@ -0,0 +1,10 @@ +--- +"react-router": major +--- + +- Expose the underlying router promises from the following APIs for compsition in React 19 APIs: + - `useNavigate()` + - `useSubmit` + - `useFetcher().load` + - `useFetcher().submit` + - `useRevalidator.revalidate` diff --git a/.changeset/fair-beans-design.md b/.changeset/fair-beans-design.md new file mode 100644 index 0000000000..a2d2fc494c --- /dev/null +++ b/.changeset/fair-beans-design.md @@ -0,0 +1,5 @@ +--- +"@react-router/cloudflare": minor +--- + +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. diff --git a/.changeset/fair-cheetahs-hope.md b/.changeset/fair-cheetahs-hope.md new file mode 100644 index 0000000000..e0503d468d --- /dev/null +++ b/.changeset/fair-cheetahs-hope.md @@ -0,0 +1,6 @@ +--- +"react-router-dom": major +"react-router": major +--- + +Remove `future.v7_normalizeFormMethod` future flag diff --git a/.changeset/fluffy-ducks-try.md b/.changeset/fluffy-ducks-try.md new file mode 100644 index 0000000000..6761871eec --- /dev/null +++ b/.changeset/fluffy-ducks-try.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": major +--- + +Allow returning `undefined` from actions and loaders diff --git a/.changeset/kind-timers-refuse.md b/.changeset/kind-timers-refuse.md new file mode 100644 index 0000000000..975607a534 --- /dev/null +++ b/.changeset/kind-timers-refuse.md @@ -0,0 +1,11 @@ +--- +"@react-router/server-runtime": major +"react-router-dom": major +"@react-router/express": major +"react-router": major +"@react-router/serve": major +"@react-router/node": major +"@react-router/dev": major +--- + +update minimum node version to 18 diff --git a/.changeset/late-buckets-turn.md b/.changeset/late-buckets-turn.md new file mode 100644 index 0000000000..c3a23566ab --- /dev/null +++ b/.changeset/late-buckets-turn.md @@ -0,0 +1,6 @@ +--- +"react-router-dom": major +"react-router": major +--- + +Remove `future.v7_prependBasename` from the ionternalized `@remix-run/router` package diff --git a/.changeset/link-prefetching.md b/.changeset/link-prefetching.md new file mode 100644 index 0000000000..0482a5c1b2 --- /dev/null +++ b/.changeset/link-prefetching.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": minor +--- + +Add prefetching support to `Link`/`NavLink` when using Remix SSR diff --git a/.changeset/moody-kids-count.md b/.changeset/moody-kids-count.md new file mode 100644 index 0000000000..bac5e026db --- /dev/null +++ b/.changeset/moody-kids-count.md @@ -0,0 +1,6 @@ +--- +"react-router-dom": major +"react-router": major +--- + +Remove `future.v7_throwAbortReason` from internalized `@remix-run/router` package diff --git a/.changeset/nice-pillows-hunt.md b/.changeset/nice-pillows-hunt.md new file mode 100644 index 0000000000..cebc13fa69 --- /dev/null +++ b/.changeset/nice-pillows-hunt.md @@ -0,0 +1,10 @@ +--- +"@react-router/express": major +"@react-router/serve": major +"@react-router/node": major +"@react-router/dev": major +"react-router-dom": major +"react-router": major +--- + +Add `exports` field to all packages diff --git a/.changeset/nine-ravens-work.md b/.changeset/nine-ravens-work.md new file mode 100644 index 0000000000..faa55cd7fd --- /dev/null +++ b/.changeset/nine-ravens-work.md @@ -0,0 +1,10 @@ +--- +"react-router-dom": major +"@react-router/express": major +"react-router": major +"@react-router/serve": major +"@react-router/node": major +"@react-router/dev": major +--- + +node package no longer re-exports from react-router diff --git a/.changeset/odd-beds-behave.md b/.changeset/odd-beds-behave.md new file mode 100644 index 0000000000..0a499191a9 --- /dev/null +++ b/.changeset/odd-beds-behave.md @@ -0,0 +1,5 @@ +--- +"react-router": major +--- + +renamed RemixContext to FrameworkContext diff --git a/.changeset/prerendering.md b/.changeset/prerendering.md new file mode 100644 index 0000000000..31a6883241 --- /dev/null +++ b/.changeset/prerendering.md @@ -0,0 +1,28 @@ +--- +"react-router": minor +--- + +- Add support for `prerender` config in the React Router vite plugin, to support existing SSG use-cases + - You can use the `prerender` config to pre-render your `.html` and `.data` files at build time and then serve them statically at runtime (either from a running server or a CDN) + - `prerender` can either be an array of string paths, or a function (sync or async) that returns an array of strings so that you can dynamically generate the paths by talking to your CMS, etc. + +```ts +export default defineConfig({ + plugins: [ + reactRouter({ + async prerender() { + let slugs = await fakeGetSlugsFromCms(); + // Prerender these paths into `.html` files at build time, and `.data` + // files if they have loaders + return ["/", "/about", ...slugs.map((slug) => `/product/${slug}`)]; + }, + }), + tsconfigPaths(), + ], +}); + +async function fakeGetSlugsFromCms() { + await new Promise((r) => setTimeout(r, 1000)); + return ["shirt", "hat"]; +} +``` diff --git a/.changeset/red-olives-compare.md b/.changeset/red-olives-compare.md new file mode 100644 index 0000000000..72c8383725 --- /dev/null +++ b/.changeset/red-olives-compare.md @@ -0,0 +1,6 @@ +--- +"react-router-dom": major +"react-router": major +--- + +updates the minimum React version to 18 diff --git a/.changeset/remix-scroll-restoration.md b/.changeset/remix-scroll-restoration.md new file mode 100644 index 0000000000..88e9a4eecc --- /dev/null +++ b/.changeset/remix-scroll-restoration.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": minor +--- + +Enhance `ScrollRestoration` so it can restore properly on an SSR'd document load diff --git a/.changeset/remove-build-end-public-path.md b/.changeset/remove-build-end-public-path.md new file mode 100644 index 0000000000..56b396c863 --- /dev/null +++ b/.changeset/remove-build-end-public-path.md @@ -0,0 +1,5 @@ +--- +"@react-router/dev": major +--- + +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. diff --git a/.changeset/remove-manifest-option.md b/.changeset/remove-manifest-option.md new file mode 100644 index 0000000000..9d471b7c5f --- /dev/null +++ b/.changeset/remove-manifest-option.md @@ -0,0 +1,28 @@ +--- +"@react-router/dev": major +--- + +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. + +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 { vitePlugin as reactRouter } from "@react-router/dev"; +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" + ); + }, + }), + ], +}; +``` diff --git a/.changeset/router-provider-hydration.md b/.changeset/router-provider-hydration.md new file mode 100644 index 0000000000..06b3a66ea7 --- /dev/null +++ b/.changeset/router-provider-hydration.md @@ -0,0 +1,5 @@ +--- +"react-router-dom": minor +--- + +Add built-in Remix-style hydration support to `RouterProvider`. When running from a Remix-SSR'd HTML payload with the proper `window` variables (`__remixContext`, `__remixManifest`, `__remixRouteModules`), you don't need to pass a `router` prop and `RouterProvider` will create the `router` for you internally. ([#11396](https://github.com/remix-run/react-router/pull/11396)) diff --git a/.changeset/stupid-days-heal.md b/.changeset/stupid-days-heal.md new file mode 100644 index 0000000000..fecd283a9c --- /dev/null +++ b/.changeset/stupid-days-heal.md @@ -0,0 +1,13 @@ +--- +"@react-router/dev": major +--- + +Update default `isbot` version to v5 and drop support for `isbot@3` + +- 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` diff --git a/.changeset/tall-mangos-add.md b/.changeset/tall-mangos-add.md new file mode 100644 index 0000000000..7a960eeeaa --- /dev/null +++ b/.changeset/tall-mangos-add.md @@ -0,0 +1,5 @@ +--- +"@react-router/dev": minor +--- + +Remove internal entry.server.spa.tsx implementation diff --git a/.changeset/tasty-penguins-live.md b/.changeset/tasty-penguins-live.md new file mode 100644 index 0000000000..4ffae59f61 --- /dev/null +++ b/.changeset/tasty-penguins-live.md @@ -0,0 +1,11 @@ +--- +"react-router-dom": major +"react-router": major +--- + +- Remove the `future.v7_partialHydration` flag + - 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 diff --git a/.changeset/thin-nails-turn.md b/.changeset/thin-nails-turn.md new file mode 100644 index 0000000000..cfe12d7c0b --- /dev/null +++ b/.changeset/thin-nails-turn.md @@ -0,0 +1,5 @@ +--- +"react-router": minor +--- + +Remove duplicate `RouterProvider` impliementations diff --git a/.changeset/tidy-clouds-lay.md b/.changeset/tidy-clouds-lay.md new file mode 100644 index 0000000000..304c25d6f4 --- /dev/null +++ b/.changeset/tidy-clouds-lay.md @@ -0,0 +1,5 @@ +--- +"react-router": major +--- + +Remove `v7_relativeSplatPath` future flag diff --git a/.changeset/tough-pens-brush.md b/.changeset/tough-pens-brush.md new file mode 100644 index 0000000000..2927281795 --- /dev/null +++ b/.changeset/tough-pens-brush.md @@ -0,0 +1,10 @@ +--- +"@react-router/serve": patch +--- + +Update `express.static` configurations to support prerendering + +- 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 diff --git a/.changeset/twenty-carrots-yawn.md b/.changeset/twenty-carrots-yawn.md new file mode 100644 index 0000000000..98c0abdedc --- /dev/null +++ b/.changeset/twenty-carrots-yawn.md @@ -0,0 +1,5 @@ +--- +"react-router": major +--- + +rename createRemixStub to createRoutesStub diff --git a/.changeset/two-countries-yell.md b/.changeset/two-countries-yell.md new file mode 100644 index 0000000000..de7d6d5098 --- /dev/null +++ b/.changeset/two-countries-yell.md @@ -0,0 +1,5 @@ +--- +"react-router": major +--- + +Remove `@remix-run/router` deprecated `detectErrorBoundary` option in favor of `mapRouteProperties` diff --git a/.changeset/vite-manifest-location.md b/.changeset/vite-manifest-location.md new file mode 100644 index 0000000000..31fdb73e0d --- /dev/null +++ b/.changeset/vite-manifest-location.md @@ -0,0 +1,7 @@ +--- +"@react-router/dev": major +--- + +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. + +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. diff --git a/.changeset/weak-otters-dance.md b/.changeset/weak-otters-dance.md new file mode 100644 index 0000000000..fbe5d5ad2b --- /dev/null +++ b/.changeset/weak-otters-dance.md @@ -0,0 +1,6 @@ +--- +"react-router-dom": major +"react-router": major +--- + +Remove `future.v7_fetcherPersist` flag 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/format.yml b/.github/workflows/format.yml index 7e6b410c39..de70984536 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -27,8 +27,8 @@ jobs: - 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..bdebc1d319 --- /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: "[18, 20]" + 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: "[18, 20]" + 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: "[18, 20]" + browser: '["webkit"]' diff --git a/.github/workflows/integration-pr-ubuntu.yml b/.github/workflows/integration-pr-ubuntu.yml new file mode 100644 index 0000000000..6223906475 --- /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: "[20]" + 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..84dce92295 --- /dev/null +++ b/.github/workflows/integration-pr-windows-macos.yml @@ -0,0 +1,41 @@ +name: PR (Full) + +# PRs touching react-router-dev will also run on Windows and OSX + +on: + pull_request: + paths: + - "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: "[20]" + 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: "[20]" + 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: "[20]" + browser: '["webkit"]' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 55daeb6aa6..acbd189f93 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,7 @@ jobs: id: changesets uses: changesets/action@v1 with: - version: pnpm run version + version: pnpm run changeset:version commit: "chore: Update version for release" title: "chore: Update version for release" publish: pnpm run release @@ -79,7 +79,7 @@ jobs: - 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..0a38fe9d0a --- /dev/null +++ b/.github/workflows/shared-build.yml @@ -0,0 +1,35 @@ +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@v3.0.0 + + - name: โŽ” Setup node + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "pnpm" + + - 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..f72ff8adc0 --- /dev/null +++ b/.github/workflows/shared-integration.yml @@ -0,0 +1,61 @@ +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: "[18, 20]"), + # 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@v3.0.0 + + - name: โŽ” Setup node ${{ matrix.node }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: "pnpm" + + - 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 }}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3fbb8d9cfa..b85bab5c89 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,8 +26,8 @@ jobs: fail-fast: false matrix: node: - - 16 - 18 + - 20 runs-on: ubuntu-latest @@ -41,9 +41,9 @@ jobs: - name: โŽ” Setup node uses: actions/setup-node@v4 with: + node-version: ${{ matrix.node }} cache: pnpm check-latest: true - node-version: ${{ matrix.node }} - name: Disable GitHub Actions Annotations run: | @@ -57,11 +57,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..83d226e94a 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,10 @@ node_modules/ /packages/*/dist/ /packages/*/LICENSE.md -# compat module copies -/packages/react-router-dom-v5-compat/react-router-dom - .eslintcache +.tmp /.env /NOTES.md + +# v7 reference docs +/public \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f534e13be8..4660e90c7b 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -74,10 +74,12 @@ Hotfix releases follow the same process as standard releases above, but the `rel ### Experimental releases -Experimental releases and hot-fixes do not need to be branched off of `dev`. Experimental releases can be branched from anywhere as they are not intended for general use. - -- Create a new branch for the release: `git checkout -b release-experimental` -- Make whatever changes you need and commit them: `git add . && git commit "experimental changes!"` -- Update version numbers and create a release tag: `pnpm run version:experimental` -- Push to GitHub: `git push origin --follow-tags` -- The CI workflow should automatically trigger from the experimental tag to publish the release to npm +Experimental releases use a [manually-triggered Github Actions workflow](./.github/workflows/release-experimental.yml) and can be built from any existing branch. to build and publish an experimental release: + +- Commit your changes to a branch +- Push the branch to github +- Go to the Github Actions UI for the [release-experimental.yml workflow](https://github.com/remix-run/react-router/actions/workflows/release-experimental.yml) +- Click the `Run workflow` dropdown +- Leave the `Use workflow from` dropdown as `main` +- Enter your feature branch in the `branch` input +- Click the `Run workflow` button 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/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..1959f16183 --- /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: accepted + +## 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"; + +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 +npm react-router vite: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 { defineRoutes } from "react-router/config"; + +export default defineRoutes([ + { + path: "*", + file: "src/catchall.tsx", + }, +]); +``` + +And then create the catchall route module and render your existing root App component within it. + +```tsx filename=src/catchall.tsx +import { defineRoute } from "react-router"; +import App from "./App"; + +export default defineRoute({ + 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 { defineRoutes } from "react-router/config"; + +export default defineRoutes([ + { + path: "/pages/:id", + file: "./containers/page.tsx", + }, + { + path: "*", + file: "src/catchall.tsx", + }, +]); +``` + +And then edit the route module to use `defineRoute`: + +```tsx filename=src/pages/about.tsx +import { defineRoute } from "react-router"; + +export default defineRoute({ + params: ["id"], + + async clientLoader({ params }) { + let page = await getPage(params.id); + return page; + }, + + Component({ data }) { + 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 { plugin as app } from "@react-router/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + app({ + ssr: true, + async prerender() { + return ["/", "/about", "/contact"]; + }, + }), + ], +}); +``` + +See [Deploying][deploying] for more information on deploying a server. + +[deploying]: ../start/deploying diff --git a/docs/upgrading/vite-router-provider.md b/docs/upgrading/vite-router-provider.md new file mode 100644 index 0000000000..a7d8a1f4bf --- /dev/null +++ b/docs/upgrading/vite-router-provider.md @@ -0,0 +1,251 @@ +--- +title: Adopting Vite (RouterProvider) +--- + +# 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. The `react` key accepts the same options as the React plugin. + +```diff filename=vite.config.ts +-import react from '@vitejs/plugin-react' ++import { plugin as app } from "@react-router/vite"; +import { defineConfig } from "vite"; + + +export default defineConfig({ + plugins: [ +- react(reactOptions) ++ app({ react: reactOptions }) + ], +}); +``` + +## 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 +npm react-router vite: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 { defineRoutes } from "react-router/config"; + +export default defineRoutes([ + { + path: "/pages/:id", + file: "./containers/page.tsx", + }, +]); +``` + +And then edit the route module to use `defineRoute`: + +```tsx filename=src/pages/about.tsx +import { defineRoute } from "react-router"; + +export default defineRoute({ + params: ["id"], + + async clientLoader({ params }) { + let page = await getPage(params.id); + return page; + }, + + Component({ data }) { + 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](./deploying) for more information. + +```ts filename=vite.config.ts +import { plugin as app } from "@react-router/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + app({ + ssr: true, + async prerender() { + return ["/", "/pages/about"]; + }, + }), + ], +}); +``` 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..967f079b12 --- /dev/null +++ b/integration/abort-signal-test.ts @@ -0,0 +1,66 @@ +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 { json } from "react-router"; + 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 json({ aborted: request.signal.aborted }); + } + + export function loader({ request }) { + return json({ 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/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..ccacf307fa --- /dev/null +++ b/integration/bug-report-test.ts @@ -0,0 +1,121 @@ +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 { json } from "react-router"; + import { useLoaderData, Link } from "react-router"; + + export function loader() { + return json("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..e99cc8f06c --- /dev/null +++ b/integration/catch-boundary-data-test.ts @@ -0,0 +1,244 @@ +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 { json } from "react-router"; + import { + Links, + Meta, + Outlet, + Scripts, + useLoaderData, + useMatches, + } from "react-router"; + + export const loader = () => json("${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..1b1cb6475e --- /dev/null +++ b/integration/catch-boundary-test.ts @@ -0,0 +1,367 @@ +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 { json } from "react-router"; + import { Links, Meta, Outlet, Scripts, useMatches } from "react-router"; + + export function loader() { + return json({ 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..41b6556849 --- /dev/null +++ b/integration/client-data-test.ts @@ -0,0 +1,1397 @@ +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 { json } from "react-router" + import { Outlet, useLoaderData } from "react-router" + export function loader() { + return json({ 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 { json } from "react-router" + import { Form, Outlet, useActionData, useLoaderData } from "react-router" + export function loader() { + return json({ message: 'Child Server Loader'}); + } + export function action() { + return json({ 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 { json } from "react-router"; + import { useLoaderData } from "react-router"; + export function loader() { + return json({ + 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 { json } from "react-router"; + import { useLoaderData, useRevalidator } from "react-router"; + let isFirstCall = true; + export async function loader({ serverLoader }) { + if (isFirstCall) { + isFirstCall = false + return json({ + message: "Child Server Loader Data (1)", + }); + } + return json({ + 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 json({ + message: "Child Server Loader Data (1)", + }); + } + return json({ + 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.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..e8464e2105 --- /dev/null +++ b/integration/defer-loader-test.ts @@ -0,0 +1,101 @@ +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` + export function loader({ response }) { + response.status = 301; + response.headers.set("Location", "/?redirected"); + return { food: "pizza" }; + } + 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..a71a3a38b3 --- /dev/null +++ b/integration/error-boundary-test.ts @@ -0,0 +1,1341 @@ +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; + let NO_ROOT_BOUNDARY_LOADER_RETURN = "/loader-no-return" as const; + let NO_ROOT_BOUNDARY_ACTION_RETURN = "/action-no-return" 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

+ Loader no return +
+ + +
+
+ ) + } + `, + + [`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

+
+ ) + } + `, + + [`app/routes${NO_ROOT_BOUNDARY_LOADER_RETURN}.jsx`]: js` + import { useLoaderData } from "react-router"; + + export async function loader() {} + + export default function () { + let data = useLoaderData(); + return ( +
+

{data}

+
+ ) + } + `, + + [`app/routes${NO_ROOT_BOUNDARY_ACTION_RETURN}.jsx`]: js` + import { useActionData } from "react-router"; + + export async function action() {} + + export default function () { + let data = useActionData(); + return ( +
+

{data}

+
+ ) + } + `, + }, + }); + 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("bubbles to internal boundary if loader doesn't return (document requests)", async () => { + let res = await fixture.requestDocument(NO_ROOT_BOUNDARY_LOADER_RETURN); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING); + }); + + test("bubbles to internal boundary if loader doesn't return", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickLink(NO_ROOT_BOUNDARY_LOADER_RETURN); + await page.waitForSelector(`text=${INTERNAL_ERROR_BOUNDARY_HEADING}`); + expect(await app.getHtml("h1")).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING); + }); + + test("bubbles to internal boundary if action doesn't return (document requests)", async () => { + let res = await fixture.requestDocument(NO_ROOT_BOUNDARY_ACTION_RETURN, { + method: "post", + }); + expect(res.status).toBe(500); + expect(await res.text()).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING); + }); + + test("bubbles to internal boundary if action doesn't return", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + await app.clickSubmitButton(NO_ROOT_BOUNDARY_ACTION_RETURN); + await page.waitForSelector(`text=${INTERNAL_ERROR_BOUNDARY_HEADING}`); + expect(await app.getHtml("h1")).toMatch(INTERNAL_ERROR_BOUNDARY_HEADING); + }); + }); +}); + +test.describe("loaderData in ErrorBoundary", () => { + let fixture: Fixture; + let appFixture: AppFixture; + let consoleErrors: string[]; + 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 { Outlet, useLoaderData, useMatches, useRouteError } from "react-router"; + + export function loader() { + return "PARENT"; + } + + export default function () { + return ( +
+

{useLoaderData()}

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

{useLoaderData()}

+

+ {useMatches().find(m => m.id === 'routes/parent').data} +

+

{error.message}

+ + ); + } + `, + + "app/routes/parent.child-with-boundary.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 ( + <> +

{useLoaderData()}

+

{error.message}

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

{useLoaderData()}

+
+ +
+ + ) + } + `, + }, + }); + + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test.beforeEach(({ page }) => { + oldConsoleError = console.error; + console.error = () => {}; + consoleErrors = []; + // Listen for all console events and handle errors + page.on("console", (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + } + }); + }); + + test.afterEach(() => { + console.error = oldConsoleError; + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runBoundaryTests(); + }); + + test.describe("with JavaScript", () => { + test.use({ javaScriptEnabled: true }); + runBoundaryTests(); + }); + + function runBoundaryTests() { + test("Prevents useLoaderData in self ErrorBoundary", async ({ + page, + javaScriptEnabled, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child-with-boundary"); + + expect(await app.getHtml("#parent-data")).toEqual( + '

PARENT

' + ); + expect(await app.getHtml("#child-data")).toEqual( + '

CHILD

' + ); + expect(consoleErrors).toEqual([]); + + await app.clickSubmitButton("/parent/child-with-boundary"); + await page.waitForSelector("#child-error"); + + expect(await app.getHtml("#child-error")).toEqual( + '

Broken!

' + ); + expect(await app.getHtml("#parent-data")).toEqual( + '

PARENT

' + ); + expect(await app.getHtml("#child-data")).toEqual( + '

' + ); + + // Only look for this message. Chromium browsers will also log the + // network error but firefox does not + // "Failed to load resource: the server responded with a status of 500 (Internal Server Error)", + let msg = + "You cannot `useLoaderData` in an errorElement (routeId: routes/parent.child-with-boundary)"; + if (javaScriptEnabled) { + expect(consoleErrors.filter((m) => m === msg)).toEqual([msg]); + } else { + // We don't get the useLoaderData message in the client when JS is disabled + expect(consoleErrors.filter((m) => m === msg)).toEqual([]); + } + }); + + test("Prevents useLoaderData in bubbled ErrorBoundary", async ({ + page, + javaScriptEnabled, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child-without-boundary"); + + expect(await app.getHtml("#parent-data")).toEqual( + '

PARENT

' + ); + expect(await app.getHtml("#child-data")).toEqual( + '

CHILD

' + ); + expect(consoleErrors).toEqual([]); + + await app.clickSubmitButton("/parent/child-without-boundary"); + await page.waitForSelector("#parent-error"); + + expect(await app.getHtml("#parent-error")).toEqual( + '

Broken!

' + ); + if (javaScriptEnabled) { + // This data remains in single fetch with JS because we don't revalidate + // due to the 500 action response + expect(await app.getHtml("#parent-matches-data")).toEqual( + '

PARENT

' + ); + } else { + // But without JS document requests call all loaders up to the + // boundary route so parent's data clears out + expect(await app.getHtml("#parent-matches-data")).toEqual( + '

' + ); + } + expect(await app.getHtml("#parent-data")).toEqual( + '

' + ); + + // Only look for this message. Chromium browsers will also log the + // network error but firefox does not + // "Failed to load resource: the server responded with a status of 500 (Internal Server Error)", + let msg = + "You cannot `useLoaderData` in an errorElement (routeId: routes/parent)"; + if (javaScriptEnabled) { + expect(consoleErrors.filter((m) => m === msg)).toEqual([msg]); + } else { + // We don't get the useLoaderData message in the client when JS is disabled + expect(consoleErrors.filter((m) => m === msg)).toEqual([]); + } + }); + } +}); + +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..0bce843770 --- /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, + "#child-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..bc5f0f64af --- /dev/null +++ b/integration/error-data-request-test.ts @@ -0,0 +1,180 @@ +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` + import { json } from "react-router"; + + export async function loader() { + return json({ 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` + import { json } from "react-router"; + + export async function action() { + return json({ 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..8e5bd67b88 --- /dev/null +++ b/integration/fetcher-layout-test.ts @@ -0,0 +1,282 @@ +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 { json } from "react-router"; + import { Outlet, useFetcher, useFormAction } from "react-router"; + + export let action = ({ params }) => json("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 { json } from "react-router"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router"; + + export let loader = ({ params }) => json("index data"); + + export let action = ({ params }) => json("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 { json } from "react-router"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router"; + + export let loader = ({ params }) => json(params.param); + + export let action = ({ params }) => json("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 { json } from "react-router"; + import { Outlet, useFetcher, useFormAction } from "react-router"; + + export let loader = () => json("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 { json } from "react-router"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router"; + + export let loader = ({ params }) => json("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 { json } from "react-router"; + import { + useFetcher, + useFormAction, + useLoaderData, + } from "react-router"; + + export let loader = ({ params }) => json(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..b91ef8d8e1 --- /dev/null +++ b/integration/fetcher-test.ts @@ -0,0 +1,529 @@ +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 { json } from "react-router"; + 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 json({ 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 json({ 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/file-uploads-test.ts b/integration/file-uploads-test.ts new file mode 100644 index 0000000000..acbe544be7 --- /dev/null +++ b/integration/file-uploads-test.ts @@ -0,0 +1,149 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as url from "node:url"; +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 } from "./helpers/playwright-fixture.js"; + +test.describe("file-uploads", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/fileUploadHandler.ts": js` + import * as path from "node:path"; + import * as url from "node:url"; + import { + unstable_composeUploadHandlers as composeUploadHandlers, + unstable_createMemoryUploadHandler as createMemoryUploadHandler, + } from "react-router"; + import { + unstable_createFileUploadHandler as createFileUploadHandler, + } from "@react-router/node"; + + const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); + export let uploadHandler = composeUploadHandlers( + createFileUploadHandler({ + directory: path.resolve(__dirname, "..", "..", "uploads"), + maxPartSize: 10_000, // 10kb + // you probably want to avoid conflicts in production + // do not set to false or passthrough filename in real + // applications. + avoidFileConflicts: false, + file: ({ filename }) => filename + }), + createMemoryUploadHandler(), + ); + `, + "app/routes/file-upload.tsx": js` + import { + unstable_parseMultipartFormData as parseMultipartFormData, + } from "react-router"; + import { Form, useActionData } from "react-router"; + import { uploadHandler } from "~/fileUploadHandler"; + + export let action = async ({ request }) => { + try { + let formData = await parseMultipartFormData(request, uploadHandler); + + if (formData.get("test") !== "hidden") { + return { errorMessage: "hidden field not in form data" }; + } + + let file = formData.get("file"); + if (typeof file === "string" || !file) { + return { errorMessage: "invalid file type" }; + } + + return { name: file.name, size: file.size }; + } catch (error) { + return { errorMessage: error.message }; + } + }; + + export default function Upload() { + let actionData = useActionData(); + return ( + <> +
+ + + + +
+ {actionData ?
{JSON.stringify(actionData, null, 2)}
: null} + + ); + } + `, + }, + }); + + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => { + appFixture.close(); + }); + + test("handles files under upload size limit", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let uploadFile = path.join( + fixture.projectDir, + "toUpload", + "underLimit.txt" + ); + let uploadData = Array(1_000).fill("a").join(""); // 1kb + await fs + .mkdir(path.dirname(uploadFile), { recursive: true }) + .catch(() => {}); + await fs.writeFile(uploadFile, uploadData, "utf8"); + + await app.goto("/file-upload"); + await app.uploadFile("#file", uploadFile); + await app.clickSubmitButton("/file-upload"); + await page.waitForSelector("pre"); + expect(await app.getHtml("pre")).toBe(`
+{
+  "name": "underLimit.txt",
+  "size": 1000
+}
`); + + let written = await fs.readFile( + url.pathToFileURL( + path.join(fixture.projectDir, "uploads/underLimit.txt") + ), + "utf8" + ); + expect(written).toBe(uploadData); + }); + + test("rejects files over upload size limit", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + let uploadFile = path.join(fixture.projectDir, "toUpload", "overLimit.txt"); + let uploadData = Array(10_001).fill("a").join(""); // 10.000001KB + await fs + .mkdir(path.dirname(uploadFile), { recursive: true }) + .catch(() => {}); + await fs.writeFile(uploadFile, uploadData, "utf8"); + + await app.goto("/file-upload"); + await app.uploadFile("#file", uploadFile); + await app.clickSubmitButton("/file-upload"); + await page.waitForSelector("pre"); + expect(await app.getHtml("pre")).toBe(`
+{
+  "errorMessage": "Field \\"file\\" exceeded upload size of 10000 bytes."
+}
`); + }); +}); diff --git a/integration/flat-routes-test.ts b/integration/flat-routes-test.ts new file mode 100644 index 0000000000..4e4265269e --- /dev/null +++ b/integration/flat-routes-test.ts @@ -0,0 +1,423 @@ +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("flat routes", () => { + let IGNORED_ROUTE = "ignore-me-pls"; + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "vite.config.js": ` + import { defineConfig } from "vite"; + import { vitePlugin as reactRouter } from "@react-router/dev"; + + export default defineConfig({ + plugins: [reactRouter({ + ignoredRouteFiles: [${JSON.stringify(`**/${IGNORED_ROUTE}.*`)}], + })], + }); + `, + "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/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 (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/fog-of-war-test.ts b/integration/fog-of-war-test.ts new file mode 100644 index 0000000000..7ec9058c35 --- /dev/null +++ b/integration/fog-of-war-test.ts @@ -0,0 +1,761 @@ +import type { Request as PlaywrightRequest } from "@playwright/test"; +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"; + 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).__remixManifest.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).__remixManifest.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).__remixManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickLink("/a"); + await page.waitForSelector("#a"); + + await page.waitForFunction( + () => (window as any).__remixManifest.routes["routes/a.b"] + ); + + expect( + await page.evaluate(() => + Object.keys((window as any).__remixManifest.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).__remixManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickElement("button"); + await page.waitForFunction( + () => (window as any).__remixManifest.routes["routes/a.b"] + ); + + expect( + await page.evaluate(() => + Object.keys((window as any).__remixManifest.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).__remixManifest.routes) + ) + ).toEqual(["root", "routes/_index"]); + + await app.clickElement("button"); + await page.waitForFunction( + () => (window as any).__remixManifest.routes["routes/a"] + ); + + expect( + await page.evaluate(() => + Object.keys((window as any).__remixManifest.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).__remixManifest.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).__remixManifest.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).__remixManifest.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).__remixManifest.routes["routes/a"] + ); + expect( + await page.evaluate(() => + Object.keys((window as any).__remixManifest.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).__remixManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + + await app.clickLink("/a"); + await page.waitForSelector("form"); + + await page.waitForFunction( + () => (window as any).__remixManifest.routes["routes/a.b"] + ); + + expect( + await page.evaluate(() => + Object.keys((window as any).__remixManifest.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).__remixManifest.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).__remixManifest.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).__remixManifest.routes) + ) + ).toEqual([ + "root", + "routes/parent", + "routes/parent.child", + "routes/_index", + "routes/parent._a", + "routes/parent._a._b", + "routes/parent._a._b._index", + ]); + + // 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\?version=[a-z0-9]{8}&p=%2Fparent%2Fchild2/ + ), + ]); + }); + + 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: PlaywrightRequest[] = []; + page.on("request", (req) => { + if (req.url().includes("/__manifest")) { + manifestRequests.push(req); + } + }); + + 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).__remixManifest.routes) + ) + ).toEqual(["root", "routes/_index", "routes/a"]); + }); +}); diff --git a/integration/form-data-test.ts b/integration/form-data-test.ts new file mode 100644 index 0000000000..988eaef1d0 --- /dev/null +++ b/integration/form-data-test.ts @@ -0,0 +1,60 @@ +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` + import { json } from "react-router"; + + export async function action({ request }) { + try { + await request.formData() + } catch { + return json("no pizza"); + } + return json("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..7b6b86b689 --- /dev/null +++ b/integration/form-test.ts @@ -0,0 +1,1140 @@ +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 json({ 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 { json } from "react-router"; + import { Form, useActionData } from "react-router"; + + export async function action({ request }) { + let formData = await request.formData(); + return json(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"; + import { json } from "react-router"; + + export function action({ request }) { + return json(request.method) + } + + export function loader({ request }) { + return json(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 { json, Form, Outlet, useActionData } from "react-router" + + export async function action({ request }) { + return json({ 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/headers-test.ts b/integration/headers-test.ts new file mode 100644 index 0000000000..d51dc69d06 --- /dev/null +++ b/integration/headers-test.ts @@ -0,0 +1,420 @@ +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 { json } from "react-router"; + import { Links, Meta, Outlet, Scripts } from "react-router"; + + export const loader = () => json({}); + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/_index.tsx": js` + import { json } from "react-router"; + + export function loader() { + return json(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 { json } from "react-router"; + + export function action() { + return json(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 { json, Outlet } from "react-router"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has("parent-throw")) { + throw json(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 { json } from "react-router"; + + export function loader({ request }) { + if (new URL(request.url).searchParams.has("throw")) { + throw json(null, { headers: { "Set-Cookie": "thrown-cookie=true" } }); + } + return json(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 { json } from "react-router"; + + export function loader() { + return json(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..0d1c253f6f --- /dev/null +++ b/integration/helpers/create-fixture.ts @@ -0,0 +1,389 @@ +import type { Writable } 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 { installGlobals } from "@react-router/node"; + +import { viteConfig } 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; + 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) { + installGlobals(); + + 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" + ); + }; + + let isSpaMode = init.spaMode; + + if (isSpaMode) { + let requestDocument = () => { + let html = fse.readFileSync( + path.join(projectDir, "build/client/index.html") + ); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); + }; + + return { + projectDir, + build: null, + isSpaMode, + requestDocument, + 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, + }; + } + + 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); + let decoded = await decodeViaTurboStream(response.body!, global); + return { + status: response.status, + statusText: response.statusText, + headers: response.headers, + data: decoded.value, + }; + }; + + 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, + 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"), + next + ) + ); + let server = app.listen(port); + accept({ stop: server.close.bind(server), port }); + }); + } + + 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 = "node-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 reactRouterDev = path.join( + // projectDir, + // "node_modules/@react-router/dev/dist/cli.js" + // ); + // await fse.chmod(reactRouterDev, 0o755); + // await fse.ensureSymlink( + // reactRouterDev, + // path.join(projectDir, "node_modules/.bin/rr") + // ); + // + // let reactRouterServe = path.join( + // projectDir, + // "node_modules/@react-router/serve/dist/cli.js" + // ); + // await fse.chmod(reactRouterServe, 0o755); + // await fse.ensureSymlink( + // reactRouterServe, + // path.join(projectDir, "node_modules/.bin/react-router-serve") + // ); + + let hasViteConfig = Object.keys(init.files ?? {}).some((filename) => + filename.startsWith("vite.config.") + ); + + let { spaMode } = init; + + await writeTestFiles( + { + ...(hasViteConfig + ? {} + : { + "vite.config.js": await viteConfig.basic({ + port, + 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.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/killtree.ts b/integration/helpers/killtree.ts new file mode 100644 index 0000000000..01e412687f --- /dev/null +++ b/integration/helpers/killtree.ts @@ -0,0 +1,57 @@ +import execa from "execa"; +import pidtree from "pidtree"; + +const isWindows = process.platform === "win32"; + +const kill = async (pid: number) => { + if (!isAlive(pid)) return; + if (isWindows) { + await execa("taskkill", ["/F", "/PID", pid.toString()]).catch((error) => { + // taskkill 128 -> the process is already dead + if (error.exitCode === 128) return; + if (/There is no running instance of the task./.test(error.message)) + return; + console.warn(error.message); + }); + return; + } + await execa("kill", ["-9", pid.toString()]).catch((error) => { + // process is already dead + if (/No such process/.test(error.message)) return; + console.warn(error.message); + }); +}; + +const isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return false; + } +}; + +export const killtree = async (pid: number) => { + let descendants = await pidtree(pid).catch(() => undefined); + if (descendants === undefined) return; + let pids = [pid, ...descendants]; + + await Promise.all(pids.map(kill)); + + return new Promise((resolve, reject) => { + let check = setInterval(() => { + pids = pids.filter(isAlive); + if (pids.length === 0) { + clearInterval(check); + resolve(); + } + }, 50); + + setTimeout(() => { + clearInterval(check); + reject( + new Error("Timeout: Processes did not exit within the specified time.") + ); + }, 2000); + }); +}; diff --git a/integration/helpers/node-template/.gitignore b/integration/helpers/node-template/.gitignore new file mode 100644 index 0000000000..3f7bf98da3 --- /dev/null +++ b/integration/helpers/node-template/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/integration/helpers/node-template/app/root.tsx b/integration/helpers/node-template/app/root.tsx new file mode 100644 index 0000000000..b36392b4dd --- /dev/null +++ b/integration/helpers/node-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/node-template/app/routes/_index.tsx b/integration/helpers/node-template/app/routes/_index.tsx new file mode 100644 index 0000000000..d35260cb00 --- /dev/null +++ b/integration/helpers/node-template/app/routes/_index.tsx @@ -0,0 +1,41 @@ +import type { MetaFunction } from "react-router"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export default function Index() { + return ( + + ); +} diff --git a/integration/helpers/node-template/package.json b/integration/helpers/node-template/package.json new file mode 100644 index 0000000000..2c03b0658f --- /dev/null +++ b/integration/helpers/node-template/package.json @@ -0,0 +1,37 @@ +{ + "name": "integration-node-template", + "version": "0.0.0", + "private": true, + "sideEffects": false, + "type": "module", + "scripts": { + "build": "react-router build", + "dev": "react-router dev", + "start": "react-router-serve ./build/server/index.js", + "typecheck": "tsc" + }, + "dependencies": { + "@react-router/express": "workspace:*", + "@react-router/node": "workspace:*", + "@react-router/serve": "workspace:*", + "express": "^4.19.2", + "isbot": "^5.1.11", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router": "workspace:*" + }, + "devDependencies": { + "@react-router/dev": "workspace:*", + "@types/react": "^18.2.20", + "@types/react-dom": "^18.2.7", + "@vanilla-extract/css": "^1.10.0", + "@vanilla-extract/vite-plugin": "^3.9.2", + "getos": "^3.2.1", + "postcss-import": "^15.1.0", + "tailwindcss": "^3.3.0", + "typescript": "^5.1.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/integration/helpers/node-template/public/favicon.ico b/integration/helpers/node-template/public/favicon.ico new file mode 100644 index 0000000000..8830cf6821 Binary files /dev/null and b/integration/helpers/node-template/public/favicon.ico differ diff --git a/integration/helpers/node-template/remix.env.d.ts b/integration/helpers/node-template/remix.env.d.ts new file mode 100644 index 0000000000..fc1bbb27ff --- /dev/null +++ b/integration/helpers/node-template/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/integration/helpers/node-template/tsconfig.json b/integration/helpers/node-template/tsconfig.json new file mode 100644 index 0000000000..7edc18b084 --- /dev/null +++ b/integration/helpers/node-template/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "target": "ES2022", + "strict": true, + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + + // Remix takes care of building everything in `remix build`. + "noEmit": true + } +} diff --git a/integration/helpers/playwright-fixture.ts b/integration/helpers/playwright-fixture.ts new file mode 100644 index 0000000000..0e8dbf6094 --- /dev/null +++ b/integration/helpers/playwright-fixture.ts @@ -0,0 +1,346 @@ +import cp from "node:child_process"; +import type { Page, Response, Request } from "@playwright/test"; +import { test } from "@playwright/test"; +import cheerio from "cheerio"; +import prettier from "prettier"; + +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): 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/_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..c92f9b8834 --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/package.json @@ -0,0 +1,33 @@ +{ + "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", + "tsc": "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:*", + "@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": ">=18.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..effa954b27 --- /dev/null +++ b/integration/helpers/vite-cloudflare-template/vite.config.ts @@ -0,0 +1,9 @@ +import { + vitePlugin as reactRouter, + cloudflareDevProxyVitePlugin as reactRouterCloudflareDevProxy, +} from "@react-router/dev"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [reactRouterCloudflareDevProxy(), reactRouter()], +}); diff --git a/integration/helpers/vite-template/.gitignore b/integration/helpers/vite-template/.gitignore new file mode 100644 index 0000000000..80ec311f4f --- /dev/null +++ b/integration/helpers/vite-template/.gitignore @@ -0,0 +1,5 @@ +node_modules + +/.cache +/build +.env 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/_index.tsx b/integration/helpers/vite-template/app/routes/_index.tsx new file mode 100644 index 0000000000..d35260cb00 --- /dev/null +++ b/integration/helpers/vite-template/app/routes/_index.tsx @@ -0,0 +1,41 @@ +import type { MetaFunction } from "react-router"; + +export const meta: MetaFunction = () => { + return [ + { title: "New Remix App" }, + { name: "description", content: "Welcome to Remix!" }, + ]; +}; + +export default function Index() { + return ( + + ); +} 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..475ed8e41e --- /dev/null +++ b/integration/helpers/vite-template/package.json @@ -0,0 +1,39 @@ +{ + "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": "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:*", + "@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": ">=18.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..66c06d274b --- /dev/null +++ b/integration/helpers/vite-template/tsconfig.json @@ -0,0 +1,22 @@ +{ + "include": ["env.d.ts", "**/*.ts", "**/*.tsx"], + "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 + } +} diff --git a/integration/helpers/vite-template/vite.config.ts b/integration/helpers/vite-template/vite.config.ts new file mode 100644 index 0000000000..b5f64dabc0 --- /dev/null +++ b/integration/helpers/vite-template/vite.config.ts @@ -0,0 +1,7 @@ +import { vitePlugin as reactRouter } from "@react-router/dev"; +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..fa8b02d8b3 --- /dev/null +++ b/integration/helpers/vite.ts @@ -0,0 +1,404 @@ +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 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 { VitePluginConfig } from "@react-router/dev"; + +const require = createRequire(import.meta.url); + +const reactRouterBin = "node_modules/@react-router/dev/dist/cli.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 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[]; + spaMode?: boolean; + }) => { + let pluginOptions: VitePluginConfig = { + ssr: !args.spaMode, + }; + + return dedent` + import { vitePlugin as reactRouter } from "@react-router/dev"; + import { envOnlyMacros } from "vite-env-only"; + import tsconfigPaths from "vite-tsconfig-paths"; + + export default { + ${await viteConfig.server(args)} + plugins: [ + reactRouter(${JSON.stringify(pluginOptions)}), + envOnlyMacros(), + tsconfigPaths() + ], + }; + `; + }, +}; + +export const EXPRESS_SERVER = (args: { + port: number; + loadContext?: Record; +}) => + String.raw` + import { createRequestHandler } from "@react-router/express"; + import { installGlobals } from "@react-router/node"; + import express from "express"; + + installGlobals(); + + 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 }); + 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; basename?: string } +) { + let devStdout = bufferize(proc.stdout); + let devStderr = bufferize(proc.stderr); + + await waitOn({ + resources: [`http://localhost:${args.port}${args.basename ?? "/"}`], + timeout: 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..ac7ec35bf1 --- /dev/null +++ b/integration/hook-useSubmit-test.ts @@ -0,0 +1,136 @@ +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 { json } from "react-router"; + import { useActionData, useSubmit } from "react-router"; + + export async function action({ request }) { + let contentType = request.headers.get('Content-Type'); + if (contentType.includes('application/json')) { + return json({ value: await request.json() }); + } + if (contentType.includes('text/plain')) { + return json({ value: await request.text() }); + } + let fd = await request.formData(); + return json({ 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..6ddd827c63 --- /dev/null +++ b/integration/link-test.ts @@ -0,0 +1,628 @@ +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 { json } from "react-router"; + import { 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() { + let data = { + users: [ + { id: "ryanflorence", name: "Ryan Florence" }, + { id: "mjackson", name: "Michael Jackson" }, + ], + }; + return json(data, { + 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 { json, 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 json(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 ( +
    +

    Public Gists

    + +
    + ); + } + `, + + "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..f023d8c065 --- /dev/null +++ b/packages/react-router/__tests__/server-runtime/responses-test.ts @@ -0,0 +1,101 @@ +/** + * @jest-environment node + */ + +import type { TypedResponse } from "../../lib/server-runtime/responses"; +import { json, redirect } from "../../lib/server-runtime/responses"; +import { isEqual } from "./utils"; + +describe("json", () => { + it("sets the Content-Type header", () => { + let response = json({}); + expect(response.headers.get("Content-Type")).toEqual( + "application/json; charset=utf-8" + ); + }); + + it("preserves existing headers, including Content-Type", () => { + let 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 = json({ hello: "remix" }); + expect(await response.json()).toEqual({ hello: "remix" }); + }); + + it("accepts status as a second parameter", () => { + let response = json({}, 201); + expect(response.status).toEqual(201); + }); + + it("infers input type", async () => { + let response = json({ hello: "remix" }); + isEqual>(true); + let result = await response.json(); + expect(result).toMatchObject({ hello: "remix" }); + }); + + // eslint-disable-next-line jest/expect-expect + it("disallows unmatched typed responses", async () => { + let response = json("hello"); + isEqual, typeof response>(false); + }); + + it("disallows unserializables", () => { + // @ts-expect-error + expect(() => json(124n)).toThrow(); + // @ts-expect-error + expect(() => 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..13ccb3f481 --- /dev/null +++ b/packages/react-router/__tests__/server-runtime/server-test.ts @@ -0,0 +1,2094 @@ +/** + * @jest-environment node + */ + +import type { StaticHandlerContext } from "react-router"; +import { json } 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 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 json("resource"); + }); + let subResourceLoader = jest.fn(() => { + return 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 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 json("resource"); + }); + let subResourceAction = jest.fn(() => { + return 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 (v3_throwAbortReason)", 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", + }, + }, + { + future: { + v3_throwAbortReason: true, + }, + 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, + }, + }, + { + future: { + v3_throwAbortReason: 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: {}, + }, + }, + { + future: { + v3_throwAbortReason: true, + }, + 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..32e8027006 --- /dev/null +++ b/packages/react-router/__tests__/server-runtime/sessions-test.ts @@ -0,0 +1,359 @@ +import { createCookieFactory } from "../../lib/server-runtime/cookies"; +import type { + SignFunction, + UnsignFunction, +} from "../../lib/server-runtime/crypto"; +import { + createSession, + createSessionStorageFactory, + isSession, +} from "../../lib/server-runtime/sessions"; +import { createCookieSessionStorageFactory } from "../../lib/server-runtime/sessions/cookieStorage"; +import { createMemorySessionStorageFactory } from "../../lib/server-runtime/sessions/memoryStorage"; + +function getCookieFromSetCookie(setCookie: string): string { + return setCookie.split(/;\s*/)[0]; +} + +const sign: SignFunction = async (value, secret) => { + return JSON.stringify({ value, secret }); +}; +const unsign: UnsignFunction = async (signed, secret) => { + try { + let unsigned = JSON.parse(signed); + if (unsigned.secret !== secret) return false; + return unsigned.value; + } catch (e: unknown) { + return false; + } +}; +const createCookie = createCookieFactory({ sign, unsign }); +const createCookieSessionStorage = + createCookieSessionStorageFactory(createCookie); +const createSessionStorage = createSessionStorageFactory(createCookie); +const createMemorySessionStorage = + createMemorySessionStorageFactory(createSessionStorage); + +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); + }); + }); +}); + +describe("Custom cookie-backed session storage", () => { + let memoryBacking = {}; + let createCookieBackedSessionStorage = + createSessionStorageFactory(createCookie); + let implementation = { + createData(data) { + let id = Math.random().toString(36).substring(2, 10); + memoryBacking[id] = data; + return Promise.resolve(id); + }, + readData(id) { + return Promise.resolve(memoryBacking[id] || null); + }, + updateData(id, data) { + memoryBacking[id] = data; + return Promise.resolve(); + }, + deleteData(id) { + memoryBacking[id] = null; + return Promise.resolve(memoryBacking[id]); + }, + }; + + it("persists session data across requests", async () => { + let { getSession, commitSession } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { secrets: ["test"] }), + }); + 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 } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { secrets: ["test"] }), + }); + 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 } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { secrets: ["test"] }), + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + expect(setCookie).toContain("Path=/"); + }); + + it("destroys sessions using a past date", async () => { + let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let { getSession, destroySession } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { secrets: ["test"] }), + }); + let session = await getSession(); + let setCookie = await destroySession(session); + expect(setCookie).toMatchInlineSnapshot( + `"test=; 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 } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { + maxAge: 60 * 60, // 1 hour + secrets: ["test"], + }), + }); + let session = await getSession(); + let setCookie = await destroySession(session); + expect(setCookie).toMatchInlineSnapshot( + `"test=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` + ); + spy.mockRestore(); + }); +}); + +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..f2b70a364a 100644 --- a/packages/react-router/__tests__/setup.ts +++ b/packages/react-router/__tests__/setup.ts @@ -1,19 +1,47 @@ -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.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 { TextDecoder, TextEncoder } = require("node:util"); + globalThis.TextDecoder = TextDecoder; + globalThis.TextEncoder = TextEncoder; + + const { ReadableStream, WritableStream } = require("node:stream/web"); + globalThis.ReadableStream = ReadableStream; + globalThis.WritableStream = WritableStream; + + 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.AbortController) { + const { AbortController } = require("abort-controller"); + globalThis.AbortController = AbortController; +} + +if (!globalThis.TextEncoder || !globalThis.TextDecoder) { + const { TextDecoder, TextEncoder } = require("node:util"); + globalThis.TextEncoder = TextEncoder; + globalThis.TextDecoder = TextDecoder; +} + +if (!globalThis.TextEncoderStream) { + const { TextEncoderStream } = require("node:stream/web"); + globalThis.TextEncoderStream = TextEncoderStream; +} + +if (!globalThis.TransformStream) { + const { TransformStream } = require("node:stream/web"); + globalThis.TransformStream = TransformStream; +} + +if (!globalThis.File) { + const { File } = require("undici"); + globalThis.File = File; } 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..e21dca7749 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"; +import { joinPaths } from "../../lib/router"; 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/index.ts b/packages/react-router/index.ts index 5f417b83dd..5145e30c15 100644 --- a/packages/react-router/index.ts +++ b/packages/react-router/index.ts @@ -1,4 +1,3 @@ -import * as React from "react"; import type { ActionFunction, ActionFunctionArgs, @@ -9,14 +8,13 @@ import type { unstable_DataStrategyMatch, ErrorResponse, Fetcher, - HydrationState, - InitialEntry, JsonFunction, LazyRouteFunction, LoaderFunction, LoaderFunctionArgs, Location, Navigation, + NavigationStates, ParamParseKey, Params, Path, @@ -25,22 +23,15 @@ import type { PathPattern, RedirectFunction, RelativeRoutingType, - Router as RemixRouter, - FutureConfig as RouterFutureConfig, ShouldRevalidateFunction, ShouldRevalidateFunctionArgs, To, UIMatch, unstable_HandlerResult, - unstable_AgnosticPatchRoutesOnMissFunction, -} from "@remix-run/router"; +} from "./lib/router"; import { - AbortedDeferredError, Action as NavigationType, - createMemoryHistory, createPath, - createRouter, - defer, generatePath, isRouteErrorResponse, json, @@ -50,12 +41,11 @@ import { redirect, redirectDocument, resolvePath, - UNSAFE_warning as warning, -} from "@remix-run/router"; + UNSAFE_ErrorResponseImpl, +} from "./lib/router"; import type { AwaitProps, - FutureConfig, IndexRouteProps, LayoutRouteProps, MemoryRouterProps, @@ -64,8 +54,8 @@ import type { PathRouteProps, RouteProps, RouterProps, - RouterProviderProps, RoutesProps, + unstable_PatchRoutesOnMissFunction, } from "./lib/components"; import { Await, @@ -74,10 +64,11 @@ import { Outlet, Route, Router, - RouterProvider, Routes, createRoutesFromChildren, renderMatches, + createMemoryRouter, + mapRouteProperties, } from "./lib/components"; import type { DataRouteMatch, @@ -140,7 +131,6 @@ export type { unstable_DataStrategyMatch, ErrorResponse, Fetcher, - FutureConfig, Hash, IndexRouteObject, IndexRouteProps, @@ -155,6 +145,7 @@ export type { NavigateOptions, NavigateProps, Navigation, + NavigationStates, Navigator, NonIndexRouteObject, OutletProps, @@ -172,7 +163,6 @@ export type { RouteObject, RouteProps, RouterProps, - RouterProviderProps, RoutesProps, Search, ShouldRevalidateFunction, @@ -182,9 +172,9 @@ export type { Blocker, BlockerFunction, unstable_HandlerResult, + unstable_PatchRoutesOnMissFunction, }; export { - AbortedDeferredError, Await, MemoryRouter, Navigate, @@ -192,12 +182,11 @@ export { Outlet, Route, Router, - RouterProvider, Routes, + createMemoryRouter, createPath, createRoutesFromChildren, createRoutesFromChildren as createRoutesFromElements, - defer, generatePath, isRouteErrorResponse, json, @@ -231,96 +220,240 @@ export { useRoutes, }; -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, - }; +// Expose old @remix-run/router API +export type { + // TODO: Stop exporting agnostic stuff in v7? + AgnosticDataIndexRouteObject, + AgnosticDataNonIndexRouteObject, + AgnosticDataRouteMatch, + AgnosticDataRouteObject, + AgnosticIndexRouteObject, + AgnosticNonIndexRouteObject, + AgnosticRouteMatch, + AgnosticRouteObject, + HydrationState, + InitialEntry, + LowerCaseFormMethod, + StaticHandler, + TrackedPromise, + FetcherStates, + UpperCaseFormMethod, +} from "./lib/router"; +export { + getStaticContextFromError, + stripBasename, + UNSAFE_convertRoutesToDataRoutes, +} from "./lib/router"; + +// Expose old RR DOM API +export type { + FormEncType, + FormMethod, + GetScrollRestorationKeyFunction, + StaticHandlerContext, + Submission, +} from "./lib/router"; + +export type { + BrowserRouterProps, + HashRouterProps, + HistoryRouterProps, + LinkProps, + NavLinkProps, + NavLinkRenderProps, + FetcherFormProps, + FormProps, + ScrollRestorationProps, + SetURLSearchParams, + SubmitFunction, + FetcherSubmitFunction, + FetcherWithComponents, + RouterProviderProps, +} from "./lib/dom/lib"; +export { + createBrowserRouter, + createHashRouter, + BrowserRouter, + HashRouter, + Link, + // TODO: Collapse RouterProvider implementations + // RouterProvider, + UNSAFE_ViewTransitionContext, + UNSAFE_FetchersContext, + unstable_HistoryRouter, + NavLink, + Form, + RouterProvider, + ScrollRestoration, + useLinkClickHandler, + useSearchParams, + useSubmit, + useFormAction, + useFetcher, + useFetchers, + UNSAFE_useScrollRestoration, + useBeforeUnload, + unstable_usePrompt, + unstable_useViewTransitionState, +} from "./lib/dom/lib"; +export type { + 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 { HydratedRouter } from "./lib/dom/ssr/browser"; +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 { + HtmlLinkDescriptor, + LinkDescriptor, + PrefetchPageDescriptor, +} from "./lib/dom/ssr/links"; +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"; +export { + defineRoute, + type Match, + type MetaMatch, +} from "./lib/router/define-route"; + +// Expose old @remix-run/server-runtime API, minus duplicate APIs +export { createCookieFactory, isCookie } from "./lib/server-runtime/cookies"; +export { + composeUploadHandlers as unstable_composeUploadHandlers, + parseMultipartFormData as unstable_parseMultipartFormData, +} from "./lib/server-runtime/formData"; +// TODO: (v7) Clean up code paths for these exports +// export { +// json, +// redirect, +// redirectDocument, +// } from "./lib/server-runtime/responses"; +export { createRequestHandler } from "./lib/server-runtime/server"; +export { + createSession, + createSessionStorageFactory, + isSession, +} from "./lib/server-runtime/sessions"; +export { createCookieSessionStorageFactory } from "./lib/server-runtime/sessions/cookieStorage"; +export { createMemorySessionStorageFactory } from "./lib/server-runtime/sessions/memoryStorage"; +export { createMemoryUploadHandler as unstable_createMemoryUploadHandler } from "./lib/server-runtime/upload/memoryUploadHandler"; +export { MaxPartSizeExceededError } from "./lib/server-runtime/upload/errors"; +export { setDevServerHooks as unstable_setDevServerHooks } from "./lib/server-runtime/dev"; + +export type { + CreateCookieFunction, + IsCookieFunction, +} from "./lib/server-runtime/cookies"; +// TODO: (v7) Clean up code paths for these exports +// export type { +// JsonFunction, +// RedirectFunction, +// } from "./lib/server-runtime/responses"; +export type { CreateRequestHandlerFunction } from "./lib/server-runtime/server"; +export type { + CreateSessionFunction, + CreateSessionStorageFunction, + IsSessionFunction, +} from "./lib/server-runtime/sessions"; +export type { CreateCookieSessionStorageFunction } from "./lib/server-runtime/sessions/cookieStorage"; +export type { CreateMemorySessionStorageFunction } from "./lib/server-runtime/sessions/memoryStorage"; + +export type { + HandleDataRequestFunction, + HandleDocumentRequestFunction, + HandleErrorFunction, + ServerBuild, + ServerEntryModule, +} from "./lib/server-runtime/build"; + +export type { + UploadHandlerPart, + UploadHandler, +} from "./lib/server-runtime/formData"; +export type { + MemoryUploadHandlerOptions, + MemoryUploadHandlerFilterArgs, +} from "./lib/server-runtime/upload/memoryUploadHandler"; - 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 { + Cookie, + CookieOptions, + CookieParseOptions, + CookieSerializeOptions, + CookieSignatureOptions, +} from "./lib/server-runtime/cookies"; + +export type { SignFunction, UnsignFunction } from "./lib/server-runtime/crypto"; - 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 { + // TODO: (v7) Clean up code paths for these exports + // HtmlLinkDescriptor, + // LinkDescriptor, + PageLinkDescriptor, +} from "./lib/server-runtime/links"; - return updates; -} +export type { TypedResponse } from "./lib/server-runtime/responses"; -export interface unstable_PatchRoutesOnMissFunction - extends unstable_AgnosticPatchRoutesOnMissFunction {} +export type { + // TODO: (v7) Clean up code paths for these exports + // ActionFunction, + // ActionFunctionArgs, + // LinksFunction, + // LoaderFunction, + // LoaderFunctionArgs, + // ServerRuntimeMetaArgs, + // ServerRuntimeMetaDescriptor, + // ServerRuntimeMetaFunction, + DataFunctionArgs, + HeadersArgs, + HeadersFunction, +} from "./lib/server-runtime/routeModules"; + +export type { RequestHandler } from "./lib/server-runtime/server"; + +export type { + Session, + SessionData, + SessionIdStorageStrategy, + SessionStorage, + FlashSessionData, +} from "./lib/server-runtime/sessions"; -export function createMemoryRouter( - routes: RouteObject[], - opts?: { - basename?: string; - future?: Partial>; - hydrationData?: HydrationState; - initialEntries?: InitialEntry[]; - initialIndex?: number; - unstable_dataStrategy?: unstable_DataStrategyFunction; - unstable_patchRoutesOnMiss?: unstable_PatchRoutesOnMissFunction; - } -): 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_patchRoutesOnMiss: opts?.unstable_patchRoutesOnMiss, - }).initialize(); -} +// Private exports for internal use +export { ServerMode as UNSAFE_ServerMode } from "./lib/server-runtime/mode"; /////////////////////////////////////////////////////////////////////////////// // DANGER! PLEASE READ ME! @@ -345,4 +478,35 @@ export { mapRouteProperties as UNSAFE_mapRouteProperties, useRouteId as UNSAFE_useRouteId, useRoutesImpl as UNSAFE_useRoutesImpl, + UNSAFE_ErrorResponseImpl, }; + +/** @internal */ +export { FrameworkContext as UNSAFE_FrameworkContext } from "./lib/dom/ssr/components"; + +/** @internal */ +export type { RouteModules as UNSAFE_RouteModules } from "./lib/dom/ssr/routeModules"; + +/** @internal */ +export type { + FutureConfig as UNSAFE_FutureConfig, + AssetsManifest as UNSAFE_AssetsManifest, + FrameworkContextObject as UNSAFE_FrameworkContextObject, +} from "./lib/dom/ssr/entry"; + +/** @internal */ +export type { + EntryRoute as UNSAFE_EntryRoute, + RouteManifest as UNSAFE_RouteManifest, +} from "./lib/dom/ssr/routes"; + +/** @internal */ +export type { + SingleFetchRedirectResult as UNSAFE_SingleFetchRedirectResult, + SingleFetchResult as UNSAFE_SingleFetchResult, + SingleFetchResults as UNSAFE_SingleFetchResults, +} from "./lib/dom/ssr/single-fetch"; +export { + decodeViaTurboStream as UNSAFE_decodeViaTurboStream, + SingleFetchRedirectSymbol as UNSAFE_SingleFetchRedirectSymbol, +} from "./lib/dom/ssr/single-fetch"; 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..8408526b8d 100644 --- a/packages/react-router/jest.config.js +++ b/packages/react-router/jest.config.js @@ -1,22 +1,6 @@ +/** @type {import('jest').Config} */ module.exports = { + ...require("../../jest/jest.config.shared"), + 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..0a066f7712 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -1,30 +1,30 @@ import type { + FutureConfig, + HydrationState, InitialEntry, LazyRouteFunction, Location, MemoryHistory, RelativeRoutingType, Router as RemixRouter, - RouterState, - RouterSubscriber, To, TrackedPromise, -} from "@remix-run/router"; + unstable_DataStrategyFunction, + unstable_AgnosticPatchRoutesOnMissFunction, +} from "./router"; import { - AbortedDeferredError, Action as NavigationType, createMemoryHistory, - UNSAFE_getResolveToMatches as getResolveToMatches, + createRouter, UNSAFE_invariant as invariant, parsePath, resolveTo, stripBasename, UNSAFE_warning as warning, -} from "@remix-run/router"; +} from "./router"; import * as React from "react"; import type { - DataRouteObject, IndexRouteObject, Navigator, NonIndexRouteObject, @@ -33,8 +33,6 @@ import type { } from "./context"; import { AwaitContext, - DataRouterContext, - DataRouterStateContext, LocationContext, NavigationContext, RouteContext, @@ -47,180 +45,130 @@ import { useNavigate, useOutlet, useRoutes, - useRoutesImpl, } from "./hooks"; +import { getResolveToMatches } from "./router/utils"; -export interface FutureConfig { - v7_relativeSplatPath: boolean; - v7_startTransition: boolean; -} - -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>; -} +// TODO: Let's get this back to using an import map and development/production +// condition once we get the rollup build replaced +const ENABLE_DEV_WARNINGS = true; /** - 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]; - -/** - * Given a Remix Router instance, render the appropriate UI + * @private */ -export function RouterProvider({ - fallbackElement, - router, - future, -}: RouterProviderProps): React.ReactElement { - let [state, setStateImpl] = React.useState(router.state); - let { v7_startTransition } = future || {}; - - let setState = React.useCallback( - (newState: RouterState) => { - if (v7_startTransition && startTransitionImpl) { - startTransitionImpl(() => setStateImpl(newState)); - } else { - setStateImpl(newState); +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." + ); } - }, - [setStateImpl, v7_startTransition] - ); + } + Object.assign(updates, { + element: React.createElement(route.Component), + Component: undefined, + }); + } - // 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]); + 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, + }); + } - 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 (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, + }); + } - 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]); + return updates; +} - let basename = router.basename || "/"; +export interface unstable_PatchRoutesOnMissFunction + extends unstable_AgnosticPatchRoutesOnMissFunction {} - let dataRouterContext = React.useMemo( - () => ({ - router, - navigator, - static: false, - basename, +/** + * @category Routers + */ +export function createMemoryRouter( + routes: RouteObject[], + opts?: { + basename?: string; + future?: Partial; + hydrationData?: HydrationState; + initialEntries?: InitialEntry[]; + initialIndex?: number; + unstable_dataStrategy?: unstable_DataStrategyFunction; + unstable_patchRoutesOnMiss?: unstable_PatchRoutesOnMissFunction; + } +): RemixRouter { + return createRouter({ + basename: opts?.basename, + future: opts?.future, + history: createMemoryHistory({ + initialEntries: opts?.initialEntries, + initialIndex: opts?.initialIndex, }), - [router, navigator, basename] - ); - - // 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