diff --git a/.changeset/dirty-bags-wink.md b/.changeset/dirty-bags-wink.md new file mode 100644 index 00000000000..e4b3c406d0f --- /dev/null +++ b/.changeset/dirty-bags-wink.md @@ -0,0 +1,5 @@ +--- +"@remix-run/serve": major +--- + +integrate manual mode in remix-serve diff --git a/.changeset/fix-commit-session-expires.md b/.changeset/fix-commit-session-expires.md new file mode 100644 index 00000000000..825522dc027 --- /dev/null +++ b/.changeset/fix-commit-session-expires.md @@ -0,0 +1,5 @@ +--- +"@remix-run/server-runtime": patch +--- + +Ensure `maxAge`/`expires` options passed to `commitSession` take precedence over the original `cookie.expires` value ([#6598](https://github.com/remix-run/remix/pull/6598)) diff --git a/.eslintrc.js b/.eslintrc.js index cf59e693bea..20bb1250efc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -52,8 +52,6 @@ module.exports = { "newlines-between": "always", }, ], - - "react/jsx-no-leaked-render": [WARN, { validStrategies: ["ternary"] }], }, }, ], diff --git a/.github/workflows/reusable-test.yml b/.github/workflows/reusable-test.yml index 74b87348d73..2b51c50ad5d 100644 --- a/.github/workflows/reusable-test.yml +++ b/.github/workflows/reusable-test.yml @@ -163,7 +163,7 @@ jobs: fail-fast: false matrix: node: ${{ fromJSON(inputs.node_version) }} - browser: ["edge"] + browser: ["msedge"] runs-on: windows-latest steps: diff --git a/.github/workflows/shared-build.yml b/.github/workflows/shared-build.yml new file mode 100644 index 00000000000..7608c8668fb --- /dev/null +++ b/.github/workflows/shared-build.yml @@ -0,0 +1,36 @@ +name: ๐ ๏ธ Build + +on: + workflow_call: + +env: + CI: true + CYPRESS_INSTALL_BINARY: 0 + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: ๐ Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: โฌ๏ธ Checkout repo + uses: actions/checkout@v3 + + - name: โ Setup node + uses: actions/setup-node@v3 + with: + node-version-file: ".nvmrc" + cache: "yarn" + + - 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: yarn --frozen-lockfile + + - name: ๐ Build + run: yarn build diff --git a/.github/workflows/shared-test-integration.yml b/.github/workflows/shared-test-integration.yml new file mode 100644 index 00000000000..5fcc4cba5f7 --- /dev/null +++ b/.github/workflows/shared-test-integration.yml @@ -0,0 +1,62 @@ +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 + CYPRESS_INSTALL_BINARY: 0 + +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: ๐ Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: โฌ๏ธ Checkout repo + uses: actions/checkout@v3 + + - name: โ Setup node ${{ matrix.node }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + cache: "yarn" + + - 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: yarn --frozen-lockfile + + - name: ๐ฅ Install Playwright + run: npx playwright install --with-deps ${{ matrix.browser }} + + - name: ๐ Run Integration Tests ${{ matrix.browser }} + run: "yarn test:integration --project=${{ matrix.browser }}" diff --git a/.github/workflows/shared-test-unit.yml b/.github/workflows/shared-test-unit.yml new file mode 100644 index 00000000000..756645c0bdf --- /dev/null +++ b/.github/workflows/shared-test-unit.yml @@ -0,0 +1,55 @@ +name: ๐งช Test (Unit) + +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 + +env: + CI: true + CYPRESS_INSTALL_BINARY: 0 + +jobs: + test: + name: "${{ inputs.os }} / node@${{ matrix.node }}" + strategy: + fail-fast: false + matrix: + node: ${{ fromJSON(inputs.node_version) }} + runs-on: ${{ inputs.os }} + steps: + - name: ๐ Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.11.0 + + - name: โฌ๏ธ Checkout repo + uses: actions/checkout@v3 + + - name: โ Setup node ${{ matrix.node }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node }} + cache: "yarn" + + - 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: yarn --frozen-lockfile + + # It's faster to use the built `cli.js` in tests if its available and up-to-date + - name: ๐ Build + run: yarn build + + - name: ๐งช Run Primary Tests + run: "yarn test:primary" diff --git a/.github/workflows/test-full.yml b/.github/workflows/test-full.yml new file mode 100644 index 00000000000..5d9a15561d7 --- /dev/null +++ b/.github/workflows/test-full.yml @@ -0,0 +1,63 @@ +name: Branch + +# main/dev branches will get the full run across all OS/browsers + +on: + push: + branches: + - main + - dev + paths-ignore: + - "docs/**" + - "scripts/**" + - "contributors.yml" + - "**/*.md" + +jobs: + build: + name: "โ๏ธ Build" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-build.yml + + unit-ubuntu: + name: "๐งช Unit Test" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-test-unit.yml + with: + os: "ubuntu-latest" + node_version: '["latest"]' + + unit-windows: + name: "๐งช Unit Test" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-test-unit.yml + with: + os: "windows-latest" + node_version: '["latest"]' + + integration-ubuntu: + name: "๐ Integration Test" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-test-integration.yml + with: + os: "ubuntu-latest" + node_version: '["latest"]' + browser: '["chromium", "firefox"]' + + integration-windows: + name: "๐ Integration Test" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-test-integration.yml + with: + os: "windows-latest" + node_version: '["latest"]' + browser: '["msedge"]' + + integration-macos: + name: "๐ Integration Test" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-test-integration.yml + with: + os: "macos-latest" + node_version: '["latest"]' + browser: '["webkit"]' diff --git a/.github/workflows/test-pr-ubuntu.yml b/.github/workflows/test-pr-ubuntu.yml new file mode 100644 index 00000000000..e7b8a536592 --- /dev/null +++ b/.github/workflows/test-pr-ubuntu.yml @@ -0,0 +1,34 @@ +name: PR (Base) + +# All PRs touching code will run tests on ubuntu/node/chromium + +on: + pull_request: + paths-ignore: + - "docs/**" + - "scripts/**" + - "contributors.yml" + - "**/*.md" + +jobs: + build: + name: "โ๏ธ Build" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-build.yml + + unit-ubuntu: + name: "๐งช Unit Test" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-test-unit.yml + with: + os: "ubuntu-latest" + node_version: '["latest"]' + + integration-chromium: + name: "๐ Integration Test" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-test-integration.yml + with: + os: "ubuntu-latest" + node_version: '["latest"]' + browser: '["chromium"]' diff --git a/.github/workflows/test-pr-windows-macos.yml b/.github/workflows/test-pr-windows-macos.yml new file mode 100644 index 00000000000..7dc4a923a39 --- /dev/null +++ b/.github/workflows/test-pr-windows-macos.yml @@ -0,0 +1,46 @@ +name: PR (Full) + +# PRs touching create-remix/remix-dev will also run on Windows and OSX + +on: + pull_request: + paths: + - "packages/create-remix/**" + - "packages/remix-dev/**" + - "!**/*.md" + +jobs: + unit-windows: + name: "๐งช Unit Test" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-test-unit.yml + with: + os: "windows-latest" + node_version: '["latest"]' + + integration-firefox: + name: "๐ Integration Test" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-test-integration.yml + with: + os: "ubuntu-latest" + node_version: '["latest"]' + browser: '["firefox"]' + + integration-msedge: + name: "๐ Integration Test" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-test-integration.yml + with: + os: "windows-latest" + node_version: '["latest"]' + browser: '["msedge"]' + + integration-webkit: + name: "๐ Integration Test" + if: github.repository == 'remix-run/remix' + uses: ./.github/workflows/shared-test-integration.yml + with: + os: "macos-latest" + node_version: '["latest"]' + browser: '["webkit"]' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 14905fe4d14..00000000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: ๐งช Test - -on: - push: - branches: - - main - - dev - paths-ignore: - - "docs/**" - - "scripts/**" - - "contributors.yml" - - "**/*.md" - pull_request: - paths-ignore: - - "docs/**" - - "scripts/**" - - "contributors.yml" - - "**/*.md" - -jobs: - test: - if: github.repository == 'remix-run/remix' - uses: ./.github/workflows/reusable-test.yml - with: - node_version: '["latest"]' diff --git a/contributors.yml b/contributors.yml index b18708de381..7664e08abf2 100644 --- a/contributors.yml +++ b/contributors.yml @@ -183,6 +183,7 @@ - guatedude2 - guerra08 - gunners6518 +- gustavoguichard - gustavopch - gyx1000 - hadizz @@ -568,6 +569,7 @@ - amir-ziaei - mrkhosravian - tanerijun +- naveed-fida - toufiqnuur - ally1002 - defjosiah diff --git a/docs/api/conventions.md b/docs/api/conventions.md deleted file mode 100644 index 054179bf14c..00000000000 --- a/docs/api/conventions.md +++ /dev/null @@ -1,231 +0,0 @@ ---- -title: Conventions ---- - -# Conventions - -The docs in this page have moved to their own pages - -## remix.config.js - -[Moved โ][moved] - -### appDirectory - -[Moved โ][moved-2] - -### assetsBuildDirectory - -[Moved โ][moved-3] - -### cacheDirectory - -[Moved โ][moved-4] - -### ignoredRouteFiles - -[Moved โ][moved-7] - -### publicPath - -[Moved โ][moved-8] - -### routes - -[Moved โ][moved-9] - -### server - -[Moved โ][moved-10] - -### serverBuildDirectory - -[Moved โ][moved-11] - -### serverBuildPath - -[Moved โ][moved-12] - -### serverDependenciesToBundle - -[Moved โ][moved-14] - -### watchPaths - -[Moved โ][moved-15] - -## File Name Conventions - -### Special Files - -### Route File Conventions - -[Moved โ][moved-16] - -#### Root Layout Route - -[Moved โ][moved-17] - -#### Basic Routes - -[Moved โ][moved-18] - -#### Dynamic Route Parameters - -[Moved โ][moved-19] - -#### Layout Routes - -[Moved โ][moved-20] - -#### Pathless Layout Routes - -[Moved โ][moved-21] - -#### Dot Delimiters - -[Moved โ][moved-22] - -#### Splat Routes - -[Moved โ][moved-23] - -### Escaping special characters - -[Moved โ][moved-24] - -## Entry Files - -### entry.client.tsx - -[Moved โ][moved-25] - -### entry.server.tsx - -[Moved โ][moved-26] - -## Route Module API - -### `default` export - -[Moved โ][moved-27] - -### `loader` - -[Moved โ][moved-28] - -#### loader `params` - -[Moved โ][moved-29] - -#### loader `request` - -[Moved โ][moved-30] - -#### loader `context` - -[Moved โ][moved-31] - -#### Returning Response Instances - -[Moved โ][moved-32] - -#### Throwing Responses in Loaders - -[Moved โ][moved-33] - -### `action` - -[Moved โ][moved-34] - -### `headers` - -[Moved โ][moved-35] - -### `meta` - -[Moved โ][moved-36] - -#### `MetaDescriptor` - -[Moved โ][moved-37] - -#### Page context in `meta` function - -[Moved โ][moved-38] - -### `links` - -[Moved โ][moved-39] - -#### `HtmlLinkDescriptor` - -[Moved โ][moved-40] - -#### `PageLinkDescriptor` - -[Moved โ][moved-41] - -### ErrorBoundary - -[Moved โ][moved-43] - -### handle - -[Moved โ][moved-44] - -#### Never reloading the root - -\[Moved โ]\[moved-46] - -#### Ignoring search params - -\[Moved โ]\[moved-47] - -## Asset URL Imports - -[Moved โ][moved-48] - -[moved]: ../file-conventions/remix-config -[moved-2]: ../file-conventions/remix-config#appdirectory -[moved-3]: ../file-conventions/remix-config#assetsbuilddirectory -[moved-4]: ../file-conventions/remix-config#cachedirectory -[moved-5]: ../file-conventions/remix-config#devserverbroadcastdelay -[moved-6]: ../file-conventions/remix-config#devserverport -[moved-7]: ../file-conventions/remix-config#ignoredroutefiles -[moved-8]: ../file-conventions/remix-config#publicpath -[moved-9]: ../file-conventions/remix-config#routes -[moved-10]: ../file-conventions/remix-config#server -[moved-11]: ../file-conventions/remix-config#serverbuilddirectory -[moved-12]: ../file-conventions/remix-config#serverbuildpath -[moved-14]: ../file-conventions/remix-config#serverdependenciestobundle -[moved-15]: ../file-conventions/remix-config#watchpaths -[moved-16]: ../file-conventions/routes-files -[moved-17]: ../file-conventions/root -[moved-18]: ../file-conventions/routes-files#basic-routes -[moved-19]: ../file-conventions/routes-files#dynamic-route-parameters -[moved-20]: ../file-conventions/routes-files#layout-routes -[moved-21]: ../file-conventions/routes-files#pathless-layout-routes -[moved-22]: ../file-conventions/routes-files#dot-delimiters -[moved-23]: ../file-conventions/routes-files#splat-routes -[moved-24]: ../file-conventions/routes-files#escaping-special-characters -[moved-25]: ../file-conventions/entry.client -[moved-26]: ../file-conventions/entry.server -[moved-27]: ../route/component -[moved-28]: ../route/loader -[moved-29]: ../route/loader#params -[moved-30]: ../route/loader#request -[moved-31]: ../route/loader#context -[moved-32]: ../route/loader#returning-response-instances -[moved-33]: ../route/loader#throwing-responses-in-loaders -[moved-34]: ../route/action -[moved-35]: ../route/headers -[moved-36]: ../route/meta -[moved-37]: ../route/meta#htmlmetadescriptor -[moved-38]: ../route/meta#page-context-in-meta-function -[moved-39]: ../route/links -[moved-40]: ../route/links#htmllinkdescriptor -[moved-41]: ../route/links#pagelinkdescriptor -[moved-43]: ../route/error-boundary -[moved-44]: ../route/handle -[moved-48]: ../other-api/asset-imports diff --git a/docs/api/index.md b/docs/api/index.md deleted file mode 100644 index 2f2a485d126..00000000000 --- a/docs/api/index.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: API -hidden: true ---- diff --git a/docs/api/remix.md b/docs/api/remix.md deleted file mode 100644 index aad42b3799e..00000000000 --- a/docs/api/remix.md +++ /dev/null @@ -1,340 +0,0 @@ ---- -title: Remix Packages ---- - -# Remix Packages - -The docs in this page have moved to their own pages - -## Components and Hooks - -### ``, ``, ``, ``, `` - -- [Links Moved โ][links-moved] -- [LiveReload Moved โ][live-reload-moved] -- [Meta Moved โ][meta-moved] -- [Scripts Moved โ][scripts-moved] -- [ScrollRestoration Moved โ][scroll-restoration-moved] - -### `` - -[Moved โ][moved] - -### `` - -[Moved โ][moved-68] - -### `` - -[Moved โ][moved-2] - -### `` - -[Moved โ][moved-3] - -#### `` - -[Moved โ][moved-4] - -#### `` - -[Moved โ][moved-5] - -#### `` - -[Moved โ][moved-6] - -#### `` - -[Moved โ][moved-7] - -#### `` - -[Moved โ][moved-8] - -### `` - -[Moved โ][moved-9] - -### `useLoaderData` - -[Moved โ][moved-10] - -### `useActionData` - -[Moved โ][moved-11] - -#### Notes about resubmissions - -[Moved โ][moved-12] - -### `useFormAction` - -[Moved โ][moved-13] - -### `useSubmit` - -[Moved โ][moved-14] - -### `useFetcher` - -[Moved โ][moved-20] - -#### `fetcher.state` - -[Moved โ][moved-21] - -#### `fetcher.type` - -[Moved โ][moved-22] - -#### `fetcher.submission` - -[Moved โ][moved-23] - -#### `fetcher.data` - -[Moved โ][moved-24] - -#### `fetcher.Form` - -[Moved โ][moved-25] - -#### `fetcher.submit()` - -[Moved โ][moved-26] - -#### `fetcher.load()` - -[Moved โ][moved-27] - -#### Examples - -[Moved โ][moved-28] - -### `useFetchers` - -[Moved โ][moved-29] - -### `useMatches` - -[Moved โ][moved-30] - -### `useBeforeUnload` - -[Moved โ][moved-31] - -## HTTP Helpers - -### `json` - -[Moved โ][moved-32] - -### `redirect` - -[Moved โ][moved-33] - -### `unstable_parseMultipartFormData` - -[Moved โ][moved-34] - -### `uploadHandler` - -[Moved โ][moved-35] - -#### `unstable_createFileUploadHandler` - -[Moved โ][moved-36] - -#### `unstable_createMemoryUploadHandler` - -[Moved โ][moved-37] - -### Upload Handler Composition - -[Moved โ][moved-38] - -## Cookies - -[Moved โ][moved-39] - -### Using cookies - -[Moved โ][moved-40] - -### Cookie attributes - -[Moved โ][moved-41] - -### Signing cookies - -[Moved โ][moved-42] - -### `createCookie` - -[Moved โ][moved-43] - -### `isCookie` - -[Moved โ][moved-44] - -### Cookie API - -[Moved โ][moved-45] - -#### `cookie.name` - -[Moved โ][moved-46] - -#### `cookie.parse()` - -[Moved โ][moved-47] - -#### `cookie.serialize()` - -[Moved โ][moved-48] - -#### `cookie.isSigned` - -[Moved โ][moved-49] - -#### `cookie.expires` - -[Moved โ][moved-50] - -## Sessions - -[Moved โ][moved-51] - -### Using Sessions - -[Moved โ][moved-52] - -### Session Gotchas - -[Moved โ][moved-53] - -### `createSession` - -[Moved โ][moved-54] - -### `isSession` - -[Moved โ][moved-55] - -### `createSessionStorage` - -[Moved โ][moved-56] - -### `createCookieSessionStorage` - -[Moved โ][moved-57] - -### `createMemorySessionStorage` - -[Moved โ][moved-58] - -### `createFileSessionStorage` (node) - -[Moved โ][moved-59] - -### `createWorkersKVSessionStorage` (Cloudflare Workers) - -[Moved โ][moved-60] - -### `createArcTableSessionStorage` (architect, Amazon DynamoDB) - -[Moved โ][moved-61] - -### Session API - -[Moved โ][moved-62] - -#### `session.has(key)` - -[Moved โ][moved-63] - -#### `session.set(key, value)` - -[Moved โ][moved-64] - -#### `session.flash(key, value)` - -[Moved โ][moved-65] - -#### `session.get()` - -[Moved โ][moved-66] - -#### `session.unset()` - -[Moved โ][moved-67] - -[links-moved]: ../components/links -[live-reload-moved]: ../components/live-reload -[meta-moved]: ../components/meta -[scripts-moved]: ../components/scripts -[scroll-restoration-moved]: ../components/ScrollRestoration -[moved]: ../components/link -[moved-2]: ../components/nav-link -[moved-3]: ../components/form -[moved-4]: ../components/form#action -[moved-5]: ../components/form#method -[moved-6]: ../components/form#enctype -[moved-7]: ../components/form#replace -[moved-8]: ../components/form#reloaddocument -[moved-9]: ../components/scroll-restoration -[moved-10]: ../hooks/use-loader-data -[moved-11]: ../hooks/use-action-data -[moved-12]: ../hooks/use-action-data#notes-about-resubmissions -[moved-13]: ../hooks/use-form-action -[moved-14]: ../hooks/use-submit -[moved-20]: ../hooks/use-fetcher -[moved-21]: ../hooks/use-fetcher#fetcherstate -[moved-22]: ../hooks/use-fetcher#fetchertype -[moved-23]: ../hooks/use-fetcher#fetchersubmission -[moved-24]: ../hooks/use-fetcher#fetcherdata -[moved-25]: ../hooks/use-fetcher#fetcherform -[moved-26]: ../hooks/use-fetcher#fetchersubmit -[moved-27]: ../hooks/use-fetcher#fetcherload -[moved-28]: ../hooks/use-fetcher#examples -[moved-29]: ../hooks/use-fetchers -[moved-30]: ../hooks/use-matches -[moved-31]: ../hooks/use-before-unload -[moved-32]: ../utils/json -[moved-33]: ../utils/redirect -[moved-34]: ../utils/parse-multipart-form-data -[moved-35]: ../utils/parse-multipart-form-data#uploadhandler -[moved-36]: ../utils/unstable-create-file-upload-handler -[moved-37]: ../utils/unstable-create-memory-upload-handler -[moved-38]: ../guides/file-uploads#upload-handler-composition -[moved-39]: ../utils/cookies -[moved-40]: ../utils/cookies#using-cookies -[moved-41]: ../utils/cookies#cookie-attributes -[moved-42]: ../utils/cookies#signing-cookies -[moved-43]: ../utils/cookies#createcookie -[moved-44]: ../utils/cookies#iscookie -[moved-45]: ../utils/cookies#cookie-api -[moved-46]: ../utils/cookies#cookiename -[moved-47]: ../utils/cookies#cookieparse -[moved-48]: ../utils/cookies#cookieserialize -[moved-49]: ../utils/cookies#cookieissigned -[moved-50]: ../utils/cookies#cookieexpires -[moved-51]: ../utils/sessions -[moved-52]: ../utils/sessions#using-sessions -[moved-53]: ../utils/sessions#session-gotchas -[moved-54]: ../utils/sessions#createsession -[moved-55]: ../utils/sessions#issession -[moved-56]: ../utils/sessions#createsessionstorage -[moved-57]: ../utils/sessions#createcookiesessionstorage -[moved-58]: ../utils/sessions#creatememorysessionstorage -[moved-59]: ../utils/sessions#createfilesessionstorage-node -[moved-60]: ../utils/sessions#createworkerskvsessionstorage-cloudflare-workers -[moved-61]: ../utils/sessions#createarctablesessionstorage-architect-amazon-dynamodb -[moved-62]: ../utils/sessions#session-api -[moved-63]: ../utils/sessions#sessionhaskey -[moved-64]: ../utils/sessions#sessionsetkey-value -[moved-65]: ../utils/sessions#sessionflashkey-value -[moved-66]: ../utils/sessions#sessionget -[moved-67]: ../utils/sessions#sessionunset -[moved-68]: ../components/prefetch-page-links diff --git a/docs/discussion/01-runtimes.md b/docs/discussion/01-runtimes.md index 66e73df39b7..288ba4fe3bb 100644 --- a/docs/discussion/01-runtimes.md +++ b/docs/discussion/01-runtimes.md @@ -25,10 +25,10 @@ Each runtime has varying support for the standard Web APIs that Remix is built o The following runtimes packages are available: -- `@remix-run/node` -- `@remix-run/cloudflare-pages` -- etc. -- TODO: add links to each one +- [`@remix-run/cloudflare-pages`][remix-run-cloudflare-pages] +- [`@remix-run/cloudflare-workers`][remix-run-cloudflare-workers] +- [`@remix-run/deno`][remix-run-deno] +- [`@remix-run/node`][remix-run-node] The majority of the APIs you interact with in your app are not imported directly from these packages, so your code is fairly portable between runtimes. However, occasionally you'll import something from these packages for a specific feature that isn't a standard Web API. @@ -110,3 +110,7 @@ Once you've picked a template or [set up an app from scratch][quickstart], you'r [serve]: ../other-api/serve [quickstart]: ../start/quickstart [templates-guide]: ../guides/templates +[remix-run-cloudflare-pages]: https://www.npmjs.com/package/@remix-run/cloudflare-pages +[remix-run-cloudflare-workers]: https://www.npmjs.com/package/@remix-run/cloudflare-workers +[remix-run-deno]: https://www.npmjs.com/package/@remix-run/deno +[remix-run-node]: https://www.npmjs.com/package/@remix-run/node diff --git a/docs/discussion/02-routes.md b/docs/discussion/02-routes.md index 51169e373e0..9556a16d974 100644 --- a/docs/discussion/02-routes.md +++ b/docs/discussion/02-routes.md @@ -1,341 +1,144 @@ --- -title: Routing +title: Route Configuration --- -# Routing +# Route Configuration -Remix uses nested routes, popularized by [Ember.js][ember-js] many years ago. Everything starts with your routes: the compiler, the initial request, and almost every user interaction afterward. +One of the foundational concepts in Remix's routing system is the use of nested routes, an approach that traces its roots back to Ember.js. With nested routes, segments of the URL are coupled to both data dependencies and the UI's component hierarchy. A URL like `/sales/invoices/102000` not only reveals a clear path in the application but also delineates the relationships and dependencies for different components. -Nested Routing is the general idea of coupling segments of the URL to component hierarchy in the UI. We've found that in almost every case, segments of the URL determine: +## Modular Design -- The layouts to render on the page -- The code-split JavaScript bundles to load -- The data dependencies of those layouts +Nested routes provide clarity by segmenting URLs into multiple parts. Each segment directly correlates with a particular data requirement and component. For instance, in the URL `/sales/invoices/102000`, each segment - `sales`, `invoices`, and `102000` - can be associated with specific data points and UI sections, making it intuitive to manage in the codebase. -Let's consider a UI to help us out. Hover or tap each button to see how each segment of the URL maps to three things: a component layout, a JavaScript bundle, and a piece of data. +A feature of nested routing is the ability for several routes in the nested route tree to match a single URL. This granularity ensures that each route is primarily focused on its specific URL segment and related slice of UI. This approach champions the principles of modularity and separation of concerns, ensuring each route remains focused on its core responsibilities. -As the user clicks between links in the sidebar, the sidebar persists while the main content changes. Likewise, as they click between the Sales page top nav (Overview, Subscriptions, Invoices, etc.) both the sidebar and the top nav persist while the secondary content changes, and so on down the layout hierarchy. +## Parallel Loading -In Remix, all of these "boxes" are a **Route**, defined by a **Route Module** in your app. +In some web applications, the sequential loading of data and assets can sometimes lead to an artificially slow user experience. Even when data dependencies aren't interdependent, they may be loaded sequentially because they are coupled to rendering hierarchy, creating an undesirable chain of requests. -## Defining Routes +Remix leveraging its nested routing system to optimize load times. When a URL matches multiple routes, Remix will load the required data and assets for all matching routes in parallel. By doing this, Remix effectively sidesteps the conventional pitfall of chained request sequences. -The primary way to define a route is to create a new file in `app/routes/*`. The routes for the UI example above would look something like this: +This strategy, combined with modern browsers' capability to handle multiple concurrent requests efficiently, positions Remix as a front-runner in delivering highly responsive and swift web applications. It's not just about making your data fetching fast; it's about fetching it in an organized way to provide the best possible experience for the end user. -``` -app -โโโ root.tsx -โโโ routes - โโโ _index.tsx - โโโ accounts.tsx - โโโ dashboard.tsx - โโโ expenses.tsx - โโโ reports.tsx - โโโ sales._index.tsx - โโโ sales.customers.tsx - โโโ sales.deposits.tsx - โโโ sales.invoices.$invoiceId._index.tsx - โโโ sales.invoices.$invoiceId.tsx - โโโ sales.invoices.tsx - โโโ sales.subscriptions.tsx - โโโ sales.tsx -``` - -- `root.tsx` is the "root route" that serves as the layout for the entire application. Every route will render inside its ``. -- Note that there are files with `.` delimiters. The `.` creates a `/` in the URL for that route, as well as layout nesting with another route that matches the segments before the `.`. For example, `sales.tsx` is the **parent route** for all the **child routes** that look like `sales.[the nested path].tsx`. The `` in `sales.tsx` will render the matching child route. -- The `_index.tsx` routes will render inside the parent `` when the url is only as deep as the parent's path (like `example.com/sales` instead of `example.com/sales/customers`) - -## Rendering Route Layout Hierarchies - -Let's consider the URL is `/sales/invoices/102000`. The following routes all match that URL: - -- `root.tsx` -- `routes/sales.tsx` -- `routes/sales.invoices.tsx` -- `routes/sales.invoices.$invoiceId.tsx` - -When the user visits this page, Remix will render the components in this hierarchy: - -```tsx - - - - - - - -``` - -You'll note that the component hierarchy is perfectly mapped to the file system hierarchy in `routes`. By looking at the files, you can anticipate how they will render. - -``` -app -โโโ root.tsx -โโโ routes - โโโ sales.invoices.$invoiceId.tsx - โโโ sales.invoices.tsx - โโโ sales.tsx -``` - -If the URL is `/accounts`, the UI hierarchy changes to this: - -```tsx - - - -``` - -It's partly your job to make this work. You need to render an `` to continue the rendering of the route hierarchy from the parent routes. `root.tsx` renders the main layout, sidebar, and then an outlet for the child routes to continue rendering through: - -```tsx filename=app/root.tsx lines=[1,7] -import { Outlet } from "@remix-run/react"; - -export default function Root() { - return ( - - - - - ); -} -``` - -Next up is the sales route, which also renders an outlet for its child routes (all the routes matching `app/routes/sales.*.tsx`). - -```tsx filename=app/routes/sales.tsx lines=[8] -import { Outlet } from "@remix-run/react"; - -export default function Sales() { - return ( - - Sales - - - - ); -} -``` - -And so on down the route tree. This is a powerful abstraction that makes something previously complex very simple. - -## Index Routes - -Index routes are often difficult to understand at first. It's easiest to think of them as _the default child route_ for a parent route. When there is no child route to render, we render the index route. - -Consider the URL `example.com/sales`. If our app didn't have an index route at `app/routes/sales._index.tsx` the UI would look like this! - - - -And index is the thing you render to fill in that empty space when none of the child routes match. - -Index Routes cannot have child routes - -Index routes are "leaf routes". They're the end of the line. If you think you need to add child routes to an index route, that usually means your layout code (like a shared nav) needs to move out of the index route and into the parent route. - -This usually comes up when folks are just getting started with Remix and put their global nav in `app/routes/_index.tsx`. Move that global nav up into `app/root.tsx`. Everything inside of `app/routes/*` is already a child of `root.tsx`. - -### What is the `?index` query param? - -You may notice an `?index` query parameter showing up on your URLs from time to time, particularly when you are submitting a `` from an index route. This is required to differentiate index routes from their parent layout routes. Consider the following structure, where a URL such as `/sales/invoices` would be ambiguous. Is that referring to the `app/routes/sales.invoices.tsx` file? Or is it referring to the `app/routes/sales.invoices._index.tsx` file? In order to avoid this ambiguity, Remix uses the `?index` parameter to indicate when a URL refers to the index route instead of the layout route. - -``` -โโโ app - โโโ root.tsx - โโโ routes - โโโ sales.invoices._index.tsx <-- /sales/invoices?index - โโโ sales.invoices.tsx <-- /sales/invoices -``` - -This is handled automatically for you when you submit from a `` contained within either the layout route or the index route. But if you are submitting forms to different routes, or using `fetcher.submit`/`fetcher.load` you may need to be aware of this URL pattern, so you can target the correct route. - -## Nested URLs without nesting layouts - -Sometimes you want to add nesting to the URL (slashes) but you don't want to create UI hierarchy. Consider an edit page for an invoice: - -- We want the URL to be `/sales/invoices/:invoiceId/edit` -- We **don't** want it nested inside the components except the root so the user (and our designer) has plenty of room to edit the invoice - -In other words, we don't want this: +## Conventional Route Configuration -```tsx bad - - - - - - - - - -``` - -We want this: - -```tsx - - - -``` - -So, if we want a flat UI hierarchy, we use a `trailing_` underscore to opt-out of layout nesting. This defines URL nesting _without creating component nesting_. - -``` -โโโ app - โโโ root.tsx - โโโ routes - โโโ sales.invoices.$invoiceId.tsx - โโโ sales.invoices.tsx - โโโ sales_.invoices.$invoiceId.edit.tsx ๐ not nested - โโโ sales.tsx -``` - -So if the url is "example.com/sales/invoices/2000/edit", we'll get this UI hierarchy that matches the file system hierarchy: - -```tsx - - - -``` +Remix introduces a key convention to help streamline the routing process: the `routes` folder. When a developer introduces a file within this folder, Remix inherently understands it as a route. This convention simplifies the process of defining routes, associating them with URLs, and rendering the associated components. -If we remove "edit" from the URL like this: "example.com/sales/invoices/2000", then we get all the hierarchy again: +Here's a sample directory that uses the routes folder convention: -```tsx - - - - - - - + +```markdown +app/ +โโโ routes/ +โ โโโ _index.tsx +โ โโโ about.tsx +โ โโโ concerts._index.tsx +โ โโโ concerts.$city.tsx +โ โโโ concerts.trending.tsx +โ โโโ concerts.tsx +โโโ root.tsx ``` -- Layout Nesting + Nested URLs: happens automatically with `.` delimiters that match parent route names. -- `trailing_` underscore on the segment matching the parent route opts-out of layout nesting. +All the routes that start with `concerts.` will be child routes of `concerts.tsx`. -You can introduce nesting or non-nesting at any level of your routes, like `app/routes/invoices.$id_.edit.tsx`, which matches the URL `/invoices/123/edit` but does not create nesting inside of `$id.tsx`, it would nest with `app/routes/invoices.tsx` instead. +| URL | Matched Route | Layout | +| -------------------------- | ----------------------- | -------------- | +| `/` | `_index.tsx` | `root.tsx` | +| `/about` | `about.tsx` | `root.tsx` | +| `/concerts` | `concerts._index.tsx` | `concerts.tsx` | +| `/concerts/trending` | `concerts.trending.tsx` | `concerts.tsx` | +| `/concerts/salt-lake-city` | `concerts.$city.tsx` | `concerts.tsx` | -## Pathless Layout Routes +## Conventional Route Folders -Now for the inverse use case, sometimes you want to share a layout for a set of routes, _but you don't want to add any segments to the URL_. You can do this with a **pathless layout route**. +For routes that require additional modules or assets, a folder inside of `routes/` with a `route.tsx` file can be used. This method: -Consider we want to add some authentication routes, with a UI hierarchy like this: +- **Co-locates Modules**: It gathers all elements connected to a particular route, ensuring logic, styles, and components are closely knit. +- **Simplifies Imports**: With related modules in one place, managing imports becomes straightforward, enhancing code maintainability. +- **Facilitates Automatic Code Organization**: Using the `route.tsx` setup inherently promotes a well-organized codebase, beneficial as the application scales. -```tsx - - - - - -``` - -At first, you might think to just create an `auth` parent route and put the child routes inside to get the layout nesting: +The same routes from above could instead be organized like this: + ``` -app -โโโ root.tsx -โโโ routes - โโโ auth.login.tsx - โโโ auth.logout.tsx - โโโ auth.signup.tsx - โโโ auth.tsx +app/ +โโโ routes/ + โโโ _index/ + โย ย โโโ signup-form.tsx + โย ย โโโ route.tsx + โโโ about/ + โย ย โโโ header.tsx + โย ย โโโ route.tsx + โโโ concerts/ + โย ย โโโ favorites-cookie.ts + โย ย โโโ route.tsx + โโโ concerts.$city/ + โย ย โโโ route.tsx + โโโ concerts._index/ + โย ย โโโ featured.tsx + โย ย โโโ route.tsx + โโโ concerts.trending/ + โโโ card.tsx + โโโ route.tsx + โโโ sponsored.tsx ``` -We have the right UI hierarchy, but we probably don't actually want each of the URLs to be prefixed with `/auth` like `/auth/login`. We just want `/login`. +You can read more about the specific patterns in the file names and other features in the [Route File Conventions][route-file-conventions] reference. -You can remove the URL nesting, but keep the UI nesting, by adding an underscore to the auth route segment: +Only the folders directly beneath `routes/` will be registered as a route. Deeply nested folders are ignored. The file at `routes/about/header/route.tsx` will not create a route. + +```markdown bad lines=[4] +routes +โโโ about + โโโ header + โย ย โโโ route.tsx + โโโ route.tsx ``` -app -โโโ root.tsx -โโโ routes - โโโ _auth.login.tsx - โโโ _auth.logout.tsx - โโโ _auth.signup.tsx - โโโ _auth.tsx -``` - -Now when the URL matches `/login` the UI hierarchy will be same as before. - - - -- `_leading` underscore opts-out of URL nesting -- `trailing_` underscore opts-out of layout nesting - - - -## Dynamic Segments - -Prefixing a file name with `$` will make that route path a **dynamic segment**. This means Remix will match any value in the URL for that segment and provide it to your app. - -For example, the `$invoiceId.tsx` route. When the url is `/sales/invoices/102000`, Remix will provide the string value `102000` to your loaders, actions, and components by the same name as the filename segment: - -```tsx -import { useParams } from "@remix-run/react"; - -export async function loader({ params }: LoaderArgs) { - const id = params.invoiceId; -} - -export async function action({ params }: ActionArgs) { - const id = params.invoiceId; -} - -export default function Invoice() { - const params = useParams(); - const id = params.invoiceId; -} -``` - -Route can have multiple params, and params can be folders as well. - -``` -app -โโโ root.tsx -โโโ routes - โโโ projects.$projectId.tsx - โโโ projects.$projectId.$taskId.tsx - โโโ projects.tsx -``` - -If the URL is `/projects/123/abc` then the params will be as follows: - -```tsx -params.projectId; // "123" -params.taskId; // "abc" -``` - -## Splats - -Naming a file `$.tsx` will make that route path a **splat route**. This means Remix will match any value in the URL for rest of the URL to the end. Unlike **dynamic segments**, a splat won't stop matching at the next `/`, it will capture everything. - -Consider the following routes: - -``` -app -โโโ root.tsx -โโโ routes - โโโ files.$.tsx - โโโ files.mine.tsx - โโโ files.recent.tsx - โโโ files.tsx -``` - -When the URL is `example.com/files/images/work/flyer.jpg`. The splat param will capture the trailing segments of the URL and be available to your app on `params["*"]` - -```tsx -export async function loader({ params }: LoaderArgs) { - params["*"]; // "images/work/flyer.jpg" -} -``` - -You can add splats at any level of your route hierarchy. Any sibling routes will match first (like `/files/mine`). -It's common to add a `app/routes/$.tsx` file build custom 404 pages with data from a loader (without it, Remix renders your root `ErrorBoundary` with no ability to load data for the page when the URL doesn't match any routes). +## Manual Route Configuration -## Conclusion +While the `routes/` folder offers a convenient convention for developers, Remix appreciates that one size doesn't fit all. There are times when the provided convention might not align with specific project requirements or a developer's preferences. In such cases, Remix allows for manual route configuration via the `remix.config`. This flexibility ensures developers can structure their application in a way that makes sense for their project. -Nested routes are an incredibly powerful abstraction. Layouts are shared automatically and each route is only concerned with its slice of the data on the page. Additionally, because of this convention, Remix is able to make a ton of optimizations, automatically turning what feels like a server side app from the developer's perspective into a turbocharged SPA for the user. +A common way to structure an app is by top-level features folders. Consider that routes related to a particular theme, like concerts, likely share several modules. Organizing them under a single folder makes sense: -[ember-js]: https://emberjs.com +```text +app/ +โโโ about +โย ย โโโ route.tsx +โโโ concerts +โย ย โโโ card.tsx +โย ย โโโ city.tsx +โย ย โโโ favorites-cookie.ts +โย ย โโโ home.tsx +โย ย โโโ layout.tsx +โย ย โโโ sponsored.tsx +โย ย โโโ trending.tsx +โโโ home + โโโ header.tsx + โโโ route.tsx +``` + +To configure this structure into the same URLs as the previous examples, you can use the `routes` function in `remix.config.js`: + +```js filename=remix.config.js +export default { + routes(defineRoutes) { + return defineRoutes((route) => { + route("/", "home/route.tsx", { index: true }); + route("about", "about/route.tsx"); + route("concerts", "concerts/layout.tsx", () => { + route("/", "concerts/home.tsx", { index: true }); + route("trending", "concerts/trending.tsx"); + route(":city", "concerts/city.tsx"); + }); + }); + }, +}; +``` + +Remix's route configuration approach blends convention with flexibility. You can use the `routes` folder for an easy, organized way to set up your routes. If you want more control, dislike the file names, or have unique needs, there's `remix.config`. It is expected that many apps forgo the routes folder convention in favor of `remix.config`. + +[route-file-conventions]: ../file-conventions/routes diff --git a/docs/discussion/08-state-management.md b/docs/discussion/08-state-management.md index 88afd360175..0de26c36f57 100644 --- a/docs/discussion/08-state-management.md +++ b/docs/discussion/08-state-management.md @@ -81,7 +81,7 @@ import { export function List() { const navigate = useNavigate(); - const params = useSearchParams(); + const [params] = useSearchParams(); const [view, setView] = React.useState( params.get("view") || "list" ); @@ -118,7 +118,7 @@ Instead of synchronizing state, you can simply read and set the state in the URL import { Form } from "@remix-run/react"; export function List() { - const params = useSearchParams(); + const [params] = useSearchParams(); const view = params.get("view") || "list"; return ( @@ -137,15 +137,209 @@ export function List() { } ``` +### Persistent UI State + +Consider a UI that toggles a sidebar's visibility. We have three ways to handle the state: + +1. React state +2. Browser local storage +3. Cookies + +In this discussion, we'll break down the trade-offs associated with each method. + +#### React State + +React state provides a simple solution for temporary state storage. + +**Pros**: + +- **Simple**: Easy to implement and understand. +- **Encapsulated**: State is scoped to the component. + +**Cons**: + +- **Transient**: Doesn't survive page refreshes, returning to the page later, or unmounting and remounting the component. + +**Implementation**: + +```tsx +function Sidebar({ children }) { + const [isOpen, setIsOpen] = React.useState(false); + return ( + + setIsOpen(!isOpen)}> + {isOpen ? "Close" : "Open"} + + + + ); +} +``` + +#### Local Storage + +To persist state beyond the component lifecycle, browser local storage is a step up. + +**Pros**: + +- **Persistent**: Maintains state across page refreshes and component mounts/unmounts. +- **Encapsulated**: State is scoped to the component. + +**Cons**: + +- **Requires Synchronization**: React components must sync up with local storage to initialize and save the current state. +- **Server Rendering Limitation**: The `window` and `localStorage` objects are not accessible during server-side rendering, so state must be initialized in the browser with an effect. +- **UI Flickering**: On initial page loads, the state in local storage may not match what was rendered by the server and the UI will flicker when JavaScript loads. + +**Implementation**: + +```tsx +function Sidebar({ children }) { + const [isOpen, setIsOpen] = React.useState(false); + + // synchronize initially + useLayoutEffect(() => { + const isOpen = window.localStorage.getItem("sidebar"); + setIsOpen(isOpen); + }, []); + + // synchronize on change + useEffect(() => { + window.localStorage.setItem("sidebar", isOpen); + }, [isOpen]); + + return ( + + { + setIsOpen(!isOpen); + }} + > + {isOpen ? "Close" : "Open"} + + + + ); +} +``` + +In this approach, state must be initialized within an effect. This is crucial to avoid complications during server-side rendering. Directly initializing the React state from `localStorage` will cause errors since `window.localStorage` is unavailable during server rendering. Furthermore, even if it were accessible, it wouldn't mirror the user's browser local storage. + +```tsx bad lines=[4] +function Sidebar() { + const [isOpen, setIsOpen] = React.useState( + // error: window is not defined + window.localStorage.getItem("sidebar") + ); + + // ... +} +``` + +By initializing the state within an effect, there's potential for a mismatch between the server-rendered state and the state stored in local storage. This discrepancy wll lead to brief UI flickering shortly after the page renders and should be avoided. + +#### Cookies + +Cookies offer a comprehensive solution for this use case. However, this method introduces added preliminary setup before making the state accessible within the component. + +**Pros**: + +- **Server Rendering**: State is available on the server for rendering and even for server actions. +- **Single Source of Truth**: Eliminates state synchronization hassles. +- **Persistence**: Maintains state across page loads and component mounts/unmounts. State can even persist across devices if you switch to a database-backed session. +- **Progressive Enhancement**: Functions even before JavaScript loads. + +**Cons**: + +- **Boilerplate**: Requires more code because of the network. +- **Exposed**: The state is not encapsulated to a single component, other parts of the app must be aware of the cookie. + +**Implementation**: + +First we'll need to create a cookie object: + +```tsx +import { createCookie } from "@remix-run/node"; +export const prefs = createCookie("prefs"); +``` + +Next we set up the server action and loader to read and write the cookie: + +```tsx +import { prefs } from "./prefs-cookie"; + +// read the state from the cookie +export function loader({ request }) { + const cookieHeader = request.headers.get("Cookie"); + const cookie = await prefs.parse(cookieHeader); + return { sidebarIsOpen: cookie.sidebarIsOpen }; +} + +// write the state to the cookie +export function action({ request }) { + const cookieHeader = request.headers.get("Cookie"); + const cookie = await prefs.parse(cookieHeader); + const formData = await request.formData(); + + const isOpen = formData.get("sidebar") === "open"; + cookie.sidebarIsOpen = isOpen; + + return json(isOpen, { + headers: { + "Set-Cookie": await prefs.serialize(cookie), + }, + }); +} +``` + +After the server code is set up, we can use the cookie state in our UI: + +```tsx +function Sidebar({ children }) { + const fetcher = useFetcher(); + let { sidebarIsOpen } = useLoaderData(); + + // use optimistic UI to immediately change the UI state + if (fetcher.formData?.has("sidebar")) { + sidebarIsOpen = + fetcher.formData.get("sidebar") === "open"; + } + + return ( + + + + {sidebarIsOpen ? "Close" : "Open"} + + + + + ); +} +``` + +While this is certainly more code that touches more of the application to account for the network requests and responses, the UX is greatly improved. Additionally, state comes from a single source of truth without any state synchronization required. + +In summary, each of the discussed methods offers a unique set of benefits and challenges: + +- **React state**: Offers simple but transient state management. +- **Local Storage**: Provides persistence but with synchronization requirements and UI flickering. +- **Cookies**: Delivers robust, persistent state management at the cost of added boilerplate. + +None of these are wrong, but if you want to persist the state across visits, cookies offer the best user experience. + ### Form Validation and Action Data -While client side validation is a great way to enhance the user experience, you can get similar UX by skipping the client side states and letting the server handle it. +Client-side validation can augment the user experience, but similar enhancements can be achieved by leaning more towards server-side processing and letting it handle the complexities. -This example is a doozy, it's certainly got bugs, and there are libraries that can help, but it illustrates the complexity and touch points of managing your own network state, synchronizing state from the server, and doubling up on form validation between the client and server. +The following example illustrates the inherent complexities of managing network state, coordinating state from the server, and implementing validation redundantly on both the client and server sides. It's just for illustration, so forgive any obvious bugs or problems you find. ```tsx bad lines=[2,14,30,41,66] export function Signup() { - // managing a lot of React State + // A multitude of React State declarations const [isSubmitting, setIsSubmitting] = React.useState(false); @@ -157,7 +351,7 @@ export function Signup() { const [passwordError, setPasswordError] = React.useState(""); - // Duplicating some server logic in the browser + // Replicating server-side logic in the client function validateForm() { setUserNameError(null); setPasswordError(null); @@ -173,7 +367,7 @@ export function Signup() { return Boolean(errors); } - // managing the network yourself + // Manual network interaction handling async function handleSubmit() { if (validateForm()) { setSubmitting(true); @@ -184,7 +378,7 @@ export function Signup() { const json = await res.json(); setIsSubmitting(false); - // synchronizing server state to the client + // Server state synchronization to the client if (json.errors) { if (json.errors.userName) { setUserNameError(json.errors.userName); @@ -209,7 +403,7 @@ export function Signup() { name="username" value={userName} onChange={() => { - // synchronizing form state for the fetch + // Synchronizing form state for the fetch setUserName(event.target.value); }} /> @@ -221,7 +415,7 @@ export function Signup() { type="password" name="password" onChange={(event) => { - // synchronizing form state for the fetch + // Synchronizing form state for the fetch setPassword(event.target.value); }} /> @@ -238,7 +432,7 @@ export function Signup() { } ``` -The backend API at `/api/signup` also validates and returns errors. It needs to run server side to check things like duplicate user names, etc. Stuff the client can't know. +The backend endpoint, `/api/signup`, also performs validation and sends error feedback. Note that some essential validation, like detecting duplicate usernames, can only be done server-side using information the client doesn't have access to. ```tsx export function signupHandler(request) { @@ -251,7 +445,7 @@ export function signupHandler(request) { } ``` -Now consider the same example with Remix. The action is identical, but the component is much simpler since you can use the server state directly from `useActionData` and read the network state Remix is already managing. +Now, let's contrast this with a Remix-based implementation. The action remains consistent, but the component is vastly simplified due to the direct utilization of server state via `useActionData`, and leveraging the network state that Remix inherently manages. ```tsx filename=app/routes/signup.tsx lines=[19-21] import { @@ -298,11 +492,13 @@ export function Signup() { } ``` -All of the previous state management gets collapsed into three lines of code. There is no need for React state, change event listeners, submit handlers, or state management libraries for a network interaction like this. +The extensive state management from our previous example is distilled into just three code lines. We eliminate the necessity for React state, change event listeners, submit handlers, and state management libraries for such network interactions. + +Direct access to the server state is made possible through `useActionData`, and network state through `useNavigation` (or `useFetcher`). -The server state is available directly from `useActionData` and the network state is available from `useNavigation`. If you find yourself managing and synchronizing state for network interactions, there's probably a simpler way to do it in Remix. +As bonus party trick, the form is functional even before JavaScript loads. Instead of Remix managing the network operations, the default browser behaviors step in. -As bonus a party trick, the form will still work if JavaScript fails to load. Instead of Remix managing the network, the browser will manage it. +If you ever find yourself entangled in managing and synchronizing state for network operations, Remix likely offers a more elegant solution. [fullstack-data-flow]: ./03-data-flow [pending-ui]: ./07-pending-ui diff --git a/docs/discussion/09-concurrency.md b/docs/discussion/09-concurrency.md new file mode 100644 index 00000000000..79dcb8d3583 --- /dev/null +++ b/docs/discussion/09-concurrency.md @@ -0,0 +1,126 @@ +--- +title: Network Concurrency Management +--- + +# Network Concurrency Management + +When building web applications, managing network requests can be a daunting task. The challenges of ensuring up-to-date data and handling simultaneous requests often lead to complex logic in the application to deal with interruptions and race conditions. Remix simplifies this process by automating network management, mirroring and expanding the intuitive behavior of web browsers. + +To help understand how Remix works, remember from [Fullstack Data Flow][fullstack-data-flow] that after form submissions, Remix will fetch fresh data from the loaders. This is called revalidation. + +## Natural Alignment with Browser Behavior + +Remix's handling of network concurrency is heavily inspired by the default behavior of web browsers when processing documents: + +- **Browser Link Navigation**: When you click on a link in a browser and then click on another before the page transition completes, the browser prioritizes the most recent action. It cancels the initial request, focusing solely on the latest link clicked. + + - **Remix's Approach**: Remix manages client-side navigation the same way. When a link is clicked within a Remix application, it initiates fetch requests for each loader tied to the target URL. If another navigation interrupts the initial navigation, Remix cancels the previous fetch requests, ensuring that only the latest requests proceed. + +- **Browser Form Submission**: If you initiate a form submission in a browser and then quickly submit another form again, the browser disregards the first submission, processing only the latest one. + + - **Remix's Approach**: Remix mimics this behavior when working with forms. If a form is submitted and another submission occurs before the first completes, Remix cancels the original fetch requests. It then waits for the latest submission to complete before triggering page revalidation again. + +## Concurrent Submissions and Revalidation + +While standard browsers are limited to one request at a time for navigations and form submissions, Remix elevates this behavior. Unlike navigation, with `useFetcher` multiple requests can be in flight simultaneously. + +Remix is designed to handle multiple form submissions to server actions and concurrent revalidation requests efficiently. It ensures that as soon as new data is available, the state is updated promptly. However, Remix also safeguards against potential pitfalls by refraining from committing stale data when other actions introduce race conditions. + +For instance, if three form submissions are in progress, and one completes, Remix updates the UI with that data immediately without waiting for the other two so that the UI remains responsive and dynamic. As the remaining submissions finalize, Remix continues to update the UI, ensuring that the most recent data is displayed. + +To help understand some visualizations, below is a key for the symbols used in the diagrams: + +- `|`: Submission begins +- โ: Action complete, data revalidation begins +- โ : Revalidated data is committed to the UI +- โ: Request cancelled + +```text +submission 1: |----โ-----โ +submission 2: |-----โ-----โ +submission 3: |-----โ-----โ +``` + +However, if a subsequent submission's revalidation completes before an earlier one, Remix discards the earlier data, ensuring that only the most up-to-date information is reflected in the UI. + +```text +submission 1: |----โ---------โ +submission 2: |-----โ-----โ +submission 3: |-----โ-----โ +``` + +Because the revalidation from submission (2) started later and landed earlier than submission (1), the requests from submission (1) are cancelled and only the data from submission (2) is committed to the UI. It was requested later so its more likely to contain the updated values from both (1) and (2). + +## Potential for Stale Data + +It's unlikely your users will ever experience this, but there are still chances for the user to see stale data in very rare conditions with inconsistent infrastructure. Even though Remix cancels requests for stale data, they will still end up making it to the server. Cancelling a request in the browser simply releases browser resources for that request, it can't "catch up" and stop it from getting to the server. In extremely rare conditions, a cancelled request may change data after the interrupting actions's revalidation lands. Consider this diagram: + +```text + ๐ interruption with new submission +|----โ----------------------โ + |-------โ-----โ + ๐ + initial request reaches the server + after the interrupting submission + has completed revalidation +``` + +The user is now looking at different data than what is on the server. Note that this problem is both extremely rare and exists with default browser behavior, too. The chance of the initial request reaching the server later than both the submission and revalidation of the second is unexpected on any network and server infrastructure. If this is a concern in with your infrastructure, you can send time stamps with your form submissions and write server logic to ignore stale submissions. + +## Example + +In UI components like comboboxes, each keystroke can trigger a network request. Managing such rapid, consecutive requests can be tricky, especially when ensuring that the displayed results match the most recent query. However, with Remix, this challenge is automatically handled, ensuring that users see the correct results without developers having to micro-manage the network. + +```tsx filename=app/routes/city-search.tsx +import { json } from "@remix-run/react"; + +export async function loader({ request }: LoaderArgs) { + const { searchParams } = new URL(request.url); + const cities = await searchCities(searchParams.get("q")); + return json(cities); +} + +export function CitySearchCombobox() { + const fetcher = useFetcher(); + + return ( + + + + // submit the form onChange to get the list of cities + fetcher.submit(event.target.form) + } + /> + + {/* render with the loader's data */} + {fetcher.data ? ( + + {fetcher.data.length ? ( + + {fetcher.data.map((city) => ( + + ))} + + ) : ( + No results found + )} + + ) : null} + + + ); +} +``` + +All the application needs to know is how to query the data and how to render it, Remix handles the network. + +## Conclusion + +Remix offers developers an intuitive, browser-based approach to managing network requests. By mirroring browser behaviors and enhancing them where needed, it simplifies the complexities of concurrency, revalidation, and potential race conditions. Whether you're building a simple webpage or a sophisticated web application, Remix ensures that your user interactions are smooth, reliable, and always up-to-date. + +[fullstack-data-flow]: ./03-data-flow diff --git a/docs/discussion/10-form-vs-fetcher.md b/docs/discussion/10-form-vs-fetcher.md new file mode 100644 index 00000000000..f6ad9eb05d4 --- /dev/null +++ b/docs/discussion/10-form-vs-fetcher.md @@ -0,0 +1,192 @@ +--- +title: Form vs. fetcher +--- + +# Form vs. fetcher + +Developing in Remix offers a rich set of tools that can sometimes overlap in functionality, creating a sense of ambiguity for newcomers. The key to effective development in Remix is understanding the nuances and appropriate use cases for each tool. This document seeks to provide clarity on when and why to use specific APIs. + +## APIs in Focus + +- `` +- `useNavigation` +- `useActionData` +- `useFetcher` + +Understanding the distinctions and intersections of these APIs is vital for efficient and effective Remix development. + +### URL Considerations + +The primary criterion when choosing among these tools is whether you want the URL to change or not: + +- **URL Change Desired**: When navigating or transitioning between pages, or after certain actions like creating or deleting records. This ensures that the user's browser history accurately reflects their journey through your application. + + - **Expected Behavior**: In many cases, when users hit the back button, they should be taken to the previous page. Other times the history entry may be replaced but the URL change is important nonetheless. + +- **No URL Change Desired**: For actions that don't significantly change the context or primary content of the current view. This might include updating individual fields or minor data manipulations that don't warrant a new URL or page reload. + +### Specific Use Cases + +#### When the URL Should Change + +These actions typically reflect significant changes to the user's context or state: + +- **Creating a New Record**: After creating a new record, it's common to redirect users to a page dedicated to that new record, where they can view or further modify it. + +- **Deleting a Record**: If a user is on a page dedicated to a specific record and decides to delete it, the logical next step is to redirect them to a general page, such as a list of all records. + +For these cases, developers should consider using a combination of ``, `useSubmit`, `useActionData`, and `useNavigation`. Each of these tools can be coordinated to handle form submission, invoke specific actions, retrieve action-related data, and manage navigation respectively. + +#### When the URL Shouldn't Change + +These actions are generally more subtle and don't require a context switch for the user: + +- **Updating a Single Field**: Maybe a user wants to change the name of an item in a list or update a specific property of a record. This action is minor and doesn't necessitate a new page or URL. + +- **Deleting a Record from a List**: In a list view, if a user deletes an item, they likely expect to remain on the list view, with that item simply disappearing. + +- **Creating a Record in a List View**: When adding a new item to a list, it often makes sense for the user to remain in that context, seeing their new item added to the list without a full page transition. + +For such actions, `useFetcher` is the go-to API. It's versatile, combining functionalities of the other four APIs, and is perfectly suited for tasks where the URL should remain unchanged. + +## API Comparison + +As you can see, the two sets of APIs have a lot of similarities: + +| Navigation/URL API | Fetcher API | +| ----------------------- | -------------------- | +| `` | `` | +| `useActionData()` | `fetcher.data` | +| `navigation.state` | `fetcher.state` | +| `navigation.formAction` | `fetcher.formAction` | +| `navigation.formData` | `fetcher.formData` | + +## Examples + +### Creating a New Record + +```tsx filename=app/routes/recipes/new.tsx lines=[15,19,20,25] +import { redirect } from "@remix-run/node"; +import { + Form, + useNavigation, + useActionData, +} from "@remix-run/react"; + +export function action({ request }) { + const formData = await request.formData(); + const errors = await validateRecipeFormData(formData); + if (errors) { + return errors; + } + const recipe = await db.recipes.create(formData); + return redirect(`/recipes/${recipe.id}`); +} + +export function NewRecipe() { + const errors = useActionData(); + const navigation = useNavigation(); + const isSubmitting = + navigation.formAction === "/recipes/new"; + + return ( + + + Title: + {errors?.title && {errors.title}} + + + Ingredients: + {errors?.ingredients && ( + {errors.ingredients} + )} + + + Directions: + {errors?.directions && ( + {errors.directions} + )} + + + {isSubmitting ? "Saving..." : "Create Recipe"} + + + ); +} +``` + +The example leverages ``, ` useActionData``, and `useNavigation` to facilitate an intuitive record creation process. + +Using `` ensures direct and logical navigation. After creating a record, the user is naturally guided to the new recipe's unique URL, reinforcing the outcome of their action. + +`useActionData` bridges server and client, providing immediate feedback on submission issues. This quick response enables users to rectify any errors without hindrance. + +Lastly, `useNavigation` dynamically reflects the form's submission state. This subtle UI change, like toggling the button's label, assures users that their actions are being processed. + +Combined, these APIs offer a balanced blend of structured navigation and feedback. + +### Updating a Record + +Now consider we're looking at a list of recipes that have delete buttons on each item. When a user clicks the delete button, we want to delete the recipe from the database and remove it from the list without navigating away from the list. + +First consider the basic route setup to get a list of recipes on the page: + +```tsx filename=app/routes/recipes/index.tsx +export async function loader({ request }) { + return db.recipes.findAll({ limit: 30 }); +} + +export function Recipes() { + const recipes = useLoaderData(); + return ( + + {recipes.map((recipe) => ( + + ))} + + ); +} +``` + +Now we'll look at the action that deletes a recipe and the component that renders each recipe in the list. + +```tsx filename=app/routes/recipes/index.tsx lines=[5,9,15] +export function action({ request }) { + const formData = await request.formData(); + const id = formData.get("id"); + await db.recipes.delete(id); + return { ok: true }; +} + +function RecipeListItem({ recipe }) { + const fetcher = useFetcher(); + const isDeleting = fetcher.state !== "idle"; + + return ( + + {recipe.title} + + + {isDeleting ? "Deleting..." : "Delete"} + + + + ); +} +``` + +Using useFetcher in this scenario works perfectly. Instead of navigating away or refreshing the entire page, we want in-place updates. When a user deletes a recipe, the action called and the fetcher manages the corresponding state transitions. + +The key advantage here is the maintenance of context. The user stays on the list when the deletion completes. The fetcher's state management capabilities are leveraged to give real-time feedback: it toggles between "Deleting..." and "Delete", providing a clear indication of the ongoing process. + +Furthermore, with each fetcher having the autonomy to manage its own state, operations on individual list items become independent, ensuring that actions on one item don't affect the others (though revalidation of the page data is a shared concern that is covered in [Network Concurrency Management](./09-concurrency)). + +In essence, useFetcher offers a seamless mechanism for actions that don't necessitate a change in the URL or navigation, enhancing the user experience by providing real-time feedback and context preservation. + +## Conclusion + +Remix offers a range of tools to cater to varied developmental needs. While some functionalities might seem to overlap, each tool has been crafted with specific scenarios in mind. By understanding the intricacies and ideal applications of ``, `useSubmit`, `useNavigation`, `useActionData`, and `useFetcher`, developers can create more intuitive, responsive, and user-friendly web applications. diff --git a/docs/file-conventions/routes.md b/docs/file-conventions/routes.md index c57c05436aa..f254996ac4d 100644 --- a/docs/file-conventions/routes.md +++ b/docs/file-conventions/routes.md @@ -144,7 +144,7 @@ Nested Routing is the general idea of coupling segments of the URL to component You create nested routes with [dot delimiters][dot-delimiters]. If the filename before the `.` matches another route filename, it automatically becomes a child route to the matching parent. Consider these routes: -```markdown lines=[5-8] +```markdown app/ โโโ routes/ โ โโโ _index.tsx @@ -168,6 +168,16 @@ All the routes that start with `concerts.` will be child routes of `concerts.tsx Note you typically want to add an index route when you add nested routes so that something renders inside the parent's outlet when users visit the parent URL directly. +For example, if the URL is `/concerts/salt-lake-city` then the UI hierarchy will look like this: + +```tsx + + + + + +``` + ## Nested URLs without Layout Nesting Sometimes you want the URL to be nested, but you don't want the automatic layout nesting. You can opt out of nesting with a trailing underscore on the parent segment: diff --git a/docs/guides/14-error-handling.md b/docs/guides/14-error-handling.md new file mode 100644 index 00000000000..d4043d12ef3 --- /dev/null +++ b/docs/guides/14-error-handling.md @@ -0,0 +1,16 @@ +--- +title: Error Handling +hidden: true +--- + +# Error Handling + +- unexpected + - automatically handled + - granular w/ route boundaries + - granular w/ `` boundaries +- expected + - 404s + - 401s + - 503s + - can send data! diff --git a/docs/start/tutorial.md b/docs/start/tutorial.md index b948328fe22..9234834c06e 100644 --- a/docs/start/tutorial.md +++ b/docs/start/tutorial.md @@ -5,7 +5,7 @@ order: 2 # Remix Tutorial -We'll be building a small, but feature-rich app that lets you keep track of your contacts. There's no database or other "production ready" things so we can stay focused on Remix. We expect it to take about 30m if you're following along, otherwise it's a quick read. Check the other tutorials for more in-depth examples. +We'll be building a small, but feature-rich app that lets you keep track of your contacts. There's no database or other "production ready" things, so we can stay focused on Remix. We expect it to take about 30m if you're following along, otherwise it's a quick read. Check the other tutorials for more in-depth examples. @@ -21,7 +21,7 @@ The rest is just there for your information and deeper understanding. Let's get npx create-remix@latest --template ryanflorence/remix-tutorial-template ``` -This uses a pretty bare-bones template but includes our css and data model so we can focus on Remix. The [Quick Start][quickstart] can familiarize you with the basic setup of a Remix project if you'd like to learn more. +This uses a pretty bare-bones template but includes our css and data model, so we can focus on Remix. The [Quick Start][quickstart] can familiarize you with the basic setup of a Remix project if you'd like to learn more. ๐ **Start the app** @@ -48,14 +48,14 @@ Note the file at `app/root.tsx`. This is what we call the "Root Route". It's the Expand here to see the root component code -```jsx filename=src/routes/root.jsx +```tsx filename=app/root.tsx import { + Form, Links, LiveReload, Meta, Scripts, ScrollRestoration, - Form, } from "@remix-run/react"; export default function Root() { @@ -65,7 +65,7 @@ export default function Root() { @@ -76,16 +76,16 @@ export default function Root() { @@ -119,22 +119,24 @@ export default function Root() { While there are multiple ways to style your Remix app, we're going to use a plain stylesheet that's already been written to keep things focused on Remix. -You can import CSS files directly into JavaScript modules. The compiler will fingerprint the asset, save it to your [`assetsBuildDirectory`][assetbuilddir], and provide your module with the publicly accessible href. +You can import CSS files directly into JavaScript modules. The compiler will fingerprint the asset, save it to your [`assetsBuildDirectory`][assets-build-directory], and provide your module with the publicly accessible href. ๐ **Import the app styles** -```jsx filename=app/root.tsx +```tsx filename=app/root.tsx lines=[1,4,6-8] +import type { LinksFunction } from "@remix-run/node"; // existing imports + import appStylesHref from "./app.css"; -export function links() { - return [{ rel: "stylesheet", href: appStylesHref }]; -} +export const links: LinksFunction = () => [ + { rel: "stylesheet", href: appStylesHref }, +]; ``` -Every route can export [`links`][links]. They will be collected and rendered into the `` component we rendered in `app/root.tsx`. +Every route can export a [`links`][links] function. They will be collected and rendered into the `` component we rendered in `app/root.tsx`. -The app should look something like this now. It sure is nice having a designer who can also write the CSS, isn't it? (Thank you [Jim][jim] ๐). +The app should look something like this now. It sure is nice having a designer who can also write the CSS, isn't it? (Thank you, [Jim][jim] ๐). @@ -142,15 +144,14 @@ The app should look something like this now. It sure is nice having a designer w If you click on one of the sidebar items you'll get the default 404 page. Let's create a route that matches the url `/contacts/1`. -๐ **Create the contact route module** +๐ **Create the `app/routes` directory and contact route module** ```shellscript nonumber -touch app/routes/contacts.$contactId.tsx -# you might have to escape the $ +mkdir app/routes touch app/routes/contacts.\$contactId.tsx ``` -In the Remix [route file convention][routeconvention], `.` will create a `/` in the URL and `$` makes a segment dynamic. We just created a route that will match URLs that look like this: +In the Remix [route file convention][routes-file-conventions], `.` will create a `/` in the URL and `$` makes a segment dynamic. We just created a route that will match URLs that look like this: - `/contacts/123` - `/contacts/abc` @@ -161,6 +162,7 @@ It's just a bunch of elements, feel free to copy/paste. ```tsx filename=app/routes/contacts.$contactId.tsx import { Form } from "@remix-run/react"; +import type { FunctionComponent } from "react"; import type { ContactRecord } from "../data"; @@ -212,14 +214,15 @@ export default function Contact() { Edit + { const response = confirm( "Please confirm you want to delete this record." ); - if (response === false) { + if (!response) { event.preventDefault(); } }} @@ -232,25 +235,27 @@ export default function Contact() { ); } -function Favorite({ contact }: { contact: ContactRecord }) { - // yes, this is a `let` for later +const Favorite: FunctionComponent<{ + contact: Pick; +}> = ({ contact }) => { const favorite = contact.favorite; + return ( {favorite ? "โ " : "โ"} ); -} +}; ``` Now if we click one of the links or visit `/contacts/1` we get ... nothing new? @@ -263,18 +268,31 @@ Since Remix is built on top of React Router, it supports nested routing. In orde ๐ **Render an [``][outlet]** -```jsx filename=app/root.jsx lines=[1,8-10] -import { Outlet } from "@remix-run/react"; -/* existing imports */ +```tsx filename=app/root.tsx lines=[7,20-22] +// existing imports +import { + Form, + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +// existing imports & code export default function Root() { return ( - - {/* all the other elements */} - - - - {/* all the other elements */} + + {/* other elements */} + + {/* other elements */} + + + + {/* other elements */} + ); } @@ -292,30 +310,42 @@ Client side routing allows our app to update the URL without requesting another ๐ **Change the sidebar `` to ``** -```jsx filename=app/root.jsx lines=[2,13,16] -/* existing imports */ -import { Link } from "@remix-run/react"; +```tsx filename=app/root.tsx lines=[4,25,28] +// existing imports +import { + Form, + Link, + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +// existing imports & exports export default function Root() { return ( - <> - - {/* other elements */} - - - - - Your Name - - - Your Friend - - - - + + {/* other elements */} + + + {/* other elements */} + + + + Your Name + + + Your Friend + + + + {/* other elements */} - - > + + ); } ``` @@ -333,63 +363,73 @@ URL segments, layouts, and data are more often than not coupled (tripled?) toget Because of this natural coupling, Remix has data conventions to get data into your route components easily. -There are two APIs we'll be using to load data, [`loader`][loader] and [`useLoaderData`][useloaderdata]. First we'll create and export a loader function in the root route and then render the data. +There are two APIs we'll be using to load data, [`loader`][loader] and [`useLoaderData`][use-loader-data]. First we'll create and export a `loader` function in the root route and then render the data. -๐ **Export a loader from `root.jsx` and render the data** +๐ **Export a `loader` function from `app/root.tsx` and render the data** -```jsx filename=app/root.tsx lines=[2,4,6-9,14,25-46] -/* existing imports */ -import { useLoaderData } from "@remix-run/react"; +```tsx filename=app/root.tsx lines=[2,12,16,20-23,26,35-58] +// existing imports +import { json } from "@remix-run/node"; +import { + Form, + Link, + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, +} from "@remix-run/react"; +// existing imports import { getContacts } from "./data"; -export async function loader() { - const contacts = await getContacts(); - return { contacts }; -} +// existing exports -/* other code */ +export const loader = async () => { + const contacts = await getContacts(); + return json({ contacts }); +}; export default function Root() { const { contacts } = useLoaderData(); return ( - - {/* other code */} - - Remix Contacts - - {/* other code */} - - - {contacts.length ? ( - - {contacts.map((contact) => ( - - - {contact.first || contact.last ? ( - <> - {contact.first} {contact.last} - > - ) : ( - No Name - )}{" "} - {contact.favorite ? ( - โ - ) : null} - - - ))} - - ) : ( - - No contacts - - )} - - - - {/* other code */} + + {/* other elements */} + + + {/* other elements */} + + {contacts.length ? ( + + {contacts.map((contact) => ( + + + {contact.first || contact.last ? ( + <> + {contact.first} {contact.last} + > + ) : ( + No Name + )}{" "} + {contact.favorite ? ( + โ + ) : null} + + + ))} + + ) : ( + + No contacts + + )} + + + {/* other elements */} + ); } @@ -403,10 +443,13 @@ That's it! Remix will now automatically keep that data in sync with your UI. The You may have noticed TypeScript complaining about the `contact` type inside the map. We can add a quick annotation to get type inference about our data with `typeof loader`: -```tsx filename=app/root.tsx +```tsx filename=app/root.tsx lines=[4] +// existing imports & exports + export default function Root() { const { contacts } = useLoaderData(); - // ... + + // existing code } ``` @@ -418,28 +461,33 @@ We should be seeing our old static contact page again, with one difference: the -Remember the `$contactId` part of the file name at `routes/contacts.$contactId.tsx`? These dynamic segments will match dynamic (changing) values in that position of the URL. We call these values in the URL "URL Params", or just "params" for short. +Remember the `$contactId` part of the file name at `app/routes/contacts.$contactId.tsx`? These dynamic segments will match dynamic (changing) values in that position of the URL. We call these values in the URL "URL Params", or just "params" for short. These [`params`][params] are passed to the loader with keys that match the dynamic segment. For example, our segment is named `$contactId` so the value will be passed as `params.contactId`. These params are most often used to find a record by ID. Let's try it out. -๐ **Add a loader to the contact page and access data with `useLoaderData`** +๐ **Add a `loader` function to the contact page and access data with `useLoaderData`** -```tsx filename=app/routes/contacts.$contactId.tsx lines=[1,3,5-7,11] +```tsx filename=app/routes/contacts.$contactId.tsx lines=[1-2,5,7-10,13] +import { json } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; +// existing imports import { getContact } from "../data"; -export async function loader({ params }) { +export const loader = async ({ params }) => { const contact = await getContact(params.contactId); - return contact; -} + return json({ contact }); +}; export default function Contact() { - const contact = useLoaderData(); + const { contact } = useLoaderData(); + // existing code } + +// existing code ``` @@ -448,23 +496,20 @@ export default function Contact() { TypeScript is very upset with us, let's make it happy and see what that forces us to consider: -```tsx filename=app/routes/contacts.$contactId.tsx lines=[2,3,7-8,14] +```tsx filename=app/routes/contacts.$contactId.tsx lines=[1,3,7-8] import type { LoaderArgs } from "@remix-run/node"; -import { Form, useLoaderData } from "@remix-run/react"; +// existing imports import invariant from "tiny-invariant"; -import { getContact } from "../data"; +// existing imports -export async function loader({ params }: LoaderArgs) { +export const loader = async ({ params }: LoaderArgs) => { invariant(params.contactId, "Missing contactId param"); const contact = await getContact(params.contactId); - return contact; -} + return json({ contact }); +}; -export default function Contact() { - const contact = useLoaderData(); - // existing code -} +// existing code ``` First problem this highlights is we might have gotten the param's name wrong between the file name and the code (maybe you changed the name of the file!). Invariant is a handy function for throwing an error with a custom message when you anticipated a potential issue with your code. @@ -473,15 +518,19 @@ Next, the `useLoaderData()` now knows that we got a contact or `n We could account for the possibility of the contact being not found in component code, but the webby thing to do is send a proper 404. We can do that in the loader and solve all of our problems at once. -```tsx filename=app/routes/contacts.$contactId.tsx lines=[4-6] -export async function loader({ params }: LoaderArgs) { +```tsx filename=app/routes/contacts.$contactId.tsx lines=[6-8] +// existing imports + +export const loader = async ({ params }: LoaderArgs) => { invariant(params.contactId, "Missing contactId param"); const contact = await getContact(params.contactId); if (!contact) { throw new Response("Not Found", { status: 404 }); } - return contact; -} + return json({ contact }); +}; + +// existing code ``` Now, if the user isn't found, code execution down this path stops and Remix renders the error path instead. Components in Remix can focus only on the happy path ๐ @@ -492,9 +541,9 @@ We'll create our first contact in a second, but first let's talk about HTML. Remix emulates HTML Form navigation as the data mutation primitive, which used to be the only way prior to the JavaScript cambrian explosion. Don't be fooled by the simplicity! Forms in Remix give you the UX capabilities of client rendered apps with the simplicity of the "old school" web model. -While unfamiliar to some web developers, HTML forms actually cause a navigation in the browser, just like clicking a link. The only difference is in the request: links can only change the URL while forms can also change the request method (GET vs POST) and the request body (POST form data). +While unfamiliar to some web developers, HTML `form`s actually cause a navigation in the browser, just like clicking a link. The only difference is in the request: links can only change the URL while `form`s can also change the request method (`GET` vs. `POST`) and the request body (`POST` form data). -Without client side routing, the browser will serialize the form's data automatically and send it to the server as the request body for POST, and as URLSearchParams for GET. Remix does the same thing, except instead of sending the request to the server, it uses client side routing and sends it to a route [`action`][action]. +Without client side routing, the browser will serialize the `form`'s data automatically and send it to the server as the request body for `POST`, and as [`URLSearchParams`][url-search-params] for `GET`. Remix does the same thing, except instead of sending the request to the server, it uses client side routing and sends it to the route's [`action`][action] function. We can test this out by clicking the "New" button in our app. @@ -504,43 +553,44 @@ Remix sends a 405 because there is no code on the server to handle this form nav ## Creating Contacts -We'll create new contacts by exporting an `action` in our root route. When the user clicks the "new" button, the form will POST to the root route action. +We'll create new contacts by exporting an `action` function in our root route. When the user clicks the "new" button, the form will POST to the root route action. -๐ **Create the `action`** +๐ **Export an `action` function from `app/root.tsx`** -```jsx filename=src/routes/root.jsx lines=[2,4-7] -// existing code -import { getContacts, createEmptyContact } from "../data"; +```tsx filename=app/root.tsx lines=[3,5-8] +// existing imports + +import { createEmptyContact, getContacts } from "./data"; -export async function action() { +export const action = async () => { const contact = await createEmptyContact(); - return { contact }; -} + return json({ contact }); +}; -/* other code */ +// existing code ``` -That's it! Go ahead and click the "New" button and you should see a new record pop into the list ๐ฅณ +That's it! Go ahead and click the "New" button, and you should see a new record pop into the list ๐ฅณ The `createEmptyContact` method just creates an empty contact with no name or data or anything. But it does still create a record, promise! -> ๐ง Wait a sec ... How did the sidebar update? Where did we call the `action`? Where's the code to refetch the data? Where are `useState`, `onSubmit` and `useEffect`?! +> ๐ง Wait a sec ... How did the sidebar update? Where did we call the `action` function? Where's the code to re-fetch the data? Where are `useState`, `onSubmit` and `useEffect`?! -This is where the "old school web" programming model shows up. [``][form] prevents the browser from sending the request to the server and sends it to your route `action` instead with `fetch`. +This is where the "old school web" programming model shows up. [``][form] prevents the browser from sending the request to the server and sends it to your route's `action` function instead with [`fetch`][fetch]. -In web semantics, a POST usually means some data is changing. By convention, Remix uses this as a hint to automatically revalidate the data on the page after the action finishes. +In web semantics, a `POST` usually means some data is changing. By convention, Remix uses this as a hint to automatically revalidate the data on the page after the `action` finishes. -In fact, since it's all just HTML and HTTP, you could disable JavaScript and the whole thing will still work. Instead of Remix serializing the form and making a `fetch` to your server, the browser will serialize the form and make a document request. From there Remix will render the page server side and send it down. It's the same UI in the end either way. +In fact, since it's all just HTML and HTTP, you could disable JavaScript and the whole thing will still work. Instead of Remix serializing the form and making a [`fetch`][fetch] request to your server, the browser will serialize the form and make a document request. From there Remix will render the page server side and send it down. It's the same UI in the end either way. -We'll keep JavaScript around though because we're gonna make a better user experience than spinning favicons and static documents. +We'll keep JavaScript around though because we're going to make a better user experience than spinning favicons and static documents. ## Updating Data Let's add a way to fill the information for our new record. -Just like creating data, you update data with [``][form]. Let's make a new route at `routes/contacts.$contactId_.edit.tsx`. +Just like creating data, you update data with [``][form]. Let's make a new route at `app/routes/contacts.$contactId_.edit.tsx`. ๐ **Create the edit component** @@ -548,7 +598,7 @@ Just like creating data, you update data with [``][form]. Let's make a new touch app/routes/contacts.\$contactId_.edit.tsx ``` -Note the weird `_` in `$contactId_`. By default, routes will automatically nest inside routes with the same prefixed name. Adding a trialing `_` tells the route to **not** nest inside `routes/contacts.$contactId.tsx`. Read more in the [Route File Naming][routeconvention] guide. +Note the weird `_` in `$contactId_`. By default, routes will automatically nest inside routes with the same prefixed name. Adding a trialing `_` tells the route to **not** nest inside `app/routes/contacts.$contactId.tsx`. Read more in the [Route File Naming][routes-file-conventions] guide. ๐ **Add the edit page UI** @@ -556,66 +606,67 @@ Nothing we haven't seen before, feel free to copy/paste: ```tsx filename=app/routes/contacts.$contactId.edit.tsx import type { LoaderArgs } from "@remix-run/node"; +import { json } from "@remix-run/node"; import { Form, useLoaderData } from "@remix-run/react"; import invariant from "tiny-invariant"; import { getContact } from "../data"; -export async function loader({ params }: LoaderArgs) { +export const loader = async ({ params }: LoaderArgs) => { invariant(params.contactId, "Missing contactId param"); const contact = await getContact(params.contactId); if (!contact) { - throw new Response("Not found", { status: 404 }); + throw new Response("Not Found", { status: 404 }); } - return contact; -} + return json({ contact }); +}; export default function EditContact() { - const contact = useLoaderData(); + const { contact } = useLoaderData(); return ( - + Name Twitter Avatar URL Notes @@ -632,33 +683,34 @@ Now click on your new record, then click the "Edit" button. We should see the ne -## Updating Contacts with FormData +## Updating Contacts with `FormData` -The edit route we just created already renders a form. All we need to do is add the action. Remix will serialize the form, POST it with `fetch`, and automatically revalidate all the data. +The edit route we just created already renders a `form`. All we need to do is add the `action` function. Remix will serialize the `form`, `POST` it with [`fetch`][fetch], and automatically revalidate all the data. -๐ **Add an action to the edit route** +๐ **Add an `action` function to the edit route** -```tsx filename=app/routes/contacts.$contactId_.edit.tsx -import { redirect } from "@remix-run/node"; +```tsx filename=app/routes/contacts.$contactId_.edit.tsx lines=[2,5,8,10-19] import type { - LoaderArgs, ActionArgs, + LoaderArgs, } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +// existing imports import { getContact, updateContact } from "../data"; -export async function action({ - request, +export const action = async ({ params, -}: ActionArgs) { + request, +}: ActionArgs) => { + invariant(params.contactId, "Missing contactId param"); const formData = await request.formData(); const updates = Object.fromEntries(formData); - invariant(params.contactId, "Missing contactId param"); await updateContact(params.contactId, updates); return redirect(`/contacts/${params.contactId}`); -} +}; -/* existing code */ +// existing code ``` Fill out the form, hit save, and you should see something like this! (Except easier on the eyes and maybe less hairy.) @@ -671,61 +723,64 @@ Fill out the form, hit save, and you should see something like this! (Exc Let's dig in a bit... -Open up `contacts.$contactId_.edit.tsx` and look at the form elements. Notice how they each have a name: +Open up `contacts.$contactId_.edit.tsx` and look at the `form` elements. Notice how they each have a name: -```jsx lines=[5] filename=app/routes/contacts.$contactId_.edit.tsx +```tsx filename=app/routes/contacts.$contactId_.edit.tsx lines=[4] ``` -Without JavaScript, when a form is submitted, the browser will create [`FormData`][formdata] and set it as the body of the request when it sends it to the server. As mentioned before, Remix prevents that and emulates the browser by sending the request to your action with `fetch` instead, including the [`FormData`][formdata]. +Without JavaScript, when a form is submitted, the browser will create [`FormData`][form-data] and set it as the body of the request when it sends it to the server. As mentioned before, Remix prevents that and emulates the browser by sending the request to your `action` function with [`fetch`][fetch] instead, including the [`FormData`][form-data]. -Each field in the form is accessible with `formData.get(name)`. For example, given the input field from above, you could access the first and last names like this: +Each field in the `form` is accessible with `formData.get(name)`. For example, given the input field from above, you could access the first and last names like this: -```jsx lines=[3,4] -export async function action({ request, params }) { +```tsx lines=[3,4] nocopy +export const action = async ({ + params, + request, +}: ActionArgs) => { const formData = await request.formData(); const firstName = formData.get("first"); const lastName = formData.get("last"); // ... -} +}; ``` -Since we have a handful of form fields, we used [`Object.fromEntries`][fromentries] to collect them all into an object, which is exactly what our `updateContact` function wants. +Since we have a handful of form fields, we used [`Object.fromEntries`][object-from-entries] to collect them all into an object, which is exactly what our `updateContact` function wants. -```jsx +```tsx nocopy const updates = Object.fromEntries(formData); updates.first; // "Some" updates.last; // "Name" ``` -Aside from `action`, none of these APIs we're discussing are provided by Remix: [`request`][request], [`request.formData`][requestformdata], [`Object.fromEntries`][fromentries] are all provided by the web platform. +Aside from the `action` function, none of these APIs we're discussing are provided by Remix: [`request`][request], [`request.formData`][request-form-data], [`Object.fromEntries`][object-from-entries] are all provided by the web platform. -After we finished the action, note the [`redirect`][redirect] at the end: +After we finished the `action`, note the [`redirect`][redirect] at the end: -```tsx filename=src/routes/edit.jsx lines=[6] -export async function action({ - request, +```tsx filename=app/routes/contacts.$contactId_.edit.tsx lines=[9] +export const action = async ({ params, -}: ActionArgs) { + request, +}: ActionArgs) => { + invariant(params.contactId, "Missing contactId param"); const formData = await request.formData(); const updates = Object.fromEntries(formData); - invariant(params.contactId, "Missing contactId param"); await updateContact(params.contactId, updates); return redirect(`/contacts/${params.contactId}`); -} +}; ``` -Loaders and actions can both [return a `Response`][returningresponses] (makes sense, since they received a [`Request`][request]!). The [`redirect`][redirect] helper just makes it easier to return a [response][response] that tells the app to change locations. +`action` and `loader` functions can both [return a `Response`][returning-response-instances] (makes sense, since they received a [`Request`][request]!). The [`redirect`][redirect] helper just makes it easier to return a [`Response`][response] that tells the app to change locations. -Without client side routing, if a server redirected after a POST request, the new page would fetch the latest data and render. As we learned before, REmix emulates this model and automatically revalidates the data on the page after the action. That's why the sidebar automatically updates when we save the form. The extra revalidation code doesn't exist without client side routing, so it doesn't need to exist with client side routing in Remix either! +Without client side routing, if a server redirected after a `POST` request, the new page would fetch the latest data and render. As we learned before, Remix emulates this model and automatically revalidates the data on the page after the `action` call. That's why the sidebar automatically updates when we save the form. The extra revalidation code doesn't exist without client side routing, so it doesn't need to exist with client side routing in Remix either! -One last thing. Without JavaScript, the `redirect` would be a normal redirect. However, with JavaScript it's a clientside redirect, so the user doesn't lose client state like scroll positions or component state. +One last thing. Without JavaScript, the [`redirect`][redirect] would be a normal redirect. However, with JavaScript it's a clientside redirect, so the user doesn't lose client state like scroll positions or component state. ## Redirecting new records to the edit page @@ -733,14 +788,17 @@ Now that we know how to redirect, let's update the action that creates new conta ๐ **Redirect to the new record's edit page** -```tsx filename=app/routes/root.tsx lines=[2,6] -/* existing imports */ -import { redirect } from "@remix-run/node"; +```tsx filename=app/root.tsx lines=[2,7] +// existing imports +import { json, redirect } from "@remix-run/node"; +// existing imports -export async function action() { +export const action = async () => { const contact = await createEmptyContact(); return redirect(`/contacts/${contact.id}/edit`); -} +}; + +// existing code ``` Now when we click "New", we should end up on the edit page: @@ -749,25 +807,60 @@ Now when we click "New", we should end up on the edit page: ## Active Link Styling -Now that we have a bunch of records, it's not clear which one we're looking at in the sidebar. We can use [`NavLink`][navlink] to fix this. +Now that we have a bunch of records, it's not clear which one we're looking at in the sidebar. We can use [`NavLink`][nav-link] to fix this. ๐ **Replace `` with `` in the sidebar** -```tsx filename=app/routes/root.tsx lines=[2,6-13] -/* existing imports */ -import { NavLink } from "@remix-run/react"; +```tsx filename=app/root.tsx lines=[7,28-38,42] +// existing imports +import { + Form, + Links, + LiveReload, + Meta, + NavLink, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, +} from "@remix-run/react"; + +// existing imports and exports -// inside of the `contacts.map` change `Link` to `NavLink` - - - isActive ? "active" : isPending ? "pending" : "" - } - > - {/* other code */} - -; +export default function Root() { + const { contacts } = useLoaderData(); + + return ( + + {/* existing elements */} + + + {/* existing elements */} + + {contacts.map((contact) => ( + + + isActive + ? "active" + : isPending + ? "pending" + : "" + } + to={`contacts/${contact.id}`} + > + {/* existing elements */} + + + ))} + + {/* existing elements */} + + {/* existing elements */} + + + ); +} ``` Note that we are passing a function to `className`. When the user is at the URL that matches ``, then `isActive` will be true. When it's _about_ to be active (the data is still loading) then `isPending` will be true. This allows us to easily indicate where the user is and also provide immediate feedback when links are clicked but data needs to be loaded. @@ -778,39 +871,52 @@ Note that we are passing a function to `className`. When the user is at the URL As the user navigates the app, Remix will _leave the old page up_ as data is loading for the next page. You may have noticed the app feels a little unresponsive as you click between the list. Let's provide the user with some feedback so the app doesn't feel unresponsive. -Remix is managing all of the state behind the scenes and reveals the pieces of it you need to build dynamic web apps. In this case, we'll use the [`useNavigation`][usenavigation] hook. +Remix is managing all the state behind the scenes and reveals the pieces you need to build dynamic web apps. In this case, we'll use the [`useNavigation`][use-navigation] hook. -๐ **Add `useNavigation` to add global pending UI** +๐ **Use `useNavigation` to add global pending UI** -```jsx filename=src/routes/root.jsx lines=[3,10,17] +```tsx filename=app/root.tsx lines=[12,19,27-29] +// existing imports import { - // existing code + Form, + Links, + LiveReload, + Meta, + NavLink, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, useNavigation, } from "@remix-run/react"; -// existing code +// existing imports & exports export default function Root() { - const { contacts } = useLoaderData(); + const { contacts } = useLoaderData(); const navigation = useNavigation(); return ( - <> - {/* existing code */} - - - - > + + {/* existing elements */} + + {/* existing elements */} + + + + {/* existing elements */} + + ); } ``` -[`useNavigation`][usenavigation] returns the current navigation state: it can be one of `"idle" | "submitting" | "loading"`. +[`useNavigation`][use-navigation] returns the current navigation state: it can be one of `"idle"`, `"loading"` or `"submitting"`. In our case, we add a `"loading"` class to the main part of the app if we're not idle. The CSS then adds a nice fade after a short delay (to avoid flickering the UI for fast loads). You could do anything you want though, like show a spinner or loading bar across the top. @@ -820,16 +926,15 @@ In our case, we add a `"loading"` class to the main part of the app if we're not If we review code in the contact route, we can find the delete button looks like this: -```jsx filename=src/routes/contact.jsx lines=[3] +```tsx filename=src/routes/contact.$contactId.tsx lines=[2] { - if ( - !confirm( - "Please confirm you want to delete this record." - ) - ) { + const response = confirm( + "Please confirm you want to delete this record." + ); + if (!response) { event.preventDefault(); } }} @@ -838,7 +943,7 @@ If we review code in the contact route, we can find the delete button looks like ``` -Note the `action` points to `"destroy"`. Like ``, `` can take a _relative_ value. Since the form is rendered in `contact.$contactId.tsx`, then a relative action with `destroy` will submit the form to `contact.$contactId.destroy` when clicked. +Note the `action` points to `"destroy"`. Like ``, `` can take a _relative_ value. Since the form is rendered in `contacts.$contactId.tsx`, then a relative action with `destroy` will submit the form to `contacts.$contactId.destroy` when clicked. At this point you should know everything you need to know to make the delete button work. Maybe give it a shot before moving on? You'll need: @@ -856,16 +961,17 @@ touch app/routes/contacts.\$contactId.destroy.tsx ๐ **Add the destroy action** ```tsx filename=app/routes/contacts.$contactId.destroy.tsx -import { type ActionArgs, redirect } from "@remix-run/node"; +import type { ActionArgs } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; import invariant from "tiny-invariant"; import { deleteContact } from "../data"; -export async function action({ params }: ActionArgs) { +export const action = async ({ params }: ActionArgs) => { invariant(params.contactId, "Missing contactId param"); await deleteContact(params.contactId); return redirect("/"); -} +}; ``` Alright, navigate to a record and click the "Delete" button. It works! @@ -874,11 +980,11 @@ Alright, navigate to a record and click the "Delete" button. It works! When the user clicks the submit button: -1. `` prevents the default browser behavior of sending a new document POST request to the server, but instead emulates the browser by creating a POST request with client side routing and `fetch` +1. `` prevents the default browser behavior of sending a new document `POST` request to the server, but instead emulates the browser by creating a `POST` request with client side routing and [`fetch`][fetch] 2. The `` matches the new route at `"contacts.$contactId.destroy"` and sends it the request -3. After the action redirects, Remix calls all of the loaders for the data on the page to get the latest values (this is "revalidation"). `useLoaderData` returns new values and causes the components to update! +3. After the `action` redirects, Remix calls all the `loader`s for the data on the page to get the latest values (this is "revalidation"). `useLoaderData` returns new values and causes the components to update! -Add a form, add an action, Remix does the rest. +Add a `Form`, add an `action`, Remix does the rest. ## Index Routes @@ -896,9 +1002,9 @@ touch app/routes/_index.tsx ๐ **Fill in the index component's elements** -Feel free to copy paste, nothing special here. +Feel free to copy/paste, nothing special here. -```jsx filename=app/routes/_index.tsx +```tsx filename=app/routes/_index.tsx export default function Index() { return ( @@ -915,41 +1021,35 @@ The route name `_index` is special. It tells Remix to match and render this rout -Voila! No more blank space. It's common to put dashboards, stats, feeds, etc. at index routes. They can participate in data loading as well. +Voilร ! No more blank space. It's common to put dashboards, stats, feeds, etc. at index routes. They can participate in data loading as well. ## Cancel Button On the edit page we've got a cancel button that doesn't do anything yet. We'd like it to do the same thing as the browser's back button. -We'll need a click handler on the button as well as [`useNavigate`][usenavigate]. +We'll need a click handler on the button as well as [`useNavigate`][use-navigate]. ๐ **Add the cancel button click handler with `useNavigate`** -```jsx filename=app/routes/contacts.$contactId.edit.tsx lines=[1,7,17-19] +```tsx filename=app/routes/contacts.$contactId_.edit.tsx lines=[5,11,18] +// existing imports import { Form, useLoaderData, - useNavigation, + useNavigate, } from "@remix-run/react"; +// existing imports & exports -/* existing code*/ - -export default function Edit() { - const contact = useLoaderData(); +export default function EditContact() { + const { contact } = useLoaderData(); const navigate = useNavigate(); return ( - - {/* existing code */} - + + {/* existing elements */} Save - { - navigate(-1); - }} - > + navigate(-1)} type="button"> Cancel @@ -966,41 +1066,47 @@ A ``, while seemingly redundant, is the HTML way of preven Two more features to go. We're on the home stretch! -## URL Search Params and GET Submissions +## `URLSearchParams` and `GET` Submissions -All of our interactive UI so far have been either links that change the URL or forms that post data to actions. The search field is interesting because it's a mix of both: it's a form but it only changes the URL, it doesn't change data. +All of our interactive UI so far have been either links that change the URL or `form`s that post data to `action` functions. The search field is interesting because it's a mix of both: it's a `form`, but it only changes the URL, it doesn't change data. Let's see what happens when we submit the search form: ๐ **Type a name into the search field and hit the enter key** -Note the browser's URL now contains your query in the URL as [URLSearchParams][urlsearchparams]: +Note the browser's URL now contains your query in the URL as [`URLSearchParams`][url-search-params]: ``` http://localhost:3000/?q=ryan ``` -Since it's not ``, Remix emulates the browser by serializing the [`FormData`][formdata] into the [`URLSearchParams`][urlsearchparams] instead of the request body. +Since it's not ``, Remix emulates the browser by serializing the [`FormData`][form-data] into the [`URLSearchParams`][url-search-params] instead of the request body. + +`loader` functions have access to the search params from the `request`. Let's use it to filter the list: -Loaders have access to the search params from the `request`. Let's use it to filter the list: +๐ **Filter the list if there are `URLSearchParams`** -๐ **Filter the list if there are URLSearchParams** +```tsx filename=app/root.tsx lines=[3,9-11] +import type { + LinksFunction, + LoaderArgs, +} from "@remix-run/node"; -```tsx filename=app/routes/root.tsx -/* existing imports */ -import { type LoaderArgs, redirect } from "@remix-run/node"; +// existing imports & exports -export async function loader({ request }: LoaderArgs) { +export const loader = async ({ request }: LoaderArgs) => { const url = new URL(request.url); const q = url.searchParams.get("q"); const contacts = await getContacts(q); - return { contacts }; -} + return json({ contacts }); +}; + +// existing code ``` -Because this is a GET, not a POST, Remix _does not_ call the `action`. Submitting a GET form is the same as clicking a link: only the URL changes. +Because this is a `GET`, not a `POST`, Remix _does not_ call the `action` function. Submitting a `GET` `form` is the same as clicking a link: only the URL changes. This also means it's a normal page navigation. You can click the back button to get back to where you were. @@ -1015,44 +1121,47 @@ In other words, the URL and our input's state are out of sync. Let's solve (2) first and start the input with the value from the URL. -๐ **Return `q` from your loader, set it as the input's default value** +๐ **Return `q` from your `loader`, set it as the input's default value** -```tsx filename=app/routes/root.tsx lines=[7,11,26] -// existing code +```tsx filename=app/root.tsx lines=[7,11,23] +// existing imports & exports -export async function loader({ request }: LoaderArg) { +export const loader = async ({ request }: LoaderArgs) => { const url = new URL(request.url); const q = url.searchParams.get("q"); const contacts = await getContacts(q); - return { contacts, q }; -} + return json({ contacts, q }); +}; export default function Root() { - const { contacts, q } = useLoaderData(); + const { contacts, q } = useLoaderData(); const navigation = useNavigation(); return ( - <> - - Remix Contacts - - - - {/* existing code */} - - {/* existing code */} + + {/* existing elements */} + + + {/* existing elements */} + + + + {/* existing elements */} + + {/* existing elements */} + + {/* existing elements */} - {/* existing code */} - - {/* existing code */} - > + {/* existing elements */} + + ); } ``` @@ -1061,16 +1170,17 @@ The input field will show the query if you refresh the page after a search now. Now for problem (1), clicking the back button and updating the input. We can bring in `useEffect` from React to manipulate the input's value in the DOM directly. -๐ **Synchronize input value with the URL Search Params** +๐ **Synchronize input value with the `URLSearchParams`** -```tsx filename=app/routes/root.tsx lines=[1,9-14] +```tsx filename=app/root.tsx lines=[2,10-15] +// existing imports import { useEffect } from "react"; -// existing code +// existing imports & exports export default function Root() { + const { contacts, q } = useLoaderData(); const navigation = useNavigation(); - const { contacts, q } = useLoaderData(); useEffect(() => { const searchField = document.getElementById("q"); @@ -1085,54 +1195,60 @@ export default function Root() { > ๐ค Shouldn't you use a controlled component and React State for this? -You could certainly do this as a controlled component. You will have more synchronization points but it's up to you. +You could certainly do this as a controlled component. You will have more synchronization points, but it's up to you. + Expand this to see what it would look like -```jsx filename=app/routes/root.tsx lines=[9,13-15,30,32-34] +```tsx filename=app/root.tsx lines=[2,9-10,12-16,30-33,36-37] +// existing imports import { useEffect, useState } from "react"; -// existing code + +// existing imports & exports export default function Root() { + const { contacts, q } = useLoaderData(); const navigation = useNavigation(); - const { contacts, q } = useLoaderData(); - // the query now needs to be kept in state - const [query, setQuery] = useState(q); + const [query, setQuery] = useState(q || ""); // we still have a `useEffect` to synchronize the query // to the component state on back/forward button clicks useEffect(() => { - setQuery(q); + setQuery(q || ""); }, [q]); return ( - <> - - Remix Contacts - - - { - setQuery(e.target.value); - }} - /> - {/* existing code */} - - {/* existing code */} + + {/* existing elements */} + + + {/* existing elements */} + + + + setQuery(event.currentTarget.value) + } + placeholder="Search" + type="search" + // switched to `value` from `defaultValue` + value={query} + /> + {/* existing elements */} + + {/* existing elements */} + + {/* existing elements */} - {/* existing code */} - - > + {/* existing elements */} + + ); } ``` @@ -1141,56 +1257,66 @@ export default function Root() { Alright, you should now be able to click the back/forward/refresh buttons and the input's value should be in sync with the URL and results. -## Submitting Forms `onChange` +## Submitting `Form`'s `onChange` -We've got a product decision to make here. Sometimes you want the user to submit the form to filter some results, other times you want to filter as the user types. We've already implemented the first, so let's see what it's like for the second. +We've got a product decision to make here. Sometimes you want the user to submit the `form` to filter some results, other times you want to filter as the user types. We've already implemented the first, so let's see what it's like for the second. -We've seen `useNavigate` already, we'll use its cousin, [`useSubmit`][usesubmit], for this. +We've seen `useNavigate` already, we'll use its cousin, [`useSubmit`][use-submit], for this. -```tsx filename=src/routes/root.tsx lines=[4,10,25-27] -// existing code +```tsx filename=app/root.tsx lines=[13,20,33-35] +// existing imports import { - // existing code + Form, + Links, + LiveReload, + Meta, + NavLink, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, + useNavigation, useSubmit, } from "@remix-run/react"; +// existing imports & exports export default function Root() { + const { contacts, q } = useLoaderData(); const navigation = useNavigation(); - const { contacts, q } = useLoaderData(); const submit = useSubmit(); + // existing code + return ( - <> - - Remix Contacts - - - { - submit(event.currentTarget.form); - }} - /> - {/* existing code */} - - {/* existing code */} + + {/* existing elements */} + + + {/* existing elements */} + + + submit(event.currentTarget) + } + role="search" + > + {/* existing elements */} + + {/* existing elements */} + + {/* existing elements */} - {/* existing code */} - - {/* existing code */} - > + {/* existing elements */} + + ); } ``` -As you type, the form is automatically submitted now! +As you type, the `form` is automatically submitted now! -Note the argument to [`submit`][usesubmit]. The `submit` function will serialize and submit any form you pass to it. We're passing in `event.currentTarget.form`. The `currentTarget` is the DOM node the event is attached to (the input), and the `currentTarget.form` is the form the input belongs to. +Note the argument to [`submit`][use-submit]. The `submit` function will serialize and submit any form you pass to it. We're passing in `event.currentTarget`. The `currentTarget` is the DOM node the event is attached to (the `form`). ## Adding Search Spinner @@ -1198,7 +1324,7 @@ In a production app, it's likely this search will be looking for records in a da Without any loading indicator, the search feels kinda sluggish. Even if we could make our database faster, we'll always have the user's network latency in the way and out of our control. -For a better user experience, let's add some immediate UI feedback for the search. We'll use [`useNavigation`][usenavigation] again. +For a better user experience, let's add some immediate UI feedback for the search. We'll use [`useNavigation`][use-navigation] again. ๐ **Add a variable to know if we're searching** @@ -1222,7 +1348,7 @@ export default function Root() { When nothing is happening, `navigation.location` will be `undefined`, but when the user navigates it will be populated with the next location while data loads. Then we check if they're searching with `location.search`. -๐ **Add classes to search form elements using the state** +๐ **Add classes to search form elements using the new `searching` state** ```tsx filename=app/routes/root.tsx lines=[3,14] @@ -1248,17 +1374,32 @@ When nothing is happening, `navigation.location` will be `undefined`, but when t Bonus points, avoid fading out the main screen when searching: -```tsx filename=app/routes/root.tsx - - - +```tsx filename=app/root.tsx lines=[13] +// existing imports & exports + +export default function Root() { + // existing code + + return ( + + {/* existing elements */} + + {/* existing elements */} + + + + {/* existing elements */} + + + ); +} ``` You should now have a nice spinner on the left side of the search input. @@ -1267,7 +1408,7 @@ You should now have a nice spinner on the left side of the search input. ## Managing the History Stack -Since the form is submitted for every key stroke, typing the characters "alex" and then deleting them with backspace results in a huge history stack ๐. We definitely don't want this: +Since the form is submitted for every keystroke, typing the characters "alex" and then deleting them with backspace results in a huge history stack ๐. We definitely don't want this: @@ -1275,135 +1416,162 @@ We can avoid this by _replacing_ the current entry in the history stack with the ๐ **Use `replace` in `submit`** -```tsx filename=app/routes/root.tsx lines=[6-9] - - { - const isFirstSearch = q == null; - submit(event.currentTarget.form, { - replace: !isFirstSearch, - }); - }} - /> - {/* existing code */} - +```tsx filename=app/root.tsx lines=[16-19] +// existing imports & exports + +export default function Root() { + // existing code + + return ( + + {/* existing elements */} + + + {/* existing elements */} + + { + const isFirstSearch = q === null; + submit(event.currentTarget, { + replace: !isFirstSearch, + }); + }} + role="search" + > + {/* existing elements */} + + {/* existing elements */} + + {/* existing elements */} + + {/* existing elements */} + + + ); +} ``` After a quick check if this is the first search or not, we decide to replace. Now the first search will add a new entry, but every keystroke after that will replace the current entry. Instead of clicking back 7 times to remove the search, users only have to click back once. -## Forms Without Navigation +## `Form`s Without Navigation -So far all of our forms have changed the URL. While these user flows are common, it's equally as common to want to submit a form _without_ causing a navigation. +So far all of our forms have changed the URL. While these user flows are common, it's equally common to want to submit a form _without_ causing a navigation. -For these cases, we have [`useFetcher`][usefetcher]. It allows us to communicate with loaders and actions without causing a navigation. +For these cases, we have [`useFetcher`][use-fetcher]. It allows us to communicate with `action`s and `loader`s without causing a navigation. -The โ button on the contact page makes sense for this. We aren't creating or deleting a new record and we don't want to change pages. We simply want to change the data on the page we're looking at. +The โ button on the contact page makes sense for this. We aren't creating or deleting a new record, and we don't want to change pages. We simply want to change the data on the page we're looking at. ๐ **Change the `` form to a fetcher form** -```tsx filename=app/routes/contacts.$contactId.edit.tsx lines=[1,6,10,20] +```tsx filename=app/routes/contacts.$contactId.tsx lines=[4,14,18,30] +// existing imports import { - useLoaderData, Form, useFetcher, + useLoaderData, } from "@remix-run/react"; +// existing imports & exports // existing code -function Favorite({ contact }: { contact: ContactRecord }) { +const Favorite: FunctionComponent<{ + contact: Pick; +}> = ({ contact }) => { const fetcher = useFetcher(); const favorite = contact.favorite; return ( {favorite ? "โ " : "โ"} ); -} +}; ``` -This form will no longer cause a navigation, but simply fetch to the our action. Speaking of which ... this won't work until we create the action. +This form will no longer cause a navigation, but simply fetch to the `action`. Speaking of which ... this won't work until we create the `action`. -๐ **Create the action** +๐ **Create the `action`** -```tsx filename=app/routes/contacts.$contactId.tsx -// existing code +```tsx filename=app/routes/contacts.$contactId.tsx lines=[2,7,10-19] import type { ActionArgs, LoaderArgs, } from "@remix-run/node"; +// existing imports -import { getContact, updateContact } from "../data"; +import type { ContactRecord } from "../data"; +// existing imports -export async function action({ - request, +export const action = async ({ params, -}: ActionArgs) { + request, +}: ActionArgs) => { invariant(params.contactId, "Missing contactId param"); const formData = await request.formData(); return updateContact(params.contactId, { favorite: formData.get("favorite") === "true", }); -} +}; + +// existing code ``` Alright, we're ready to click the star next to the user's name! -Check that out, both stars automatically update. Our new `` works almost exactly like a the `` we've been using: it calls the action and then all data is revalidated automatically--even your errors will be caught the same way. +Check that out, both stars automatically update. Our new `` works almost exactly like the `` we've been using: it calls the action and then all data is revalidated automatically โ even your errors will be caught the same way. There is one key difference though, it's not a navigation, so the URL doesn't change and the history stack is unaffected. ## Optimistic UI -You probably noticed the app felt kind of unresponsive when we clicked the the favorite button from the last section. Once again, we added some network latency because you're going to have it in the real world. +You probably noticed the app felt kind of unresponsive when we clicked the favorite button from the last section. Once again, we added some network latency because you're going to have it in the real world. -To give the user some feedback, we could put the star into a loading state with [`fetcher.state`][fetcherstate] (a lot like `navigation.state` from before), but we can do something even better this time. We can use a strategy called "Optimistic UI" +To give the user some feedback, we could put the star into a loading state with [`fetcher.state`][fetcher-state] (a lot like `navigation.state` from before), but we can do something even better this time. We can use a strategy called "Optimistic UI" -The fetcher knows the form data being submitted to the action, so it's available to you on `fetcher.formData`. We'll use that to immediately update the star's state, even though the network hasn't finished. If the update eventually fails, the UI will revert to the real data. +The fetcher knows the [`FormData`][form-data] being submitted to the `action`, so it's available to you on `fetcher.formData`. We'll use that to immediately update the star's state, even though the network hasn't finished. If the update eventually fails, the UI will revert to the real data. ๐ **Read the optimistic value from `fetcher.formData`** ```tsx filename=app/routes/contacts.$contactId.tsx lines=[7-9] // existing code -function Favorite({ contact }: { contact: ContactRecord }) { +const Favorite: FunctionComponent<{ + contact: Pick; +}> = ({ contact }) => { const fetcher = useFetcher(); - - let favorite = contact.favorite; - if (fetcher.formData) { - favorite = fetcher.formData.get("favorite") === "true"; - } + const favorite = fetcher.formData + ? fetcher.formData.get("favorite") === "true" + : contact.favorite; return ( {favorite ? "โ " : "โ"} ); -} +}; ``` Now the star _immediately_ changes to the new state when you click it. @@ -1412,44 +1580,30 @@ Now the star _immediately_ changes to the new state when you click it. That's it! Thanks for giving Remix a shot. We hope this tutorial gives you a solid start to build great user experiences. There's a lot more you can do, so make sure to check out all the APIs ๐ -[vite]: https://vitejs.dev/guide/ -[node]: https://nodejs.org -[createbrowserrouter]: ../routers/create-browser-router -[route]: ../route/route -[tutorial-css]: https://gist.githubusercontent.com/ryanflorence/ba20d473ef59e1965543fa013ae4163f/raw/499707f25a5690d490c7b3d54c65c65eb895930c/react-router-6.4-tutorial-css.css -[tutorial-data]: https://gist.githubusercontent.com/ryanflorence/1e7f5d3344c0db4a8394292c157cd305/raw/f7ff21e9ae7ffd55bfaaaf320e09c6a08a8a6611/contacts.js -[routeelement]: ../route/route#element -[jim]: https://blog.jim-nielsen.com/ -[errorelement]: ../route/error-element -[userouteerror]: ../hooks/use-route-error -[isrouteerrorresponse]: ../utils/is-route-error-response +[jim]: https://blog.jim-nielsen.com [outlet]: ../components/outlet [link]: ../components/link -[setup]: #setup [loader]: ../route/loader -[useloaderdata]: ../hooks/use-loader-data +[use-loader-data]: ../hooks/use-loader-data [action]: ../route/action [params]: ../route/loader#params [form]: ../components/form [request]: https://developer.mozilla.org/en-US/docs/Web/API/Request -[formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData -[fromentries]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/fromEntries -[requestformdata]: https://developer.mozilla.org/en-US/docs/Web/API/Request/formData +[form-data]: https://developer.mozilla.org/en-US/docs/Web/API/FormData +[object-from-entries]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/fromEntries +[request-form-data]: https://developer.mozilla.org/en-US/docs/Web/API/Request/formData [response]: https://developer.mozilla.org/en-US/docs/Web/API/Response -[redirect]: ../fetch/redirect -[returningresponses]: ../route/loader#returning-responses -[usenavigation]: ../hooks/use-navigation -[index]: ../route/route#index -[path]: ../route/route#path -[usenavigate]: ../hooks/use-navigate -[uselocation]: ../hooks/use-location -[urlsearchparams]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams -[usesubmit]: ../hooks/use-submit -[navlink]: ../components/nav-link -[usefetcher]: ../hooks/use-fetcher -[fetcherstate]: ../hooks/use-fetcher#fetcherstate -[assetbuilddir]: ../file-conventions/remix-config#assetsbuilddirectory +[redirect]: ../utils/redirect +[returning-response-instances]: ../route/loader#returning-response-instances +[use-navigation]: ../hooks/use-navigation +[use-navigate]: ../hooks/use-navigate +[url-search-params]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams +[use-submit]: ../hooks/use-submit +[nav-link]: ../components/nav-link +[use-fetcher]: ../hooks/use-fetcher +[fetcher-state]: ../hooks/use-fetcher#fetcherstate +[assets-build-directory]: ../file-conventions/remix-config#assetsbuilddirectory [links]: ../route/links -[notfound]: ../guides/not-found -[routeconvention]: ../file-conventions/route-files-v2 +[routes-file-conventions]: ../file-conventions/routes [quickstart]: ./quickstart +[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/fetch diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts index 216e83d3421..9f33e7411d0 100644 --- a/integration/fetcher-test.ts +++ b/integration/fetcher-test.ts @@ -245,8 +245,11 @@ test.describe("useFetcher", () => { method: "get", }), ]); - - await page.waitForSelector(`pre:has-text("${LUNCH}")`); + // Check full HTML here - Chromium/Firefox/Webkit seem to render this in + // a but Edge puts it in some weird code editor markup: + // + // "LUNCH" + expect(await app.getHtml()).toContain(LUNCH); }); test("Form can hit an action", async ({ page }) => { @@ -259,7 +262,11 @@ test.describe("useFetcher", () => { method: "post", }), ]); - await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + // Check full HTML here - Chromium/Firefox/Webkit seem to render this in + // a but Edge puts it in some weird code editor markup: + // + // "LUNCH" + expect(await app.getHtml()).toContain(CHEESESTEAK); }); }); diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 743dd98b152..c513017ff4c 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -137,7 +137,7 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { let newChunk = chunk.toString(); stdout += newChunk; let match: RegExpMatchArray | null = stdout.match( - /started at http:\/\/localhost:(\d+)\s/ + /\[remix-serve\] http:\/\/localhost:(\d+)\s/ ); if (match) { clearTimeout(rejectTimeout); @@ -223,6 +223,26 @@ export async function createFixtureProject( path.join(projectDir, "node_modules"), { overwrite: true } ); + // let remixDev = path.join( + // projectDir, + // "node_modules/@remix-run/dev/dist/cli.js" + // ); + // await fse.chmod(remixDev, 0o755); + // await fse.ensureSymlink( + // remixDev, + // path.join(projectDir, "node_modules/.bin/remix") + // ); + // + // let remixServe = path.join( + // projectDir, + // "node_modules/@remix-run/serve/dist/cli.js" + // ); + // await fse.chmod(remixServe, 0o755); + // await fse.ensureSymlink( + // remixServe, + // path.join(projectDir, "node_modules/.bin/remix-serve") + // ); + await writeTestFiles(init, projectDir); // We update the config file *after* writing test files so that tests can provide a custom diff --git a/integration/helpers/node-template/package.json b/integration/helpers/node-template/package.json index e202cff6ca6..05953d0ec46 100644 --- a/integration/helpers/node-template/package.json +++ b/integration/helpers/node-template/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "node ../../../build/node_modules/@remix-run/dev/dist/cli.js build", "dev": "node ../../../build/node_modules/@remix-run/dev/dist/cli.js dev", - "start": "node ../../../build/node_modules/@remix-run/serve/dist/cli.js build" + "start": "node ../../../build/node_modules/@remix-run/serve/dist/cli.js ./build/index.js" }, "dependencies": { "@remix-run/node": "0.0.0-local-version", diff --git a/integration/hmr-log-test.ts b/integration/hmr-log-test.ts deleted file mode 100644 index 750b893430d..00000000000 --- a/integration/hmr-log-test.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { test, expect } from "@playwright/test"; -import execa from "execa"; -import fs from "node:fs"; -import path from "node:path"; -import type { Readable } from "node:stream"; -import getPort, { makeRange } from "get-port"; - -import type { FixtureInit } from "./helpers/create-fixture.js"; -import { - createFixtureProject, - css, - js, - json, -} from "./helpers/create-fixture.js"; - -test.setTimeout(120_000); - -let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ - config: { - dev: { - port: options.devPort, - }, - }, - files: { - "package.json": json({ - private: true, - sideEffects: false, - type: "module", - scripts: { - dev: `node ./node_modules/@remix-run/dev/dist/cli.js dev -c "node ./server.js"`, - }, - dependencies: { - "@remix-run/css-bundle": "0.0.0-local-version", - "@remix-run/node": "0.0.0-local-version", - "@remix-run/react": "0.0.0-local-version", - "cross-env": "0.0.0-local-version", - express: "0.0.0-local-version", - isbot: "0.0.0-local-version", - react: "0.0.0-local-version", - "react-dom": "0.0.0-local-version", - tailwindcss: "0.0.0-local-version", - }, - devDependencies: { - "@remix-run/dev": "0.0.0-local-version", - "@types/react": "0.0.0-local-version", - "@types/react-dom": "0.0.0-local-version", - typescript: "0.0.0-local-version", - }, - engines: { - node: ">=18.0.0", - }, - }), - - "server.js": js` - import path from "path"; - import url from "url"; - import express from "express"; - import { createRequestHandler } from "@remix-run/express"; - import { logDevReady, installGlobals } from "@remix-run/node"; - - installGlobals(); - - const app = express(); - app.use(express.static("public", { immutable: true, maxAge: "1y" })); - - const MODE = process.env.NODE_ENV; - const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); - - app.all( - "*", - createRequestHandler({ - build: await import(BUILD_PATH), - mode: MODE, - }) - ); - - let port = ${options.appPort}; - app.listen(port, async () => { - let build = await import(BUILD_PATH); - console.log('โ app ready: http://localhost:' + port); - if (process.env.NODE_ENV === 'development') { - logDevReady(build); - } - }); - `, - - "tailwind.config.js": js` - /** @type {import('tailwindcss').Config} */ - module.exports = { - content: ["./app/**/*.{ts,tsx,jsx,js}"], - theme: { - extend: {}, - }, - plugins: [], - }; - `, - - "app/tailwind.css": css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - - "app/styles.module.css": css` - .test { - color: initial; - } - `, - - "app/root.tsx": js` - import type { LinksFunction } from "@remix-run/node"; - import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react"; - import { cssBundleHref } from "@remix-run/css-bundle"; - - import Counter from "./components/counter"; - import styles from "./tailwind.css"; - - export const links: LinksFunction = () => [ - { rel: "stylesheet", href: styles }, - ...cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [], - ]; - - // dummy loader to make sure that HDR is granular - export const loader = () => { - return null; - }; - - export default function Root() { - return ( - - - - - - - - Root Input - - - - - Home - About - MDX - - - - - - - - - ); - } - `, - - "app/routes/_index.tsx": js` - import { useLoaderData } from "@remix-run/react"; - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - const t = useLoaderData(); - return ( - - Index Title - - ) - } - `, - - "app/routes/about.tsx": js` - import Counter from "../components/counter"; - export default function About() { - return ( - - About Title - - - ) - } - `, - "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' -export const loader = () => "crazy" -export const Component = () => { - const data = useLoaderData() - return {data} -} - -# heyo -whatsup - - -`, - - "app/components/counter.tsx": js` - import * as React from "react"; - export default function Counter({ id }) { - let [count, setCount] = React.useState(0); - return ( - - setCount(count + 1)}>inc {count} - - ); - } - `, - }, -}); - -let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -let wait = async ( - callback: () => boolean, - { timeoutMs = 1000, intervalMs = 250 } = {} -) => { - let start = Date.now(); - while (Date.now() - start <= timeoutMs) { - if (callback()) { - return; - } - await sleep(intervalMs); - } - throw Error(`wait: timeout ${timeoutMs}ms`); -}; - -let bufferize = (stream: Readable): (() => string) => { - let buffer = ""; - stream.on("data", (data) => (buffer += data.toString())); - return () => buffer; -}; - -let logConsoleError = (error: Error) => { - console.error(`[console] ${error.name}: ${error.message}`); -}; - -let expectConsoleError = ( - isExpected: (error: Error) => boolean, - unexpected = logConsoleError -) => { - return (error: Error) => { - if (isExpected(error)) { - return; - } - unexpected(error); - }; -}; - -let HMR_TIMEOUT_MS = 10_000; - -test("HMR", async ({ page, browserName }) => { - // uncomment for debugging - // page.on("console", (msg) => console.log(msg.text())); - page.on("pageerror", logConsoleError); - let dataRequests = 0; - page.on("request", (request) => { - let url = new URL(request.url()); - if (url.searchParams.has("_data")) { - dataRequests++; - } - }); - - let portRange = makeRange(4080, 4099); - let appPort = await getPort({ port: portRange }); - let devPort = await getPort({ port: portRange }); - let projectDir = await createFixtureProject(fixture({ appPort, devPort })); - - // spin up dev server - let dev = execa("npm", ["run", "dev"], { cwd: projectDir }); - let devStdout = bufferize(dev.stdout!); - let devStderr = bufferize(dev.stderr!); - try { - await wait( - () => { - if (dev.exitCode) throw Error("Dev server exited early"); - return /โ app ready: /.test(devStdout()); - }, - { timeoutMs: HMR_TIMEOUT_MS } - ); - - await page.goto(`http://localhost:${appPort}`, { - waitUntil: "networkidle", - }); - - // `` value as page state that - // would be wiped out by a full page refresh - // but should be persisted by hmr - let input = page.getByLabel("Root Input"); - expect(input).toBeVisible(); - await input.type("asdfasdf"); - - let counter = await page.waitForSelector("#root-counter"); - await counter.click(); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - let indexPath = path.join(projectDir, "app", "routes", "_index.tsx"); - let originalIndex = fs.readFileSync(indexPath, "utf8"); - let counterPath = path.join(projectDir, "app", "components", "counter.tsx"); - let originalCounter = fs.readFileSync(counterPath, "utf8"); - let cssModulePath = path.join(projectDir, "app", "styles.module.css"); - let originalCssModule = fs.readFileSync(cssModulePath, "utf8"); - let mdxPath = path.join(projectDir, "app", "routes", "mdx.mdx"); - let originalMdx = fs.readFileSync(mdxPath, "utf8"); - - // make content and style changed to index route - let newCssModule = ` - .test { - background: black; - color: white; - } - `; - fs.writeFileSync(cssModulePath, newCssModule); - - let newIndex = ` - import { useLoaderData } from "@remix-run/react"; - import styles from "~/styles.module.css"; - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - const t = useLoaderData(); - return ( - - Changed - - ) - } - `; - fs.writeFileSync(indexPath, newIndex); - - // detect HMR'd content and style changes - await page.waitForLoadState("networkidle"); - - let h1 = page.getByText("Changed"); - await h1.waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(h1).toHaveCSS("color", "rgb(255, 255, 255)"); - expect(h1).toHaveCSS("background-color", "rgb(0, 0, 0)"); - - // verify that `` value was persisted (i.e. hmr, not full page refresh) - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - // undo change - fs.writeFileSync(indexPath, originalIndex); - fs.writeFileSync(cssModulePath, originalCssModule); - await page.getByText("Index Title").waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - // We should not have done any revalidation yet as only UI has changed - expect(dataRequests).toBe(0); - - // add loader - let withLoader1 = ` - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - export let loader = () => json({ hello: "world" }); - - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - let { hello } = useLoaderData(); - return ( - - Hello, {hello} - - ) - } - `; - fs.writeFileSync(indexPath, withLoader1); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(1); - await page.waitForLoadState("networkidle"); - - await page.getByText("Hello, world").waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - expect(dataRequests).toBe(1); - - let withLoader2 = ` - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - export function loader() { - return json({ hello: "planet" }) - } - - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - let { hello } = useLoaderData(); - return ( - - Hello, {hello} - - ) - } - `; - fs.writeFileSync(indexPath, withLoader2); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(2); - await page.waitForLoadState("networkidle"); - - await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - // change shared component - let updatedCounter = ` - import * as React from "react"; - export default function Counter({ id }) { - let [count, setCount] = React.useState(0); - return ( - - setCount(count - 1)}>dec {count} - - ); - } - `; - fs.writeFileSync(counterPath, updatedCounter); - await page.waitForSelector(`#root-counter:has-text("dec 1")`); - counter = await page.waitForSelector("#root-counter"); - await counter.click(); - await counter.click(); - await page.waitForSelector(`#root-counter:has-text("dec -1")`); - - await page.click(`a[href="/about"]`); - let aboutCounter = await page.waitForSelector( - `#about-counter:has-text("dec 0")` - ); - await aboutCounter.click(); - await page.waitForSelector(`#about-counter:has-text("dec -1")`); - - // undo change - fs.writeFileSync(counterPath, originalCounter); - - counter = await page.waitForSelector(`#root-counter:has-text("inc -1")`); - await counter.click(); - counter = await page.waitForSelector(`#root-counter:has-text("inc 0")`); - - aboutCounter = await page.waitForSelector( - `#about-counter:has-text("inc -1")` - ); - await aboutCounter.click(); - aboutCounter = await page.waitForSelector( - `#about-counter:has-text("inc 0")` - ); - - expect(dataRequests).toBe(2); - - // mdx - await page.click(`a[href="/mdx"]`); - await page.waitForSelector(`#crazy`); - let mdx = `import { useLoaderData } from '@remix-run/react' -export const loader = () => "hot" -export const Component = () => { - const data = useLoaderData() - return {data} -} - -# heyo -whatsup - - -`; - fs.writeFileSync(mdxPath, mdx); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(4); - await page.waitForSelector(`#hot`); - - fs.writeFileSync(mdxPath, originalMdx); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(5); - await page.waitForSelector(`#crazy`); - - // dev server doesn't crash when rebuild fails - await page.click(`a[href="/"]`); - await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS }); - await page.waitForLoadState("networkidle"); - - let stderr = devStderr(); - let withSyntaxError = ` - import { useLoaderData } from "@remix-run/react"; - export function shouldRevalidate(args) { - return true; - } - eport efault functio Index() { - const t = useLoaderData(); - return ( - - With Syntax Error - - ) - } - `; - fs.writeFileSync(indexPath, withSyntaxError); - await wait( - () => - devStderr() - .replace(stderr, "") - .includes('Expected ";" but found "efault"'), - { - timeoutMs: HMR_TIMEOUT_MS, - } - ); - - // React Router integration w/ React Refresh has a bug where sometimes rerenders happen with old UI and new data - // in this case causing `TypeError: Cannot destructure property`. - // Need to fix that bug, but it only shows a harmless console error in the browser in dev - page.removeListener("pageerror", logConsoleError); - // let expectedErrorCount = 0; - let expectDestructureTypeError = expectConsoleError((error) => { - let expectedMessage = new Set([ - // chrome, edge - "Cannot destructure property 'hello' of 'useLoaderData(...)' as it is null.", - // firefox - "(intermediate value)() is null", - // webkit - "Right side of assignment cannot be destructured", - ]); - let isExpected = - error.name === "TypeError" && expectedMessage.has(error.message); - // if (isExpected) expectedErrorCount += 1; - return isExpected; - }); - page.on("pageerror", expectDestructureTypeError); - - let withFix = ` - import { useLoaderData } from "@remix-run/react"; - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - // const t = useLoaderData(); - return ( - - With Fix - - ) - } - `; - fs.writeFileSync(indexPath, withFix); - await page.waitForLoadState("networkidle"); - await page.getByText("With Fix").waitFor({ timeout: HMR_TIMEOUT_MS }); - - // Restore normal console error handling - page.removeListener("pageerror", expectDestructureTypeError); - // expect(expectedErrorCount).toBe(browserName === "webkit" ? 1 : 2); - page.addListener("pageerror", logConsoleError); - } catch (e) { - console.log("stdout begin -----------------------"); - console.log(devStdout()); - console.log("stdout end -------------------------"); - - console.log("stderr begin -----------------------"); - console.log(devStderr()); - console.log("stderr end -------------------------"); - throw e; - } finally { - dev.kill(); - } -}); diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts index 0339e97f5c1..dfec1d43ad8 100644 --- a/integration/hmr-test.ts +++ b/integration/hmr-test.ts @@ -1,9 +1,11 @@ -import { test, expect } from "@playwright/test"; -import execa from "execa"; import fs from "node:fs"; import path from "node:path"; import type { Readable } from "node:stream"; -import getPort, { makeRange } from "get-port"; +import type { Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; +import execa from "execa"; +import getPort from "get-port"; +import pidtree from "pidtree"; import type { FixtureInit } from "./helpers/create-fixture.js"; import { @@ -15,76 +17,8 @@ import { test.setTimeout(150_000); -let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ - config: { - dev: { - port: options.devPort, - }, - }, - files: { - "package.json": json({ - private: true, - sideEffects: false, - type: "module", - scripts: { - dev: `node ./node_modules/@remix-run/dev/dist/cli.js dev -c "node ./server.js"`, - }, - dependencies: { - "@remix-run/css-bundle": "0.0.0-local-version", - "@remix-run/node": "0.0.0-local-version", - "@remix-run/react": "0.0.0-local-version", - "cross-env": "0.0.0-local-version", - express: "0.0.0-local-version", - isbot: "0.0.0-local-version", - "postcss-import": "0.0.0-local-version", - react: "0.0.0-local-version", - "react-dom": "0.0.0-local-version", - tailwindcss: "0.0.0-local-version", - }, - devDependencies: { - "@remix-run/dev": "0.0.0-local-version", - "@types/react": "0.0.0-local-version", - "@types/react-dom": "0.0.0-local-version", - typescript: "0.0.0-local-version", - }, - engines: { - node: ">=18.0.0", - }, - }), - - "server.js": js` - import path from "path"; - import url from "url"; - import express from "express"; - import { createRequestHandler } from "@remix-run/express"; - import { broadcastDevReady, installGlobals } from "@remix-run/node"; - - installGlobals(); - - const app = express(); - app.use(express.static("public", { immutable: true, maxAge: "1y" })); - - const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); - - app.all( - "*", - createRequestHandler({ - build: await import(BUILD_PATH), - mode: process.env.NODE_ENV, - }) - ); - - let port = ${options.appPort}; - app.listen(port, async () => { - let build = await import(BUILD_PATH); - console.log('โ app ready: http://localhost:' + port); - if (process.env.NODE_ENV === 'development') { - broadcastDevReady(build); - } - }); - `, - - "postcss.config.cjs": js` +let files = { + "postcss.config.cjs": js` module.exports = { plugins: { "postcss-import": {}, // Testing PostCSS cache invalidation @@ -93,7 +27,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ }; `, - "tailwind.config.js": js` + "tailwind.config.js": js` /** @type {import('tailwindcss').Config} */ export default { content: ["./app/**/*.{ts,tsx,jsx,js}"], @@ -104,45 +38,45 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ }; `, - "app/tailwind.css": css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, + "app/tailwind.css": css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, - "app/stylesWithImport.css": css` - @import "./importedStyle.css"; - `, + "app/stylesWithImport.css": css` + @import "./importedStyle.css"; + `, - "app/importedStyle.css": css` - .importedStyle { - font-weight: normal; - } - `, + "app/importedStyle.css": css` + .importedStyle { + font-weight: normal; + } + `, - "app/sideEffectStylesWithImport.css": css` - @import "./importedSideEffectStyle.css"; - `, + "app/sideEffectStylesWithImport.css": css` + @import "./importedSideEffectStyle.css"; + `, - "app/importedSideEffectStyle.css": css` - .importedSideEffectStyle { - font-size: initial; - } - `, + "app/importedSideEffectStyle.css": css` + .importedSideEffectStyle { + font-size: initial; + } + `, - "app/style.module.css": css` - .test { - composes: color from "./composedStyle.module.css"; - } - `, + "app/style.module.css": css` + .test { + composes: color from "./composedStyle.module.css"; + } + `, - "app/composedStyle.module.css": css` - .color { - color: initial; - } - `, + "app/composedStyle.module.css": css` + .color { + color: initial; + } + `, - "app/root.tsx": js` + "app/root.tsx": js` import type { LinksFunction } from "@remix-run/node"; import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react"; import { cssBundleHref } from "@remix-run/css-bundle"; @@ -192,7 +126,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import { useLoaderData } from "@remix-run/react"; export function shouldRevalidate(args) { return true; @@ -207,7 +141,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ } `, - "app/routes/about.tsx": js` + "app/routes/about.tsx": js` import Counter from "../components/counter"; export default function About() { return ( @@ -218,7 +152,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ ) } `, - "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' + "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' export const loader = () => "crazy" export const Component = () => { const data = useLoaderData() @@ -230,7 +164,7 @@ whatsup `, - "app/components/counter.tsx": js` + "app/components/counter.tsx": js` import * as React from "react"; export default function Counter({ id }) { let [count, setCount] = React.useState(0); @@ -241,50 +175,135 @@ whatsup ); } `, - }, -}); - -let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -let wait = async ( - callback: () => boolean, - { timeoutMs = 1000, intervalMs = 250 } = {} -) => { - let start = Date.now(); - while (Date.now() - start <= timeoutMs) { - if (callback()) { - return; - } - await sleep(intervalMs); - } - throw Error(`wait: timeout ${timeoutMs}ms`); }; -let bufferize = (stream: Readable): (() => string) => { - let buffer = ""; - stream.on("data", (data) => (buffer += data.toString())); - return () => buffer; +let packageJson = (options: { devScript: string; deps?: string[] }) => { + return json({ + private: true, + sideEffects: false, + type: "module", + scripts: { + dev: options.devScript, + }, + dependencies: deps([ + ...(options.deps ?? []), + "@remix-run/css-bundle", + "@remix-run/express", + "@remix-run/node", + "@remix-run/react", + "cross-env", + "express", + "isbot", + "postcss-import", + "react", + "react-dom", + "tailwindcss", + ]), + devDependencies: deps([ + "@remix-run/dev", + "@types/react", + "@types/react-dom", + "typescript", + ]), + engines: { + node: ">=18.0.0", + }, + }); }; -let logConsoleError = (error: Error) => { - console.error(`[console] ${error.name}: ${error.message}`); -}; +let customServer = (options: { appPort: number; devReady: string }) => { + return js` + import path from "path"; + import url from "url"; + import express from "express"; + import { createRequestHandler } from "@remix-run/express"; + import { ${options.devReady}, installGlobals } from "@remix-run/node"; -let expectConsoleError = ( - isExpected: (error: Error) => boolean, - unexpected = logConsoleError -) => { - return (error: Error) => { - if (isExpected(error)) { - return; - } - unexpected(error); - }; + installGlobals(); + + const app = express(); + app.use(express.static("public", { immutable: true, maxAge: "1y" })); + + const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); + + app.all( + "*", + createRequestHandler({ + build: await import(BUILD_PATH), + mode: process.env.NODE_ENV, + }) + ); + + let port = ${options.appPort}; + app.listen(port, async () => { + let build = await import(BUILD_PATH); + console.log('โ app ready: http://localhost:' + port); + if (process.env.NODE_ENV === 'development') { + ${options.devReady}(build); + } + }); + `; }; -let HMR_TIMEOUT_MS = 10_000; +let HMR_TIMEOUT_MS = 30_000; + +let remix = "node ./node_modules/@remix-run/dev/dist/cli.js"; +let serve = "node ./node_modules/@remix-run/serve/dist/cli.js"; + +test("HMR for remix-serve", async ({ page }) => { + await dev(page, { + files: (appPort) => ({ + ...files, + "package.json": packageJson({ + devScript: `cross-env PORT=${appPort} ${remix} dev -c "${serve} ./build/index.js"`, + deps: ["@remix-run/serve"], + }), + }), + appReadyPattern: /\[remix-serve\] /, + }); +}); + +test("HMR for custom server with broadcast", async ({ page }) => { + await dev(page, { + files: (appPort) => ({ + ...files, + "package.json": packageJson({ + devScript: `${remix} dev -c "node ./server.js"`, + deps: ["@remix-run/express"], + }), + "server.js": customServer({ + appPort, + devReady: "broadcastDevReady", + }), + }), + appReadyPattern: /โ app ready: /, + }); +}); -test("HMR", async ({ page, browserName }) => { +test("HMR for custom server with log", async ({ page }) => { + await dev(page, { + files: (appPort) => ({ + ...files, + "package.json": packageJson({ + devScript: `${remix} dev -c "node ./server.js"`, + deps: ["@remix-run/express"], + }), + "server.js": customServer({ + appPort, + devReady: "logDevReady", + }), + }), + appReadyPattern: /โ app ready: /, + }); +}); + +async function dev( + page: Page, + options: { + files: (appPort: number) => Record; + appReadyPattern: RegExp; + } +) { // uncomment for debugging // page.on("console", (msg) => console.log(msg.text())); page.on("pageerror", logConsoleError); @@ -296,24 +315,32 @@ test("HMR", async ({ page, browserName }) => { } }); - let portRange = makeRange(3080, 3099); - let appPort = await getPort({ port: portRange }); - let devPort = await getPort({ port: portRange }); - let projectDir = await createFixtureProject(fixture({ appPort, devPort })); + let appPort = await getPort(); + let devPort = await getPort(); + + let fixture: FixtureInit = { + config: { + dev: { + port: devPort, + }, + }, + files: options.files(appPort), + }; + + let projectDir = await createFixtureProject(fixture); + + let devProc = execa("npm", ["run", "dev"], { cwd: projectDir }); + let devStdout = bufferize(devProc.stdout!); + let devStderr = bufferize(devProc.stderr!); - // spin up dev server - let dev = execa("npm", ["run", "dev"], { cwd: projectDir }); - let devStdout = bufferize(dev.stdout!); - let devStderr = bufferize(dev.stderr!); try { await wait( () => { - if (dev.exitCode) throw Error("Dev server exited early"); - return /โ app ready: /.test(devStdout()); + if (devProc.exitCode) throw Error("Dev server exited early"); + return options.appReadyPattern.test(devStdout()); }, { timeoutMs: HMR_TIMEOUT_MS } ); - await page.goto(`http://localhost:${appPort}`, { waitUntil: "networkidle", }); @@ -590,7 +617,7 @@ whatsup // let expectedErrorCount = 0; let expectDestructureTypeError = expectConsoleError((error) => { let expectedMessage = new Set([ - // chrome, edge + // chrome, msedge "Cannot destructure property 'hello' of 'useLoaderData(...)' as it is null.", // firefox "(intermediate value)() is null", @@ -636,6 +663,105 @@ whatsup console.log("stderr end -------------------------"); throw e; } finally { - dev.kill(); + devProc.pid && (await killtree(devProc.pid)); } -}); +} + +let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +let wait = async ( + callback: () => boolean, + { timeoutMs = 1000, intervalMs = 250 } = {} +) => { + let start = Date.now(); + while (Date.now() - start <= timeoutMs) { + if (callback()) { + return; + } + await sleep(intervalMs); + } + throw Error(`wait: timeout ${timeoutMs}ms`); +}; + +let bufferize = (stream: Readable): (() => string) => { + let buffer = ""; + stream.on("data", (data) => (buffer += data.toString())); + return () => buffer; +}; + +let logConsoleError = (error: Error) => { + console.error(`[console] ${error.name}: ${error.message}`); +}; + +let expectConsoleError = ( + isExpected: (error: Error) => boolean, + unexpected = logConsoleError +) => { + return (error: Error) => { + if (isExpected(error)) { + return; + } + unexpected(error); + }; +}; + +let deps = (packages: string[]): Record => { + return Object.fromEntries( + packages.map((pkg) => [pkg, "0.0.0-local-version"]) + ); +}; + +let isWindows = process.platform === "win32"; + +let 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); + }); +}; + +let isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return false; + } +}; + +let 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/playwright.config.ts b/integration/playwright.config.ts index 8b30808720e..62c3e39cfd3 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -26,8 +26,13 @@ const config: PlaywrightTestConfig = { use: devices["Desktop Safari"], }, { - name: "edge", - use: devices["Desktop Edge"], + name: "msedge", + use: { + ...devices["Desktop Edge"], + // Desktop Edge uses chromium by default + // https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/deviceDescriptorsSource.json#L1502 + channel: "msedge", + }, }, { name: "firefox", diff --git a/package.json b/package.json index b895b422f02..aa73cead05d 100644 --- a/package.json +++ b/package.json @@ -97,8 +97,10 @@ "eslint": "^8.23.1", "eslint-plugin-markdown": "^2.2.1", "eslint-plugin-prefer-let": "^3.0.1", + "execa": "5.1.1", "express": "^4.17.1", "front-matter": "^4.0.2", + "get-port": "5.1.1", "glob": "8.0.3", "isbot": "^3.5.1", "jest": "^27.5.1", @@ -109,6 +111,7 @@ "mime": "^3.0.0", "npm-run-all": "^4.1.5", "patch-package": "^6.5.0", + "pidtree": "^0.6.0", "postcss-import": "^15.1.0", "prettier": "^2.7.1", "prompt-confirm": "^2.0.4", diff --git a/packages/remix-node/__tests__/sessions-test.ts b/packages/remix-node/__tests__/sessions-test.ts index b10b33bda93..5aa8c24f52a 100644 --- a/packages/remix-node/__tests__/sessions-test.ts +++ b/packages/remix-node/__tests__/sessions-test.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { promises as fsp } from "node:fs"; import os from "node:os"; -import { createFileSessionStorage } from "../sessions/fileStorage"; +import { createFileSessionStorage, getFile } from "../sessions/fileStorage"; function getCookieFromSetCookie(setCookie: string): string { return setCookie.split(/;\s*/)[0]; @@ -62,6 +62,39 @@ describe("File session storage", () => { await expect(destroySession(session)).resolves.not.toThrowError(); }); + it("saves expires to file if expires provided to commitSession when creating new cookie", async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let date = new Date(Date.now() + 1000 * 60); + let cookieHeader = await commitSession(session, { expires: date }); + let createdSession = await getSession(cookieHeader); + + let { id } = createdSession; + let fileContents = await fsp.readFile(getFile(dir, id), "utf8"); + let fileData = JSON.parse(fileContents); + expect(fileData.expires).toEqual(date.toISOString()); + }); + + it("saves expires to file if maxAge provided to commitSession when creating new cookie", async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let cookieHeader = await commitSession(session, { maxAge: 60 }); + let createdSession = await getSession(cookieHeader); + + let { id } = createdSession; + let fileContents = await fsp.readFile(getFile(dir, id), "utf8"); + let fileData = JSON.parse(fileContents); + expect(typeof fileData.expires).toBe("string"); + }); + 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 } = createFileSessionStorage({ diff --git a/packages/remix-node/sessions/fileStorage.ts b/packages/remix-node/sessions/fileStorage.ts index a7eedf3b8cb..042539710cf 100644 --- a/packages/remix-node/sessions/fileStorage.ts +++ b/packages/remix-node/sessions/fileStorage.ts @@ -103,7 +103,7 @@ export function createFileSessionStorage({ }); } -function getFile(dir: string, id: string): string { +export function getFile(dir: string, id: string): string { // Divide the session id up into a directory (first 2 bytes) and filename // (remaining 6 bytes) to reduce the chance of having very large directories, // which should speed up file access. This is a maximum of 2^16 directories, diff --git a/packages/remix-serve/cli.ts b/packages/remix-serve/cli.ts index 97dd18dd33a..f1ffe8b3791 100644 --- a/packages/remix-serve/cli.ts +++ b/packages/remix-serve/cli.ts @@ -1,13 +1,15 @@ import "@remix-run/node/install"; -import path from "node:path"; +import fs from "node:fs"; import os from "node:os"; +import path from "node:path"; import url from "node:url"; import { type ServerBuild, broadcastDevReady, installGlobals, } from "@remix-run/node"; -import { createRequestHandler } from "@remix-run/express"; +import { type RequestHandler, createRequestHandler } from "@remix-run/express"; +import chokidar from "chokidar"; import compression from "compression"; import express from "express"; import morgan from "morgan"; @@ -32,11 +34,43 @@ async function run() { process.exit(1); } - let buildPath = url.pathToFileURL( - path.resolve(process.cwd(), buildPathArg) - ).href; + let buildPath = path.resolve(buildPathArg); - let build: ServerBuild = await import(buildPath); + async function reimportServer() { + let stat = fs.statSync(buildPath); + + // use a timestamp query parameter to bust the import cache + return import(url.pathToFileURL(buildPath).href + "?t=" + stat.mtimeMs); + } + + function createDevRequestHandler(initialBuild: ServerBuild): RequestHandler { + let build = initialBuild; + async function handleServerUpdate() { + // 1. re-import the server build + build = await reimportServer(); + // 2. tell Remix that this app server is now up-to-date and ready + broadcastDevReady(build); + } + + chokidar + .watch(buildPath, { ignoreInitial: true }) + .on("add", handleServerUpdate) + .on("change", handleServerUpdate); + + // wrap request handler to make sure its recreated with the latest build for every request + return async (req, res, next) => { + try { + return createRequestHandler({ + build, + mode: "development", + })(req, res, next); + } catch (error) { + next(error); + } + }; + } + + let build: ServerBuild = await reimportServer(); let onListen = () => { let address = @@ -47,10 +81,10 @@ async function run() { ?.address; if (!address) { - console.log(`Remix App Server started at http://localhost:${port}`); + console.log(`[remix-serve] http://localhost:${port}`); } else { console.log( - `Remix App Server started at http://localhost:${port} (http://${address}:${port})` + `[remix-serve] http://localhost:${port} (http://${address}:${port})` ); } if (process.env.NODE_ENV === "development") { @@ -71,22 +105,15 @@ async function run() { app.use(express.static("public", { maxAge: "1h" })); app.use(morgan("tiny")); - let requestHandler: ReturnType | undefined; - app.all("*", async (req, res, next) => { - try { - if (!requestHandler) { - let build = await import(buildPath); - requestHandler = createRequestHandler({ + app.all( + "*", + process.env.NODE_ENV === "development" + ? createDevRequestHandler(build) + : createRequestHandler({ build, mode: process.env.NODE_ENV, - }); - } - - return await requestHandler(req, res, next); - } catch (error) { - next(error); - } - }); + }) + ); let server = process.env.HOST ? app.listen(port, process.env.HOST, onListen) diff --git a/packages/remix-serve/package.json b/packages/remix-serve/package.json index ac6d7552c31..06524c6b710 100644 --- a/packages/remix-serve/package.json +++ b/packages/remix-serve/package.json @@ -17,6 +17,7 @@ "dependencies": { "@remix-run/express": "1.19.3", "@remix-run/node": "1.19.3", + "chokidar": "^3.5.3", "compression": "^1.7.4", "express": "^4.17.1", "morgan": "^1.10.0", diff --git a/packages/remix-server-runtime/sessions.ts b/packages/remix-server-runtime/sessions.ts index bf809b65414..2d4a6c90806 100644 --- a/packages/remix-server-runtime/sessions.ts +++ b/packages/remix-server-runtime/sessions.ts @@ -277,11 +277,17 @@ export const createSessionStorageFactory = }, async commitSession(session, options) { let { id, data } = session; + let expires = + options?.maxAge != null + ? new Date(Date.now() + options.maxAge * 1000) + : options?.expires != null + ? options.expires + : cookie.expires; if (id) { - await updateData(id, data, cookie.expires); + await updateData(id, data, expires); } else { - id = await createData(data, cookie.expires); + id = await createData(data, expires); } return cookie.serialize(id, options); diff --git a/templates/remix-javascript/package.json b/templates/remix-javascript/package.json index 7143725f8e2..521a8e35ae7 100644 --- a/templates/remix-javascript/package.json +++ b/templates/remix-javascript/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "remix build", - "dev": "remix dev", + "dev": "remix dev --manual", "start": "remix-serve build" }, "dependencies": { diff --git a/templates/remix/package.json b/templates/remix/package.json index b79f377eec7..22c4b044d8d 100644 --- a/templates/remix/package.json +++ b/templates/remix/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "remix build", - "dev": "remix dev", + "dev": "remix dev --manual", "start": "remix-serve build", "typecheck": "tsc" }, diff --git a/yarn.lock b/yarn.lock index f161055e2d5..30e78f2d70c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6076,9 +6076,9 @@ get-package-type@^0.1.0: resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== -get-port@^5.1.1: +get-port@5.1.1, get-port@^5.1.1: version "5.1.1" - resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz" + resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== get-stream@^5.0.0, get-stream@^5.1.0:
- No contacts -
+ No contacts +
Name
@@ -915,41 +1021,35 @@ The route name `_index` is special. It tells Remix to match and render this rout -Voila! No more blank space. It's common to put dashboards, stats, feeds, etc. at index routes. They can participate in data loading as well. +Voilร ! No more blank space. It's common to put dashboards, stats, feeds, etc. at index routes. They can participate in data loading as well. ## Cancel Button On the edit page we've got a cancel button that doesn't do anything yet. We'd like it to do the same thing as the browser's back button. -We'll need a click handler on the button as well as [`useNavigate`][usenavigate]. +We'll need a click handler on the button as well as [`useNavigate`][use-navigate]. ๐ **Add the cancel button click handler with `useNavigate`** -```jsx filename=app/routes/contacts.$contactId.edit.tsx lines=[1,7,17-19] +```tsx filename=app/routes/contacts.$contactId_.edit.tsx lines=[5,11,18] +// existing imports import { Form, useLoaderData, - useNavigation, + useNavigate, } from "@remix-run/react"; +// existing imports & exports -/* existing code*/ - -export default function Edit() { - const contact = useLoaderData(); +export default function EditContact() { + const { contact } = useLoaderData(); const navigate = useNavigate(); return ( - - {/* existing code */} - + + {/* existing elements */} Save - { - navigate(-1); - }} - > + navigate(-1)} type="button"> Cancel @@ -966,41 +1066,47 @@ A ``, while seemingly redundant, is the HTML way of preven Two more features to go. We're on the home stretch! -## URL Search Params and GET Submissions +## `URLSearchParams` and `GET` Submissions -All of our interactive UI so far have been either links that change the URL or forms that post data to actions. The search field is interesting because it's a mix of both: it's a form but it only changes the URL, it doesn't change data. +All of our interactive UI so far have been either links that change the URL or `form`s that post data to `action` functions. The search field is interesting because it's a mix of both: it's a `form`, but it only changes the URL, it doesn't change data. Let's see what happens when we submit the search form: ๐ **Type a name into the search field and hit the enter key** -Note the browser's URL now contains your query in the URL as [URLSearchParams][urlsearchparams]: +Note the browser's URL now contains your query in the URL as [`URLSearchParams`][url-search-params]: ``` http://localhost:3000/?q=ryan ``` -Since it's not ``, Remix emulates the browser by serializing the [`FormData`][formdata] into the [`URLSearchParams`][urlsearchparams] instead of the request body. +Since it's not ``, Remix emulates the browser by serializing the [`FormData`][form-data] into the [`URLSearchParams`][url-search-params] instead of the request body. + +`loader` functions have access to the search params from the `request`. Let's use it to filter the list: -Loaders have access to the search params from the `request`. Let's use it to filter the list: +๐ **Filter the list if there are `URLSearchParams`** -๐ **Filter the list if there are URLSearchParams** +```tsx filename=app/root.tsx lines=[3,9-11] +import type { + LinksFunction, + LoaderArgs, +} from "@remix-run/node"; -```tsx filename=app/routes/root.tsx -/* existing imports */ -import { type LoaderArgs, redirect } from "@remix-run/node"; +// existing imports & exports -export async function loader({ request }: LoaderArgs) { +export const loader = async ({ request }: LoaderArgs) => { const url = new URL(request.url); const q = url.searchParams.get("q"); const contacts = await getContacts(q); - return { contacts }; -} + return json({ contacts }); +}; + +// existing code ``` -Because this is a GET, not a POST, Remix _does not_ call the `action`. Submitting a GET form is the same as clicking a link: only the URL changes. +Because this is a `GET`, not a `POST`, Remix _does not_ call the `action` function. Submitting a `GET` `form` is the same as clicking a link: only the URL changes. This also means it's a normal page navigation. You can click the back button to get back to where you were. @@ -1015,44 +1121,47 @@ In other words, the URL and our input's state are out of sync. Let's solve (2) first and start the input with the value from the URL. -๐ **Return `q` from your loader, set it as the input's default value** +๐ **Return `q` from your `loader`, set it as the input's default value** -```tsx filename=app/routes/root.tsx lines=[7,11,26] -// existing code +```tsx filename=app/root.tsx lines=[7,11,23] +// existing imports & exports -export async function loader({ request }: LoaderArg) { +export const loader = async ({ request }: LoaderArgs) => { const url = new URL(request.url); const q = url.searchParams.get("q"); const contacts = await getContacts(q); - return { contacts, q }; -} + return json({ contacts, q }); +}; export default function Root() { - const { contacts, q } = useLoaderData(); + const { contacts, q } = useLoaderData(); const navigation = useNavigation(); return ( - <> - - Remix Contacts - - - - {/* existing code */} - - {/* existing code */} + + {/* existing elements */} + + + {/* existing elements */} + + + + {/* existing elements */} + + {/* existing elements */} + + {/* existing elements */} - {/* existing code */} - - {/* existing code */} - > + {/* existing elements */} + + ); } ``` @@ -1061,16 +1170,17 @@ The input field will show the query if you refresh the page after a search now. Now for problem (1), clicking the back button and updating the input. We can bring in `useEffect` from React to manipulate the input's value in the DOM directly. -๐ **Synchronize input value with the URL Search Params** +๐ **Synchronize input value with the `URLSearchParams`** -```tsx filename=app/routes/root.tsx lines=[1,9-14] +```tsx filename=app/root.tsx lines=[2,10-15] +// existing imports import { useEffect } from "react"; -// existing code +// existing imports & exports export default function Root() { + const { contacts, q } = useLoaderData(); const navigation = useNavigation(); - const { contacts, q } = useLoaderData(); useEffect(() => { const searchField = document.getElementById("q"); @@ -1085,54 +1195,60 @@ export default function Root() { > ๐ค Shouldn't you use a controlled component and React State for this? -You could certainly do this as a controlled component. You will have more synchronization points but it's up to you. +You could certainly do this as a controlled component. You will have more synchronization points, but it's up to you. + Expand this to see what it would look like -```jsx filename=app/routes/root.tsx lines=[9,13-15,30,32-34] +```tsx filename=app/root.tsx lines=[2,9-10,12-16,30-33,36-37] +// existing imports import { useEffect, useState } from "react"; -// existing code + +// existing imports & exports export default function Root() { + const { contacts, q } = useLoaderData(); const navigation = useNavigation(); - const { contacts, q } = useLoaderData(); - // the query now needs to be kept in state - const [query, setQuery] = useState(q); + const [query, setQuery] = useState(q || ""); // we still have a `useEffect` to synchronize the query // to the component state on back/forward button clicks useEffect(() => { - setQuery(q); + setQuery(q || ""); }, [q]); return ( - <> - - Remix Contacts - - - { - setQuery(e.target.value); - }} - /> - {/* existing code */} - - {/* existing code */} + + {/* existing elements */} + + + {/* existing elements */} + + + + setQuery(event.currentTarget.value) + } + placeholder="Search" + type="search" + // switched to `value` from `defaultValue` + value={query} + /> + {/* existing elements */} + + {/* existing elements */} + + {/* existing elements */} - {/* existing code */} - - > + {/* existing elements */} + + ); } ``` @@ -1141,56 +1257,66 @@ export default function Root() { Alright, you should now be able to click the back/forward/refresh buttons and the input's value should be in sync with the URL and results. -## Submitting Forms `onChange` +## Submitting `Form`'s `onChange` -We've got a product decision to make here. Sometimes you want the user to submit the form to filter some results, other times you want to filter as the user types. We've already implemented the first, so let's see what it's like for the second. +We've got a product decision to make here. Sometimes you want the user to submit the `form` to filter some results, other times you want to filter as the user types. We've already implemented the first, so let's see what it's like for the second. -We've seen `useNavigate` already, we'll use its cousin, [`useSubmit`][usesubmit], for this. +We've seen `useNavigate` already, we'll use its cousin, [`useSubmit`][use-submit], for this. -```tsx filename=src/routes/root.tsx lines=[4,10,25-27] -// existing code +```tsx filename=app/root.tsx lines=[13,20,33-35] +// existing imports import { - // existing code + Form, + Links, + LiveReload, + Meta, + NavLink, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, + useNavigation, useSubmit, } from "@remix-run/react"; +// existing imports & exports export default function Root() { + const { contacts, q } = useLoaderData(); const navigation = useNavigation(); - const { contacts, q } = useLoaderData(); const submit = useSubmit(); + // existing code + return ( - <> - - Remix Contacts - - - { - submit(event.currentTarget.form); - }} - /> - {/* existing code */} - - {/* existing code */} + + {/* existing elements */} + + + {/* existing elements */} + + + submit(event.currentTarget) + } + role="search" + > + {/* existing elements */} + + {/* existing elements */} + + {/* existing elements */} - {/* existing code */} - - {/* existing code */} - > + {/* existing elements */} + + ); } ``` -As you type, the form is automatically submitted now! +As you type, the `form` is automatically submitted now! -Note the argument to [`submit`][usesubmit]. The `submit` function will serialize and submit any form you pass to it. We're passing in `event.currentTarget.form`. The `currentTarget` is the DOM node the event is attached to (the input), and the `currentTarget.form` is the form the input belongs to. +Note the argument to [`submit`][use-submit]. The `submit` function will serialize and submit any form you pass to it. We're passing in `event.currentTarget`. The `currentTarget` is the DOM node the event is attached to (the `form`). ## Adding Search Spinner @@ -1198,7 +1324,7 @@ In a production app, it's likely this search will be looking for records in a da Without any loading indicator, the search feels kinda sluggish. Even if we could make our database faster, we'll always have the user's network latency in the way and out of our control. -For a better user experience, let's add some immediate UI feedback for the search. We'll use [`useNavigation`][usenavigation] again. +For a better user experience, let's add some immediate UI feedback for the search. We'll use [`useNavigation`][use-navigation] again. ๐ **Add a variable to know if we're searching** @@ -1222,7 +1348,7 @@ export default function Root() { When nothing is happening, `navigation.location` will be `undefined`, but when the user navigates it will be populated with the next location while data loads. Then we check if they're searching with `location.search`. -๐ **Add classes to search form elements using the state** +๐ **Add classes to search form elements using the new `searching` state** ```tsx filename=app/routes/root.tsx lines=[3,14] @@ -1248,17 +1374,32 @@ When nothing is happening, `navigation.location` will be `undefined`, but when t Bonus points, avoid fading out the main screen when searching: -```tsx filename=app/routes/root.tsx - - - +```tsx filename=app/root.tsx lines=[13] +// existing imports & exports + +export default function Root() { + // existing code + + return ( + + {/* existing elements */} + + {/* existing elements */} + + + + {/* existing elements */} + + + ); +} ``` You should now have a nice spinner on the left side of the search input. @@ -1267,7 +1408,7 @@ You should now have a nice spinner on the left side of the search input. ## Managing the History Stack -Since the form is submitted for every key stroke, typing the characters "alex" and then deleting them with backspace results in a huge history stack ๐. We definitely don't want this: +Since the form is submitted for every keystroke, typing the characters "alex" and then deleting them with backspace results in a huge history stack ๐. We definitely don't want this: @@ -1275,135 +1416,162 @@ We can avoid this by _replacing_ the current entry in the history stack with the ๐ **Use `replace` in `submit`** -```tsx filename=app/routes/root.tsx lines=[6-9] - - { - const isFirstSearch = q == null; - submit(event.currentTarget.form, { - replace: !isFirstSearch, - }); - }} - /> - {/* existing code */} - +```tsx filename=app/root.tsx lines=[16-19] +// existing imports & exports + +export default function Root() { + // existing code + + return ( + + {/* existing elements */} + + + {/* existing elements */} + + { + const isFirstSearch = q === null; + submit(event.currentTarget, { + replace: !isFirstSearch, + }); + }} + role="search" + > + {/* existing elements */} + + {/* existing elements */} + + {/* existing elements */} + + {/* existing elements */} + + + ); +} ``` After a quick check if this is the first search or not, we decide to replace. Now the first search will add a new entry, but every keystroke after that will replace the current entry. Instead of clicking back 7 times to remove the search, users only have to click back once. -## Forms Without Navigation +## `Form`s Without Navigation -So far all of our forms have changed the URL. While these user flows are common, it's equally as common to want to submit a form _without_ causing a navigation. +So far all of our forms have changed the URL. While these user flows are common, it's equally common to want to submit a form _without_ causing a navigation. -For these cases, we have [`useFetcher`][usefetcher]. It allows us to communicate with loaders and actions without causing a navigation. +For these cases, we have [`useFetcher`][use-fetcher]. It allows us to communicate with `action`s and `loader`s without causing a navigation. -The โ button on the contact page makes sense for this. We aren't creating or deleting a new record and we don't want to change pages. We simply want to change the data on the page we're looking at. +The โ button on the contact page makes sense for this. We aren't creating or deleting a new record, and we don't want to change pages. We simply want to change the data on the page we're looking at. ๐ **Change the `` form to a fetcher form** -```tsx filename=app/routes/contacts.$contactId.edit.tsx lines=[1,6,10,20] +```tsx filename=app/routes/contacts.$contactId.tsx lines=[4,14,18,30] +// existing imports import { - useLoaderData, Form, useFetcher, + useLoaderData, } from "@remix-run/react"; +// existing imports & exports // existing code -function Favorite({ contact }: { contact: ContactRecord }) { +const Favorite: FunctionComponent<{ + contact: Pick; +}> = ({ contact }) => { const fetcher = useFetcher(); const favorite = contact.favorite; return ( {favorite ? "โ " : "โ"} ); -} +}; ``` -This form will no longer cause a navigation, but simply fetch to the our action. Speaking of which ... this won't work until we create the action. +This form will no longer cause a navigation, but simply fetch to the `action`. Speaking of which ... this won't work until we create the `action`. -๐ **Create the action** +๐ **Create the `action`** -```tsx filename=app/routes/contacts.$contactId.tsx -// existing code +```tsx filename=app/routes/contacts.$contactId.tsx lines=[2,7,10-19] import type { ActionArgs, LoaderArgs, } from "@remix-run/node"; +// existing imports -import { getContact, updateContact } from "../data"; +import type { ContactRecord } from "../data"; +// existing imports -export async function action({ - request, +export const action = async ({ params, -}: ActionArgs) { + request, +}: ActionArgs) => { invariant(params.contactId, "Missing contactId param"); const formData = await request.formData(); return updateContact(params.contactId, { favorite: formData.get("favorite") === "true", }); -} +}; + +// existing code ``` Alright, we're ready to click the star next to the user's name! -Check that out, both stars automatically update. Our new `` works almost exactly like a the `` we've been using: it calls the action and then all data is revalidated automatically--even your errors will be caught the same way. +Check that out, both stars automatically update. Our new `` works almost exactly like the `` we've been using: it calls the action and then all data is revalidated automatically โ even your errors will be caught the same way. There is one key difference though, it's not a navigation, so the URL doesn't change and the history stack is unaffected. ## Optimistic UI -You probably noticed the app felt kind of unresponsive when we clicked the the favorite button from the last section. Once again, we added some network latency because you're going to have it in the real world. +You probably noticed the app felt kind of unresponsive when we clicked the favorite button from the last section. Once again, we added some network latency because you're going to have it in the real world. -To give the user some feedback, we could put the star into a loading state with [`fetcher.state`][fetcherstate] (a lot like `navigation.state` from before), but we can do something even better this time. We can use a strategy called "Optimistic UI" +To give the user some feedback, we could put the star into a loading state with [`fetcher.state`][fetcher-state] (a lot like `navigation.state` from before), but we can do something even better this time. We can use a strategy called "Optimistic UI" -The fetcher knows the form data being submitted to the action, so it's available to you on `fetcher.formData`. We'll use that to immediately update the star's state, even though the network hasn't finished. If the update eventually fails, the UI will revert to the real data. +The fetcher knows the [`FormData`][form-data] being submitted to the `action`, so it's available to you on `fetcher.formData`. We'll use that to immediately update the star's state, even though the network hasn't finished. If the update eventually fails, the UI will revert to the real data. ๐ **Read the optimistic value from `fetcher.formData`** ```tsx filename=app/routes/contacts.$contactId.tsx lines=[7-9] // existing code -function Favorite({ contact }: { contact: ContactRecord }) { +const Favorite: FunctionComponent<{ + contact: Pick; +}> = ({ contact }) => { const fetcher = useFetcher(); - - let favorite = contact.favorite; - if (fetcher.formData) { - favorite = fetcher.formData.get("favorite") === "true"; - } + const favorite = fetcher.formData + ? fetcher.formData.get("favorite") === "true" + : contact.favorite; return ( {favorite ? "โ " : "โ"} ); -} +}; ``` Now the star _immediately_ changes to the new state when you click it. @@ -1412,44 +1580,30 @@ Now the star _immediately_ changes to the new state when you click it. That's it! Thanks for giving Remix a shot. We hope this tutorial gives you a solid start to build great user experiences. There's a lot more you can do, so make sure to check out all the APIs ๐ -[vite]: https://vitejs.dev/guide/ -[node]: https://nodejs.org -[createbrowserrouter]: ../routers/create-browser-router -[route]: ../route/route -[tutorial-css]: https://gist.githubusercontent.com/ryanflorence/ba20d473ef59e1965543fa013ae4163f/raw/499707f25a5690d490c7b3d54c65c65eb895930c/react-router-6.4-tutorial-css.css -[tutorial-data]: https://gist.githubusercontent.com/ryanflorence/1e7f5d3344c0db4a8394292c157cd305/raw/f7ff21e9ae7ffd55bfaaaf320e09c6a08a8a6611/contacts.js -[routeelement]: ../route/route#element -[jim]: https://blog.jim-nielsen.com/ -[errorelement]: ../route/error-element -[userouteerror]: ../hooks/use-route-error -[isrouteerrorresponse]: ../utils/is-route-error-response +[jim]: https://blog.jim-nielsen.com [outlet]: ../components/outlet [link]: ../components/link -[setup]: #setup [loader]: ../route/loader -[useloaderdata]: ../hooks/use-loader-data +[use-loader-data]: ../hooks/use-loader-data [action]: ../route/action [params]: ../route/loader#params [form]: ../components/form [request]: https://developer.mozilla.org/en-US/docs/Web/API/Request -[formdata]: https://developer.mozilla.org/en-US/docs/Web/API/FormData -[fromentries]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/fromEntries -[requestformdata]: https://developer.mozilla.org/en-US/docs/Web/API/Request/formData +[form-data]: https://developer.mozilla.org/en-US/docs/Web/API/FormData +[object-from-entries]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/fromEntries +[request-form-data]: https://developer.mozilla.org/en-US/docs/Web/API/Request/formData [response]: https://developer.mozilla.org/en-US/docs/Web/API/Response -[redirect]: ../fetch/redirect -[returningresponses]: ../route/loader#returning-responses -[usenavigation]: ../hooks/use-navigation -[index]: ../route/route#index -[path]: ../route/route#path -[usenavigate]: ../hooks/use-navigate -[uselocation]: ../hooks/use-location -[urlsearchparams]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams -[usesubmit]: ../hooks/use-submit -[navlink]: ../components/nav-link -[usefetcher]: ../hooks/use-fetcher -[fetcherstate]: ../hooks/use-fetcher#fetcherstate -[assetbuilddir]: ../file-conventions/remix-config#assetsbuilddirectory +[redirect]: ../utils/redirect +[returning-response-instances]: ../route/loader#returning-response-instances +[use-navigation]: ../hooks/use-navigation +[use-navigate]: ../hooks/use-navigate +[url-search-params]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams +[use-submit]: ../hooks/use-submit +[nav-link]: ../components/nav-link +[use-fetcher]: ../hooks/use-fetcher +[fetcher-state]: ../hooks/use-fetcher#fetcherstate +[assets-build-directory]: ../file-conventions/remix-config#assetsbuilddirectory [links]: ../route/links -[notfound]: ../guides/not-found -[routeconvention]: ../file-conventions/route-files-v2 +[routes-file-conventions]: ../file-conventions/routes [quickstart]: ./quickstart +[fetch]: https://developer.mozilla.org/en-US/docs/Web/API/fetch diff --git a/integration/fetcher-test.ts b/integration/fetcher-test.ts index 216e83d3421..9f33e7411d0 100644 --- a/integration/fetcher-test.ts +++ b/integration/fetcher-test.ts @@ -245,8 +245,11 @@ test.describe("useFetcher", () => { method: "get", }), ]); - - await page.waitForSelector(`pre:has-text("${LUNCH}")`); + // Check full HTML here - Chromium/Firefox/Webkit seem to render this in + // a but Edge puts it in some weird code editor markup: + // + // "LUNCH" + expect(await app.getHtml()).toContain(LUNCH); }); test("Form can hit an action", async ({ page }) => { @@ -259,7 +262,11 @@ test.describe("useFetcher", () => { method: "post", }), ]); - await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + // Check full HTML here - Chromium/Firefox/Webkit seem to render this in + // a but Edge puts it in some weird code editor markup: + // + // "LUNCH" + expect(await app.getHtml()).toContain(CHEESESTEAK); }); }); diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 743dd98b152..c513017ff4c 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -137,7 +137,7 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { let newChunk = chunk.toString(); stdout += newChunk; let match: RegExpMatchArray | null = stdout.match( - /started at http:\/\/localhost:(\d+)\s/ + /\[remix-serve\] http:\/\/localhost:(\d+)\s/ ); if (match) { clearTimeout(rejectTimeout); @@ -223,6 +223,26 @@ export async function createFixtureProject( path.join(projectDir, "node_modules"), { overwrite: true } ); + // let remixDev = path.join( + // projectDir, + // "node_modules/@remix-run/dev/dist/cli.js" + // ); + // await fse.chmod(remixDev, 0o755); + // await fse.ensureSymlink( + // remixDev, + // path.join(projectDir, "node_modules/.bin/remix") + // ); + // + // let remixServe = path.join( + // projectDir, + // "node_modules/@remix-run/serve/dist/cli.js" + // ); + // await fse.chmod(remixServe, 0o755); + // await fse.ensureSymlink( + // remixServe, + // path.join(projectDir, "node_modules/.bin/remix-serve") + // ); + await writeTestFiles(init, projectDir); // We update the config file *after* writing test files so that tests can provide a custom diff --git a/integration/helpers/node-template/package.json b/integration/helpers/node-template/package.json index e202cff6ca6..05953d0ec46 100644 --- a/integration/helpers/node-template/package.json +++ b/integration/helpers/node-template/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "node ../../../build/node_modules/@remix-run/dev/dist/cli.js build", "dev": "node ../../../build/node_modules/@remix-run/dev/dist/cli.js dev", - "start": "node ../../../build/node_modules/@remix-run/serve/dist/cli.js build" + "start": "node ../../../build/node_modules/@remix-run/serve/dist/cli.js ./build/index.js" }, "dependencies": { "@remix-run/node": "0.0.0-local-version", diff --git a/integration/hmr-log-test.ts b/integration/hmr-log-test.ts deleted file mode 100644 index 750b893430d..00000000000 --- a/integration/hmr-log-test.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { test, expect } from "@playwright/test"; -import execa from "execa"; -import fs from "node:fs"; -import path from "node:path"; -import type { Readable } from "node:stream"; -import getPort, { makeRange } from "get-port"; - -import type { FixtureInit } from "./helpers/create-fixture.js"; -import { - createFixtureProject, - css, - js, - json, -} from "./helpers/create-fixture.js"; - -test.setTimeout(120_000); - -let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ - config: { - dev: { - port: options.devPort, - }, - }, - files: { - "package.json": json({ - private: true, - sideEffects: false, - type: "module", - scripts: { - dev: `node ./node_modules/@remix-run/dev/dist/cli.js dev -c "node ./server.js"`, - }, - dependencies: { - "@remix-run/css-bundle": "0.0.0-local-version", - "@remix-run/node": "0.0.0-local-version", - "@remix-run/react": "0.0.0-local-version", - "cross-env": "0.0.0-local-version", - express: "0.0.0-local-version", - isbot: "0.0.0-local-version", - react: "0.0.0-local-version", - "react-dom": "0.0.0-local-version", - tailwindcss: "0.0.0-local-version", - }, - devDependencies: { - "@remix-run/dev": "0.0.0-local-version", - "@types/react": "0.0.0-local-version", - "@types/react-dom": "0.0.0-local-version", - typescript: "0.0.0-local-version", - }, - engines: { - node: ">=18.0.0", - }, - }), - - "server.js": js` - import path from "path"; - import url from "url"; - import express from "express"; - import { createRequestHandler } from "@remix-run/express"; - import { logDevReady, installGlobals } from "@remix-run/node"; - - installGlobals(); - - const app = express(); - app.use(express.static("public", { immutable: true, maxAge: "1y" })); - - const MODE = process.env.NODE_ENV; - const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); - - app.all( - "*", - createRequestHandler({ - build: await import(BUILD_PATH), - mode: MODE, - }) - ); - - let port = ${options.appPort}; - app.listen(port, async () => { - let build = await import(BUILD_PATH); - console.log('โ app ready: http://localhost:' + port); - if (process.env.NODE_ENV === 'development') { - logDevReady(build); - } - }); - `, - - "tailwind.config.js": js` - /** @type {import('tailwindcss').Config} */ - module.exports = { - content: ["./app/**/*.{ts,tsx,jsx,js}"], - theme: { - extend: {}, - }, - plugins: [], - }; - `, - - "app/tailwind.css": css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - - "app/styles.module.css": css` - .test { - color: initial; - } - `, - - "app/root.tsx": js` - import type { LinksFunction } from "@remix-run/node"; - import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react"; - import { cssBundleHref } from "@remix-run/css-bundle"; - - import Counter from "./components/counter"; - import styles from "./tailwind.css"; - - export const links: LinksFunction = () => [ - { rel: "stylesheet", href: styles }, - ...cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [], - ]; - - // dummy loader to make sure that HDR is granular - export const loader = () => { - return null; - }; - - export default function Root() { - return ( - - - - - - - - Root Input - - - - - Home - About - MDX - - - - - - - - - ); - } - `, - - "app/routes/_index.tsx": js` - import { useLoaderData } from "@remix-run/react"; - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - const t = useLoaderData(); - return ( - - Index Title - - ) - } - `, - - "app/routes/about.tsx": js` - import Counter from "../components/counter"; - export default function About() { - return ( - - About Title - - - ) - } - `, - "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' -export const loader = () => "crazy" -export const Component = () => { - const data = useLoaderData() - return {data} -} - -# heyo -whatsup - - -`, - - "app/components/counter.tsx": js` - import * as React from "react"; - export default function Counter({ id }) { - let [count, setCount] = React.useState(0); - return ( - - setCount(count + 1)}>inc {count} - - ); - } - `, - }, -}); - -let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -let wait = async ( - callback: () => boolean, - { timeoutMs = 1000, intervalMs = 250 } = {} -) => { - let start = Date.now(); - while (Date.now() - start <= timeoutMs) { - if (callback()) { - return; - } - await sleep(intervalMs); - } - throw Error(`wait: timeout ${timeoutMs}ms`); -}; - -let bufferize = (stream: Readable): (() => string) => { - let buffer = ""; - stream.on("data", (data) => (buffer += data.toString())); - return () => buffer; -}; - -let logConsoleError = (error: Error) => { - console.error(`[console] ${error.name}: ${error.message}`); -}; - -let expectConsoleError = ( - isExpected: (error: Error) => boolean, - unexpected = logConsoleError -) => { - return (error: Error) => { - if (isExpected(error)) { - return; - } - unexpected(error); - }; -}; - -let HMR_TIMEOUT_MS = 10_000; - -test("HMR", async ({ page, browserName }) => { - // uncomment for debugging - // page.on("console", (msg) => console.log(msg.text())); - page.on("pageerror", logConsoleError); - let dataRequests = 0; - page.on("request", (request) => { - let url = new URL(request.url()); - if (url.searchParams.has("_data")) { - dataRequests++; - } - }); - - let portRange = makeRange(4080, 4099); - let appPort = await getPort({ port: portRange }); - let devPort = await getPort({ port: portRange }); - let projectDir = await createFixtureProject(fixture({ appPort, devPort })); - - // spin up dev server - let dev = execa("npm", ["run", "dev"], { cwd: projectDir }); - let devStdout = bufferize(dev.stdout!); - let devStderr = bufferize(dev.stderr!); - try { - await wait( - () => { - if (dev.exitCode) throw Error("Dev server exited early"); - return /โ app ready: /.test(devStdout()); - }, - { timeoutMs: HMR_TIMEOUT_MS } - ); - - await page.goto(`http://localhost:${appPort}`, { - waitUntil: "networkidle", - }); - - // `` value as page state that - // would be wiped out by a full page refresh - // but should be persisted by hmr - let input = page.getByLabel("Root Input"); - expect(input).toBeVisible(); - await input.type("asdfasdf"); - - let counter = await page.waitForSelector("#root-counter"); - await counter.click(); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - let indexPath = path.join(projectDir, "app", "routes", "_index.tsx"); - let originalIndex = fs.readFileSync(indexPath, "utf8"); - let counterPath = path.join(projectDir, "app", "components", "counter.tsx"); - let originalCounter = fs.readFileSync(counterPath, "utf8"); - let cssModulePath = path.join(projectDir, "app", "styles.module.css"); - let originalCssModule = fs.readFileSync(cssModulePath, "utf8"); - let mdxPath = path.join(projectDir, "app", "routes", "mdx.mdx"); - let originalMdx = fs.readFileSync(mdxPath, "utf8"); - - // make content and style changed to index route - let newCssModule = ` - .test { - background: black; - color: white; - } - `; - fs.writeFileSync(cssModulePath, newCssModule); - - let newIndex = ` - import { useLoaderData } from "@remix-run/react"; - import styles from "~/styles.module.css"; - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - const t = useLoaderData(); - return ( - - Changed - - ) - } - `; - fs.writeFileSync(indexPath, newIndex); - - // detect HMR'd content and style changes - await page.waitForLoadState("networkidle"); - - let h1 = page.getByText("Changed"); - await h1.waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(h1).toHaveCSS("color", "rgb(255, 255, 255)"); - expect(h1).toHaveCSS("background-color", "rgb(0, 0, 0)"); - - // verify that `` value was persisted (i.e. hmr, not full page refresh) - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - // undo change - fs.writeFileSync(indexPath, originalIndex); - fs.writeFileSync(cssModulePath, originalCssModule); - await page.getByText("Index Title").waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - // We should not have done any revalidation yet as only UI has changed - expect(dataRequests).toBe(0); - - // add loader - let withLoader1 = ` - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - export let loader = () => json({ hello: "world" }); - - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - let { hello } = useLoaderData(); - return ( - - Hello, {hello} - - ) - } - `; - fs.writeFileSync(indexPath, withLoader1); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(1); - await page.waitForLoadState("networkidle"); - - await page.getByText("Hello, world").waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - expect(dataRequests).toBe(1); - - let withLoader2 = ` - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - export function loader() { - return json({ hello: "planet" }) - } - - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - let { hello } = useLoaderData(); - return ( - - Hello, {hello} - - ) - } - `; - fs.writeFileSync(indexPath, withLoader2); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(2); - await page.waitForLoadState("networkidle"); - - await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - // change shared component - let updatedCounter = ` - import * as React from "react"; - export default function Counter({ id }) { - let [count, setCount] = React.useState(0); - return ( - - setCount(count - 1)}>dec {count} - - ); - } - `; - fs.writeFileSync(counterPath, updatedCounter); - await page.waitForSelector(`#root-counter:has-text("dec 1")`); - counter = await page.waitForSelector("#root-counter"); - await counter.click(); - await counter.click(); - await page.waitForSelector(`#root-counter:has-text("dec -1")`); - - await page.click(`a[href="/about"]`); - let aboutCounter = await page.waitForSelector( - `#about-counter:has-text("dec 0")` - ); - await aboutCounter.click(); - await page.waitForSelector(`#about-counter:has-text("dec -1")`); - - // undo change - fs.writeFileSync(counterPath, originalCounter); - - counter = await page.waitForSelector(`#root-counter:has-text("inc -1")`); - await counter.click(); - counter = await page.waitForSelector(`#root-counter:has-text("inc 0")`); - - aboutCounter = await page.waitForSelector( - `#about-counter:has-text("inc -1")` - ); - await aboutCounter.click(); - aboutCounter = await page.waitForSelector( - `#about-counter:has-text("inc 0")` - ); - - expect(dataRequests).toBe(2); - - // mdx - await page.click(`a[href="/mdx"]`); - await page.waitForSelector(`#crazy`); - let mdx = `import { useLoaderData } from '@remix-run/react' -export const loader = () => "hot" -export const Component = () => { - const data = useLoaderData() - return {data} -} - -# heyo -whatsup - - -`; - fs.writeFileSync(mdxPath, mdx); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(4); - await page.waitForSelector(`#hot`); - - fs.writeFileSync(mdxPath, originalMdx); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(5); - await page.waitForSelector(`#crazy`); - - // dev server doesn't crash when rebuild fails - await page.click(`a[href="/"]`); - await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS }); - await page.waitForLoadState("networkidle"); - - let stderr = devStderr(); - let withSyntaxError = ` - import { useLoaderData } from "@remix-run/react"; - export function shouldRevalidate(args) { - return true; - } - eport efault functio Index() { - const t = useLoaderData(); - return ( - - With Syntax Error - - ) - } - `; - fs.writeFileSync(indexPath, withSyntaxError); - await wait( - () => - devStderr() - .replace(stderr, "") - .includes('Expected ";" but found "efault"'), - { - timeoutMs: HMR_TIMEOUT_MS, - } - ); - - // React Router integration w/ React Refresh has a bug where sometimes rerenders happen with old UI and new data - // in this case causing `TypeError: Cannot destructure property`. - // Need to fix that bug, but it only shows a harmless console error in the browser in dev - page.removeListener("pageerror", logConsoleError); - // let expectedErrorCount = 0; - let expectDestructureTypeError = expectConsoleError((error) => { - let expectedMessage = new Set([ - // chrome, edge - "Cannot destructure property 'hello' of 'useLoaderData(...)' as it is null.", - // firefox - "(intermediate value)() is null", - // webkit - "Right side of assignment cannot be destructured", - ]); - let isExpected = - error.name === "TypeError" && expectedMessage.has(error.message); - // if (isExpected) expectedErrorCount += 1; - return isExpected; - }); - page.on("pageerror", expectDestructureTypeError); - - let withFix = ` - import { useLoaderData } from "@remix-run/react"; - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - // const t = useLoaderData(); - return ( - - With Fix - - ) - } - `; - fs.writeFileSync(indexPath, withFix); - await page.waitForLoadState("networkidle"); - await page.getByText("With Fix").waitFor({ timeout: HMR_TIMEOUT_MS }); - - // Restore normal console error handling - page.removeListener("pageerror", expectDestructureTypeError); - // expect(expectedErrorCount).toBe(browserName === "webkit" ? 1 : 2); - page.addListener("pageerror", logConsoleError); - } catch (e) { - console.log("stdout begin -----------------------"); - console.log(devStdout()); - console.log("stdout end -------------------------"); - - console.log("stderr begin -----------------------"); - console.log(devStderr()); - console.log("stderr end -------------------------"); - throw e; - } finally { - dev.kill(); - } -}); diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts index 0339e97f5c1..dfec1d43ad8 100644 --- a/integration/hmr-test.ts +++ b/integration/hmr-test.ts @@ -1,9 +1,11 @@ -import { test, expect } from "@playwright/test"; -import execa from "execa"; import fs from "node:fs"; import path from "node:path"; import type { Readable } from "node:stream"; -import getPort, { makeRange } from "get-port"; +import type { Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; +import execa from "execa"; +import getPort from "get-port"; +import pidtree from "pidtree"; import type { FixtureInit } from "./helpers/create-fixture.js"; import { @@ -15,76 +17,8 @@ import { test.setTimeout(150_000); -let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ - config: { - dev: { - port: options.devPort, - }, - }, - files: { - "package.json": json({ - private: true, - sideEffects: false, - type: "module", - scripts: { - dev: `node ./node_modules/@remix-run/dev/dist/cli.js dev -c "node ./server.js"`, - }, - dependencies: { - "@remix-run/css-bundle": "0.0.0-local-version", - "@remix-run/node": "0.0.0-local-version", - "@remix-run/react": "0.0.0-local-version", - "cross-env": "0.0.0-local-version", - express: "0.0.0-local-version", - isbot: "0.0.0-local-version", - "postcss-import": "0.0.0-local-version", - react: "0.0.0-local-version", - "react-dom": "0.0.0-local-version", - tailwindcss: "0.0.0-local-version", - }, - devDependencies: { - "@remix-run/dev": "0.0.0-local-version", - "@types/react": "0.0.0-local-version", - "@types/react-dom": "0.0.0-local-version", - typescript: "0.0.0-local-version", - }, - engines: { - node: ">=18.0.0", - }, - }), - - "server.js": js` - import path from "path"; - import url from "url"; - import express from "express"; - import { createRequestHandler } from "@remix-run/express"; - import { broadcastDevReady, installGlobals } from "@remix-run/node"; - - installGlobals(); - - const app = express(); - app.use(express.static("public", { immutable: true, maxAge: "1y" })); - - const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); - - app.all( - "*", - createRequestHandler({ - build: await import(BUILD_PATH), - mode: process.env.NODE_ENV, - }) - ); - - let port = ${options.appPort}; - app.listen(port, async () => { - let build = await import(BUILD_PATH); - console.log('โ app ready: http://localhost:' + port); - if (process.env.NODE_ENV === 'development') { - broadcastDevReady(build); - } - }); - `, - - "postcss.config.cjs": js` +let files = { + "postcss.config.cjs": js` module.exports = { plugins: { "postcss-import": {}, // Testing PostCSS cache invalidation @@ -93,7 +27,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ }; `, - "tailwind.config.js": js` + "tailwind.config.js": js` /** @type {import('tailwindcss').Config} */ export default { content: ["./app/**/*.{ts,tsx,jsx,js}"], @@ -104,45 +38,45 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ }; `, - "app/tailwind.css": css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, + "app/tailwind.css": css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, - "app/stylesWithImport.css": css` - @import "./importedStyle.css"; - `, + "app/stylesWithImport.css": css` + @import "./importedStyle.css"; + `, - "app/importedStyle.css": css` - .importedStyle { - font-weight: normal; - } - `, + "app/importedStyle.css": css` + .importedStyle { + font-weight: normal; + } + `, - "app/sideEffectStylesWithImport.css": css` - @import "./importedSideEffectStyle.css"; - `, + "app/sideEffectStylesWithImport.css": css` + @import "./importedSideEffectStyle.css"; + `, - "app/importedSideEffectStyle.css": css` - .importedSideEffectStyle { - font-size: initial; - } - `, + "app/importedSideEffectStyle.css": css` + .importedSideEffectStyle { + font-size: initial; + } + `, - "app/style.module.css": css` - .test { - composes: color from "./composedStyle.module.css"; - } - `, + "app/style.module.css": css` + .test { + composes: color from "./composedStyle.module.css"; + } + `, - "app/composedStyle.module.css": css` - .color { - color: initial; - } - `, + "app/composedStyle.module.css": css` + .color { + color: initial; + } + `, - "app/root.tsx": js` + "app/root.tsx": js` import type { LinksFunction } from "@remix-run/node"; import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react"; import { cssBundleHref } from "@remix-run/css-bundle"; @@ -192,7 +126,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import { useLoaderData } from "@remix-run/react"; export function shouldRevalidate(args) { return true; @@ -207,7 +141,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ } `, - "app/routes/about.tsx": js` + "app/routes/about.tsx": js` import Counter from "../components/counter"; export default function About() { return ( @@ -218,7 +152,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ ) } `, - "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' + "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' export const loader = () => "crazy" export const Component = () => { const data = useLoaderData() @@ -230,7 +164,7 @@ whatsup `, - "app/components/counter.tsx": js` + "app/components/counter.tsx": js` import * as React from "react"; export default function Counter({ id }) { let [count, setCount] = React.useState(0); @@ -241,50 +175,135 @@ whatsup ); } `, - }, -}); - -let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -let wait = async ( - callback: () => boolean, - { timeoutMs = 1000, intervalMs = 250 } = {} -) => { - let start = Date.now(); - while (Date.now() - start <= timeoutMs) { - if (callback()) { - return; - } - await sleep(intervalMs); - } - throw Error(`wait: timeout ${timeoutMs}ms`); }; -let bufferize = (stream: Readable): (() => string) => { - let buffer = ""; - stream.on("data", (data) => (buffer += data.toString())); - return () => buffer; +let packageJson = (options: { devScript: string; deps?: string[] }) => { + return json({ + private: true, + sideEffects: false, + type: "module", + scripts: { + dev: options.devScript, + }, + dependencies: deps([ + ...(options.deps ?? []), + "@remix-run/css-bundle", + "@remix-run/express", + "@remix-run/node", + "@remix-run/react", + "cross-env", + "express", + "isbot", + "postcss-import", + "react", + "react-dom", + "tailwindcss", + ]), + devDependencies: deps([ + "@remix-run/dev", + "@types/react", + "@types/react-dom", + "typescript", + ]), + engines: { + node: ">=18.0.0", + }, + }); }; -let logConsoleError = (error: Error) => { - console.error(`[console] ${error.name}: ${error.message}`); -}; +let customServer = (options: { appPort: number; devReady: string }) => { + return js` + import path from "path"; + import url from "url"; + import express from "express"; + import { createRequestHandler } from "@remix-run/express"; + import { ${options.devReady}, installGlobals } from "@remix-run/node"; -let expectConsoleError = ( - isExpected: (error: Error) => boolean, - unexpected = logConsoleError -) => { - return (error: Error) => { - if (isExpected(error)) { - return; - } - unexpected(error); - }; + installGlobals(); + + const app = express(); + app.use(express.static("public", { immutable: true, maxAge: "1y" })); + + const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); + + app.all( + "*", + createRequestHandler({ + build: await import(BUILD_PATH), + mode: process.env.NODE_ENV, + }) + ); + + let port = ${options.appPort}; + app.listen(port, async () => { + let build = await import(BUILD_PATH); + console.log('โ app ready: http://localhost:' + port); + if (process.env.NODE_ENV === 'development') { + ${options.devReady}(build); + } + }); + `; }; -let HMR_TIMEOUT_MS = 10_000; +let HMR_TIMEOUT_MS = 30_000; + +let remix = "node ./node_modules/@remix-run/dev/dist/cli.js"; +let serve = "node ./node_modules/@remix-run/serve/dist/cli.js"; + +test("HMR for remix-serve", async ({ page }) => { + await dev(page, { + files: (appPort) => ({ + ...files, + "package.json": packageJson({ + devScript: `cross-env PORT=${appPort} ${remix} dev -c "${serve} ./build/index.js"`, + deps: ["@remix-run/serve"], + }), + }), + appReadyPattern: /\[remix-serve\] /, + }); +}); + +test("HMR for custom server with broadcast", async ({ page }) => { + await dev(page, { + files: (appPort) => ({ + ...files, + "package.json": packageJson({ + devScript: `${remix} dev -c "node ./server.js"`, + deps: ["@remix-run/express"], + }), + "server.js": customServer({ + appPort, + devReady: "broadcastDevReady", + }), + }), + appReadyPattern: /โ app ready: /, + }); +}); -test("HMR", async ({ page, browserName }) => { +test("HMR for custom server with log", async ({ page }) => { + await dev(page, { + files: (appPort) => ({ + ...files, + "package.json": packageJson({ + devScript: `${remix} dev -c "node ./server.js"`, + deps: ["@remix-run/express"], + }), + "server.js": customServer({ + appPort, + devReady: "logDevReady", + }), + }), + appReadyPattern: /โ app ready: /, + }); +}); + +async function dev( + page: Page, + options: { + files: (appPort: number) => Record; + appReadyPattern: RegExp; + } +) { // uncomment for debugging // page.on("console", (msg) => console.log(msg.text())); page.on("pageerror", logConsoleError); @@ -296,24 +315,32 @@ test("HMR", async ({ page, browserName }) => { } }); - let portRange = makeRange(3080, 3099); - let appPort = await getPort({ port: portRange }); - let devPort = await getPort({ port: portRange }); - let projectDir = await createFixtureProject(fixture({ appPort, devPort })); + let appPort = await getPort(); + let devPort = await getPort(); + + let fixture: FixtureInit = { + config: { + dev: { + port: devPort, + }, + }, + files: options.files(appPort), + }; + + let projectDir = await createFixtureProject(fixture); + + let devProc = execa("npm", ["run", "dev"], { cwd: projectDir }); + let devStdout = bufferize(devProc.stdout!); + let devStderr = bufferize(devProc.stderr!); - // spin up dev server - let dev = execa("npm", ["run", "dev"], { cwd: projectDir }); - let devStdout = bufferize(dev.stdout!); - let devStderr = bufferize(dev.stderr!); try { await wait( () => { - if (dev.exitCode) throw Error("Dev server exited early"); - return /โ app ready: /.test(devStdout()); + if (devProc.exitCode) throw Error("Dev server exited early"); + return options.appReadyPattern.test(devStdout()); }, { timeoutMs: HMR_TIMEOUT_MS } ); - await page.goto(`http://localhost:${appPort}`, { waitUntil: "networkidle", }); @@ -590,7 +617,7 @@ whatsup // let expectedErrorCount = 0; let expectDestructureTypeError = expectConsoleError((error) => { let expectedMessage = new Set([ - // chrome, edge + // chrome, msedge "Cannot destructure property 'hello' of 'useLoaderData(...)' as it is null.", // firefox "(intermediate value)() is null", @@ -636,6 +663,105 @@ whatsup console.log("stderr end -------------------------"); throw e; } finally { - dev.kill(); + devProc.pid && (await killtree(devProc.pid)); } -}); +} + +let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +let wait = async ( + callback: () => boolean, + { timeoutMs = 1000, intervalMs = 250 } = {} +) => { + let start = Date.now(); + while (Date.now() - start <= timeoutMs) { + if (callback()) { + return; + } + await sleep(intervalMs); + } + throw Error(`wait: timeout ${timeoutMs}ms`); +}; + +let bufferize = (stream: Readable): (() => string) => { + let buffer = ""; + stream.on("data", (data) => (buffer += data.toString())); + return () => buffer; +}; + +let logConsoleError = (error: Error) => { + console.error(`[console] ${error.name}: ${error.message}`); +}; + +let expectConsoleError = ( + isExpected: (error: Error) => boolean, + unexpected = logConsoleError +) => { + return (error: Error) => { + if (isExpected(error)) { + return; + } + unexpected(error); + }; +}; + +let deps = (packages: string[]): Record => { + return Object.fromEntries( + packages.map((pkg) => [pkg, "0.0.0-local-version"]) + ); +}; + +let isWindows = process.platform === "win32"; + +let 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); + }); +}; + +let isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return false; + } +}; + +let 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/playwright.config.ts b/integration/playwright.config.ts index 8b30808720e..62c3e39cfd3 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -26,8 +26,13 @@ const config: PlaywrightTestConfig = { use: devices["Desktop Safari"], }, { - name: "edge", - use: devices["Desktop Edge"], + name: "msedge", + use: { + ...devices["Desktop Edge"], + // Desktop Edge uses chromium by default + // https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/deviceDescriptorsSource.json#L1502 + channel: "msedge", + }, }, { name: "firefox", diff --git a/package.json b/package.json index b895b422f02..aa73cead05d 100644 --- a/package.json +++ b/package.json @@ -97,8 +97,10 @@ "eslint": "^8.23.1", "eslint-plugin-markdown": "^2.2.1", "eslint-plugin-prefer-let": "^3.0.1", + "execa": "5.1.1", "express": "^4.17.1", "front-matter": "^4.0.2", + "get-port": "5.1.1", "glob": "8.0.3", "isbot": "^3.5.1", "jest": "^27.5.1", @@ -109,6 +111,7 @@ "mime": "^3.0.0", "npm-run-all": "^4.1.5", "patch-package": "^6.5.0", + "pidtree": "^0.6.0", "postcss-import": "^15.1.0", "prettier": "^2.7.1", "prompt-confirm": "^2.0.4", diff --git a/packages/remix-node/__tests__/sessions-test.ts b/packages/remix-node/__tests__/sessions-test.ts index b10b33bda93..5aa8c24f52a 100644 --- a/packages/remix-node/__tests__/sessions-test.ts +++ b/packages/remix-node/__tests__/sessions-test.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { promises as fsp } from "node:fs"; import os from "node:os"; -import { createFileSessionStorage } from "../sessions/fileStorage"; +import { createFileSessionStorage, getFile } from "../sessions/fileStorage"; function getCookieFromSetCookie(setCookie: string): string { return setCookie.split(/;\s*/)[0]; @@ -62,6 +62,39 @@ describe("File session storage", () => { await expect(destroySession(session)).resolves.not.toThrowError(); }); + it("saves expires to file if expires provided to commitSession when creating new cookie", async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let date = new Date(Date.now() + 1000 * 60); + let cookieHeader = await commitSession(session, { expires: date }); + let createdSession = await getSession(cookieHeader); + + let { id } = createdSession; + let fileContents = await fsp.readFile(getFile(dir, id), "utf8"); + let fileData = JSON.parse(fileContents); + expect(fileData.expires).toEqual(date.toISOString()); + }); + + it("saves expires to file if maxAge provided to commitSession when creating new cookie", async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let cookieHeader = await commitSession(session, { maxAge: 60 }); + let createdSession = await getSession(cookieHeader); + + let { id } = createdSession; + let fileContents = await fsp.readFile(getFile(dir, id), "utf8"); + let fileData = JSON.parse(fileContents); + expect(typeof fileData.expires).toBe("string"); + }); + 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 } = createFileSessionStorage({ diff --git a/packages/remix-node/sessions/fileStorage.ts b/packages/remix-node/sessions/fileStorage.ts index a7eedf3b8cb..042539710cf 100644 --- a/packages/remix-node/sessions/fileStorage.ts +++ b/packages/remix-node/sessions/fileStorage.ts @@ -103,7 +103,7 @@ export function createFileSessionStorage({ }); } -function getFile(dir: string, id: string): string { +export function getFile(dir: string, id: string): string { // Divide the session id up into a directory (first 2 bytes) and filename // (remaining 6 bytes) to reduce the chance of having very large directories, // which should speed up file access. This is a maximum of 2^16 directories, diff --git a/packages/remix-serve/cli.ts b/packages/remix-serve/cli.ts index 97dd18dd33a..f1ffe8b3791 100644 --- a/packages/remix-serve/cli.ts +++ b/packages/remix-serve/cli.ts @@ -1,13 +1,15 @@ import "@remix-run/node/install"; -import path from "node:path"; +import fs from "node:fs"; import os from "node:os"; +import path from "node:path"; import url from "node:url"; import { type ServerBuild, broadcastDevReady, installGlobals, } from "@remix-run/node"; -import { createRequestHandler } from "@remix-run/express"; +import { type RequestHandler, createRequestHandler } from "@remix-run/express"; +import chokidar from "chokidar"; import compression from "compression"; import express from "express"; import morgan from "morgan"; @@ -32,11 +34,43 @@ async function run() { process.exit(1); } - let buildPath = url.pathToFileURL( - path.resolve(process.cwd(), buildPathArg) - ).href; + let buildPath = path.resolve(buildPathArg); - let build: ServerBuild = await import(buildPath); + async function reimportServer() { + let stat = fs.statSync(buildPath); + + // use a timestamp query parameter to bust the import cache + return import(url.pathToFileURL(buildPath).href + "?t=" + stat.mtimeMs); + } + + function createDevRequestHandler(initialBuild: ServerBuild): RequestHandler { + let build = initialBuild; + async function handleServerUpdate() { + // 1. re-import the server build + build = await reimportServer(); + // 2. tell Remix that this app server is now up-to-date and ready + broadcastDevReady(build); + } + + chokidar + .watch(buildPath, { ignoreInitial: true }) + .on("add", handleServerUpdate) + .on("change", handleServerUpdate); + + // wrap request handler to make sure its recreated with the latest build for every request + return async (req, res, next) => { + try { + return createRequestHandler({ + build, + mode: "development", + })(req, res, next); + } catch (error) { + next(error); + } + }; + } + + let build: ServerBuild = await reimportServer(); let onListen = () => { let address = @@ -47,10 +81,10 @@ async function run() { ?.address; if (!address) { - console.log(`Remix App Server started at http://localhost:${port}`); + console.log(`[remix-serve] http://localhost:${port}`); } else { console.log( - `Remix App Server started at http://localhost:${port} (http://${address}:${port})` + `[remix-serve] http://localhost:${port} (http://${address}:${port})` ); } if (process.env.NODE_ENV === "development") { @@ -71,22 +105,15 @@ async function run() { app.use(express.static("public", { maxAge: "1h" })); app.use(morgan("tiny")); - let requestHandler: ReturnType | undefined; - app.all("*", async (req, res, next) => { - try { - if (!requestHandler) { - let build = await import(buildPath); - requestHandler = createRequestHandler({ + app.all( + "*", + process.env.NODE_ENV === "development" + ? createDevRequestHandler(build) + : createRequestHandler({ build, mode: process.env.NODE_ENV, - }); - } - - return await requestHandler(req, res, next); - } catch (error) { - next(error); - } - }); + }) + ); let server = process.env.HOST ? app.listen(port, process.env.HOST, onListen) diff --git a/packages/remix-serve/package.json b/packages/remix-serve/package.json index ac6d7552c31..06524c6b710 100644 --- a/packages/remix-serve/package.json +++ b/packages/remix-serve/package.json @@ -17,6 +17,7 @@ "dependencies": { "@remix-run/express": "1.19.3", "@remix-run/node": "1.19.3", + "chokidar": "^3.5.3", "compression": "^1.7.4", "express": "^4.17.1", "morgan": "^1.10.0", diff --git a/packages/remix-server-runtime/sessions.ts b/packages/remix-server-runtime/sessions.ts index bf809b65414..2d4a6c90806 100644 --- a/packages/remix-server-runtime/sessions.ts +++ b/packages/remix-server-runtime/sessions.ts @@ -277,11 +277,17 @@ export const createSessionStorageFactory = }, async commitSession(session, options) { let { id, data } = session; + let expires = + options?.maxAge != null + ? new Date(Date.now() + options.maxAge * 1000) + : options?.expires != null + ? options.expires + : cookie.expires; if (id) { - await updateData(id, data, cookie.expires); + await updateData(id, data, expires); } else { - id = await createData(data, cookie.expires); + id = await createData(data, expires); } return cookie.serialize(id, options); diff --git a/templates/remix-javascript/package.json b/templates/remix-javascript/package.json index 7143725f8e2..521a8e35ae7 100644 --- a/templates/remix-javascript/package.json +++ b/templates/remix-javascript/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "remix build", - "dev": "remix dev", + "dev": "remix dev --manual", "start": "remix-serve build" }, "dependencies": { diff --git a/templates/remix/package.json b/templates/remix/package.json index b79f377eec7..22c4b044d8d 100644 --- a/templates/remix/package.json +++ b/templates/remix/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "remix build", - "dev": "remix dev", + "dev": "remix dev --manual", "start": "remix-serve build", "typecheck": "tsc" }, diff --git a/yarn.lock b/yarn.lock index f161055e2d5..30e78f2d70c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6076,9 +6076,9 @@ get-package-type@^0.1.0: resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== -get-port@^5.1.1: +get-port@5.1.1, get-port@^5.1.1: version "5.1.1" - resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz" + resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== get-stream@^5.0.0, get-stream@^5.1.0:
Save - { - navigate(-1); - }} - > + navigate(-1)} type="button"> Cancel
but Edge puts it in some weird code editor markup: + // + // "LUNCH" + expect(await app.getHtml()).toContain(LUNCH); }); test("Form can hit an action", async ({ page }) => { @@ -259,7 +262,11 @@ test.describe("useFetcher", () => { method: "post", }), ]); - await page.waitForSelector(`pre:has-text("${CHEESESTEAK}")`); + // Check full HTML here - Chromium/Firefox/Webkit seem to render this in + // a but Edge puts it in some weird code editor markup: + // + // "LUNCH" + expect(await app.getHtml()).toContain(CHEESESTEAK); }); }); diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 743dd98b152..c513017ff4c 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -137,7 +137,7 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { let newChunk = chunk.toString(); stdout += newChunk; let match: RegExpMatchArray | null = stdout.match( - /started at http:\/\/localhost:(\d+)\s/ + /\[remix-serve\] http:\/\/localhost:(\d+)\s/ ); if (match) { clearTimeout(rejectTimeout); @@ -223,6 +223,26 @@ export async function createFixtureProject( path.join(projectDir, "node_modules"), { overwrite: true } ); + // let remixDev = path.join( + // projectDir, + // "node_modules/@remix-run/dev/dist/cli.js" + // ); + // await fse.chmod(remixDev, 0o755); + // await fse.ensureSymlink( + // remixDev, + // path.join(projectDir, "node_modules/.bin/remix") + // ); + // + // let remixServe = path.join( + // projectDir, + // "node_modules/@remix-run/serve/dist/cli.js" + // ); + // await fse.chmod(remixServe, 0o755); + // await fse.ensureSymlink( + // remixServe, + // path.join(projectDir, "node_modules/.bin/remix-serve") + // ); + await writeTestFiles(init, projectDir); // We update the config file *after* writing test files so that tests can provide a custom diff --git a/integration/helpers/node-template/package.json b/integration/helpers/node-template/package.json index e202cff6ca6..05953d0ec46 100644 --- a/integration/helpers/node-template/package.json +++ b/integration/helpers/node-template/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "node ../../../build/node_modules/@remix-run/dev/dist/cli.js build", "dev": "node ../../../build/node_modules/@remix-run/dev/dist/cli.js dev", - "start": "node ../../../build/node_modules/@remix-run/serve/dist/cli.js build" + "start": "node ../../../build/node_modules/@remix-run/serve/dist/cli.js ./build/index.js" }, "dependencies": { "@remix-run/node": "0.0.0-local-version", diff --git a/integration/hmr-log-test.ts b/integration/hmr-log-test.ts deleted file mode 100644 index 750b893430d..00000000000 --- a/integration/hmr-log-test.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { test, expect } from "@playwright/test"; -import execa from "execa"; -import fs from "node:fs"; -import path from "node:path"; -import type { Readable } from "node:stream"; -import getPort, { makeRange } from "get-port"; - -import type { FixtureInit } from "./helpers/create-fixture.js"; -import { - createFixtureProject, - css, - js, - json, -} from "./helpers/create-fixture.js"; - -test.setTimeout(120_000); - -let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ - config: { - dev: { - port: options.devPort, - }, - }, - files: { - "package.json": json({ - private: true, - sideEffects: false, - type: "module", - scripts: { - dev: `node ./node_modules/@remix-run/dev/dist/cli.js dev -c "node ./server.js"`, - }, - dependencies: { - "@remix-run/css-bundle": "0.0.0-local-version", - "@remix-run/node": "0.0.0-local-version", - "@remix-run/react": "0.0.0-local-version", - "cross-env": "0.0.0-local-version", - express: "0.0.0-local-version", - isbot: "0.0.0-local-version", - react: "0.0.0-local-version", - "react-dom": "0.0.0-local-version", - tailwindcss: "0.0.0-local-version", - }, - devDependencies: { - "@remix-run/dev": "0.0.0-local-version", - "@types/react": "0.0.0-local-version", - "@types/react-dom": "0.0.0-local-version", - typescript: "0.0.0-local-version", - }, - engines: { - node: ">=18.0.0", - }, - }), - - "server.js": js` - import path from "path"; - import url from "url"; - import express from "express"; - import { createRequestHandler } from "@remix-run/express"; - import { logDevReady, installGlobals } from "@remix-run/node"; - - installGlobals(); - - const app = express(); - app.use(express.static("public", { immutable: true, maxAge: "1y" })); - - const MODE = process.env.NODE_ENV; - const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); - - app.all( - "*", - createRequestHandler({ - build: await import(BUILD_PATH), - mode: MODE, - }) - ); - - let port = ${options.appPort}; - app.listen(port, async () => { - let build = await import(BUILD_PATH); - console.log('โ app ready: http://localhost:' + port); - if (process.env.NODE_ENV === 'development') { - logDevReady(build); - } - }); - `, - - "tailwind.config.js": js` - /** @type {import('tailwindcss').Config} */ - module.exports = { - content: ["./app/**/*.{ts,tsx,jsx,js}"], - theme: { - extend: {}, - }, - plugins: [], - }; - `, - - "app/tailwind.css": css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - - "app/styles.module.css": css` - .test { - color: initial; - } - `, - - "app/root.tsx": js` - import type { LinksFunction } from "@remix-run/node"; - import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react"; - import { cssBundleHref } from "@remix-run/css-bundle"; - - import Counter from "./components/counter"; - import styles from "./tailwind.css"; - - export const links: LinksFunction = () => [ - { rel: "stylesheet", href: styles }, - ...cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [], - ]; - - // dummy loader to make sure that HDR is granular - export const loader = () => { - return null; - }; - - export default function Root() { - return ( - - - - - - - - Root Input - - - - - Home - About - MDX - - - - - - - - - ); - } - `, - - "app/routes/_index.tsx": js` - import { useLoaderData } from "@remix-run/react"; - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - const t = useLoaderData(); - return ( - - Index Title - - ) - } - `, - - "app/routes/about.tsx": js` - import Counter from "../components/counter"; - export default function About() { - return ( - - About Title - - - ) - } - `, - "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' -export const loader = () => "crazy" -export const Component = () => { - const data = useLoaderData() - return {data} -} - -# heyo -whatsup - - -`, - - "app/components/counter.tsx": js` - import * as React from "react"; - export default function Counter({ id }) { - let [count, setCount] = React.useState(0); - return ( - - setCount(count + 1)}>inc {count} - - ); - } - `, - }, -}); - -let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -let wait = async ( - callback: () => boolean, - { timeoutMs = 1000, intervalMs = 250 } = {} -) => { - let start = Date.now(); - while (Date.now() - start <= timeoutMs) { - if (callback()) { - return; - } - await sleep(intervalMs); - } - throw Error(`wait: timeout ${timeoutMs}ms`); -}; - -let bufferize = (stream: Readable): (() => string) => { - let buffer = ""; - stream.on("data", (data) => (buffer += data.toString())); - return () => buffer; -}; - -let logConsoleError = (error: Error) => { - console.error(`[console] ${error.name}: ${error.message}`); -}; - -let expectConsoleError = ( - isExpected: (error: Error) => boolean, - unexpected = logConsoleError -) => { - return (error: Error) => { - if (isExpected(error)) { - return; - } - unexpected(error); - }; -}; - -let HMR_TIMEOUT_MS = 10_000; - -test("HMR", async ({ page, browserName }) => { - // uncomment for debugging - // page.on("console", (msg) => console.log(msg.text())); - page.on("pageerror", logConsoleError); - let dataRequests = 0; - page.on("request", (request) => { - let url = new URL(request.url()); - if (url.searchParams.has("_data")) { - dataRequests++; - } - }); - - let portRange = makeRange(4080, 4099); - let appPort = await getPort({ port: portRange }); - let devPort = await getPort({ port: portRange }); - let projectDir = await createFixtureProject(fixture({ appPort, devPort })); - - // spin up dev server - let dev = execa("npm", ["run", "dev"], { cwd: projectDir }); - let devStdout = bufferize(dev.stdout!); - let devStderr = bufferize(dev.stderr!); - try { - await wait( - () => { - if (dev.exitCode) throw Error("Dev server exited early"); - return /โ app ready: /.test(devStdout()); - }, - { timeoutMs: HMR_TIMEOUT_MS } - ); - - await page.goto(`http://localhost:${appPort}`, { - waitUntil: "networkidle", - }); - - // `` value as page state that - // would be wiped out by a full page refresh - // but should be persisted by hmr - let input = page.getByLabel("Root Input"); - expect(input).toBeVisible(); - await input.type("asdfasdf"); - - let counter = await page.waitForSelector("#root-counter"); - await counter.click(); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - let indexPath = path.join(projectDir, "app", "routes", "_index.tsx"); - let originalIndex = fs.readFileSync(indexPath, "utf8"); - let counterPath = path.join(projectDir, "app", "components", "counter.tsx"); - let originalCounter = fs.readFileSync(counterPath, "utf8"); - let cssModulePath = path.join(projectDir, "app", "styles.module.css"); - let originalCssModule = fs.readFileSync(cssModulePath, "utf8"); - let mdxPath = path.join(projectDir, "app", "routes", "mdx.mdx"); - let originalMdx = fs.readFileSync(mdxPath, "utf8"); - - // make content and style changed to index route - let newCssModule = ` - .test { - background: black; - color: white; - } - `; - fs.writeFileSync(cssModulePath, newCssModule); - - let newIndex = ` - import { useLoaderData } from "@remix-run/react"; - import styles from "~/styles.module.css"; - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - const t = useLoaderData(); - return ( - - Changed - - ) - } - `; - fs.writeFileSync(indexPath, newIndex); - - // detect HMR'd content and style changes - await page.waitForLoadState("networkidle"); - - let h1 = page.getByText("Changed"); - await h1.waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(h1).toHaveCSS("color", "rgb(255, 255, 255)"); - expect(h1).toHaveCSS("background-color", "rgb(0, 0, 0)"); - - // verify that `` value was persisted (i.e. hmr, not full page refresh) - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - // undo change - fs.writeFileSync(indexPath, originalIndex); - fs.writeFileSync(cssModulePath, originalCssModule); - await page.getByText("Index Title").waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - // We should not have done any revalidation yet as only UI has changed - expect(dataRequests).toBe(0); - - // add loader - let withLoader1 = ` - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - export let loader = () => json({ hello: "world" }); - - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - let { hello } = useLoaderData(); - return ( - - Hello, {hello} - - ) - } - `; - fs.writeFileSync(indexPath, withLoader1); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(1); - await page.waitForLoadState("networkidle"); - - await page.getByText("Hello, world").waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - expect(dataRequests).toBe(1); - - let withLoader2 = ` - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - export function loader() { - return json({ hello: "planet" }) - } - - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - let { hello } = useLoaderData(); - return ( - - Hello, {hello} - - ) - } - `; - fs.writeFileSync(indexPath, withLoader2); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(2); - await page.waitForLoadState("networkidle"); - - await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - // change shared component - let updatedCounter = ` - import * as React from "react"; - export default function Counter({ id }) { - let [count, setCount] = React.useState(0); - return ( - - setCount(count - 1)}>dec {count} - - ); - } - `; - fs.writeFileSync(counterPath, updatedCounter); - await page.waitForSelector(`#root-counter:has-text("dec 1")`); - counter = await page.waitForSelector("#root-counter"); - await counter.click(); - await counter.click(); - await page.waitForSelector(`#root-counter:has-text("dec -1")`); - - await page.click(`a[href="/about"]`); - let aboutCounter = await page.waitForSelector( - `#about-counter:has-text("dec 0")` - ); - await aboutCounter.click(); - await page.waitForSelector(`#about-counter:has-text("dec -1")`); - - // undo change - fs.writeFileSync(counterPath, originalCounter); - - counter = await page.waitForSelector(`#root-counter:has-text("inc -1")`); - await counter.click(); - counter = await page.waitForSelector(`#root-counter:has-text("inc 0")`); - - aboutCounter = await page.waitForSelector( - `#about-counter:has-text("inc -1")` - ); - await aboutCounter.click(); - aboutCounter = await page.waitForSelector( - `#about-counter:has-text("inc 0")` - ); - - expect(dataRequests).toBe(2); - - // mdx - await page.click(`a[href="/mdx"]`); - await page.waitForSelector(`#crazy`); - let mdx = `import { useLoaderData } from '@remix-run/react' -export const loader = () => "hot" -export const Component = () => { - const data = useLoaderData() - return {data} -} - -# heyo -whatsup - - -`; - fs.writeFileSync(mdxPath, mdx); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(4); - await page.waitForSelector(`#hot`); - - fs.writeFileSync(mdxPath, originalMdx); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(5); - await page.waitForSelector(`#crazy`); - - // dev server doesn't crash when rebuild fails - await page.click(`a[href="/"]`); - await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS }); - await page.waitForLoadState("networkidle"); - - let stderr = devStderr(); - let withSyntaxError = ` - import { useLoaderData } from "@remix-run/react"; - export function shouldRevalidate(args) { - return true; - } - eport efault functio Index() { - const t = useLoaderData(); - return ( - - With Syntax Error - - ) - } - `; - fs.writeFileSync(indexPath, withSyntaxError); - await wait( - () => - devStderr() - .replace(stderr, "") - .includes('Expected ";" but found "efault"'), - { - timeoutMs: HMR_TIMEOUT_MS, - } - ); - - // React Router integration w/ React Refresh has a bug where sometimes rerenders happen with old UI and new data - // in this case causing `TypeError: Cannot destructure property`. - // Need to fix that bug, but it only shows a harmless console error in the browser in dev - page.removeListener("pageerror", logConsoleError); - // let expectedErrorCount = 0; - let expectDestructureTypeError = expectConsoleError((error) => { - let expectedMessage = new Set([ - // chrome, edge - "Cannot destructure property 'hello' of 'useLoaderData(...)' as it is null.", - // firefox - "(intermediate value)() is null", - // webkit - "Right side of assignment cannot be destructured", - ]); - let isExpected = - error.name === "TypeError" && expectedMessage.has(error.message); - // if (isExpected) expectedErrorCount += 1; - return isExpected; - }); - page.on("pageerror", expectDestructureTypeError); - - let withFix = ` - import { useLoaderData } from "@remix-run/react"; - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - // const t = useLoaderData(); - return ( - - With Fix - - ) - } - `; - fs.writeFileSync(indexPath, withFix); - await page.waitForLoadState("networkidle"); - await page.getByText("With Fix").waitFor({ timeout: HMR_TIMEOUT_MS }); - - // Restore normal console error handling - page.removeListener("pageerror", expectDestructureTypeError); - // expect(expectedErrorCount).toBe(browserName === "webkit" ? 1 : 2); - page.addListener("pageerror", logConsoleError); - } catch (e) { - console.log("stdout begin -----------------------"); - console.log(devStdout()); - console.log("stdout end -------------------------"); - - console.log("stderr begin -----------------------"); - console.log(devStderr()); - console.log("stderr end -------------------------"); - throw e; - } finally { - dev.kill(); - } -}); diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts index 0339e97f5c1..dfec1d43ad8 100644 --- a/integration/hmr-test.ts +++ b/integration/hmr-test.ts @@ -1,9 +1,11 @@ -import { test, expect } from "@playwright/test"; -import execa from "execa"; import fs from "node:fs"; import path from "node:path"; import type { Readable } from "node:stream"; -import getPort, { makeRange } from "get-port"; +import type { Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; +import execa from "execa"; +import getPort from "get-port"; +import pidtree from "pidtree"; import type { FixtureInit } from "./helpers/create-fixture.js"; import { @@ -15,76 +17,8 @@ import { test.setTimeout(150_000); -let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ - config: { - dev: { - port: options.devPort, - }, - }, - files: { - "package.json": json({ - private: true, - sideEffects: false, - type: "module", - scripts: { - dev: `node ./node_modules/@remix-run/dev/dist/cli.js dev -c "node ./server.js"`, - }, - dependencies: { - "@remix-run/css-bundle": "0.0.0-local-version", - "@remix-run/node": "0.0.0-local-version", - "@remix-run/react": "0.0.0-local-version", - "cross-env": "0.0.0-local-version", - express: "0.0.0-local-version", - isbot: "0.0.0-local-version", - "postcss-import": "0.0.0-local-version", - react: "0.0.0-local-version", - "react-dom": "0.0.0-local-version", - tailwindcss: "0.0.0-local-version", - }, - devDependencies: { - "@remix-run/dev": "0.0.0-local-version", - "@types/react": "0.0.0-local-version", - "@types/react-dom": "0.0.0-local-version", - typescript: "0.0.0-local-version", - }, - engines: { - node: ">=18.0.0", - }, - }), - - "server.js": js` - import path from "path"; - import url from "url"; - import express from "express"; - import { createRequestHandler } from "@remix-run/express"; - import { broadcastDevReady, installGlobals } from "@remix-run/node"; - - installGlobals(); - - const app = express(); - app.use(express.static("public", { immutable: true, maxAge: "1y" })); - - const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); - - app.all( - "*", - createRequestHandler({ - build: await import(BUILD_PATH), - mode: process.env.NODE_ENV, - }) - ); - - let port = ${options.appPort}; - app.listen(port, async () => { - let build = await import(BUILD_PATH); - console.log('โ app ready: http://localhost:' + port); - if (process.env.NODE_ENV === 'development') { - broadcastDevReady(build); - } - }); - `, - - "postcss.config.cjs": js` +let files = { + "postcss.config.cjs": js` module.exports = { plugins: { "postcss-import": {}, // Testing PostCSS cache invalidation @@ -93,7 +27,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ }; `, - "tailwind.config.js": js` + "tailwind.config.js": js` /** @type {import('tailwindcss').Config} */ export default { content: ["./app/**/*.{ts,tsx,jsx,js}"], @@ -104,45 +38,45 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ }; `, - "app/tailwind.css": css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, + "app/tailwind.css": css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, - "app/stylesWithImport.css": css` - @import "./importedStyle.css"; - `, + "app/stylesWithImport.css": css` + @import "./importedStyle.css"; + `, - "app/importedStyle.css": css` - .importedStyle { - font-weight: normal; - } - `, + "app/importedStyle.css": css` + .importedStyle { + font-weight: normal; + } + `, - "app/sideEffectStylesWithImport.css": css` - @import "./importedSideEffectStyle.css"; - `, + "app/sideEffectStylesWithImport.css": css` + @import "./importedSideEffectStyle.css"; + `, - "app/importedSideEffectStyle.css": css` - .importedSideEffectStyle { - font-size: initial; - } - `, + "app/importedSideEffectStyle.css": css` + .importedSideEffectStyle { + font-size: initial; + } + `, - "app/style.module.css": css` - .test { - composes: color from "./composedStyle.module.css"; - } - `, + "app/style.module.css": css` + .test { + composes: color from "./composedStyle.module.css"; + } + `, - "app/composedStyle.module.css": css` - .color { - color: initial; - } - `, + "app/composedStyle.module.css": css` + .color { + color: initial; + } + `, - "app/root.tsx": js` + "app/root.tsx": js` import type { LinksFunction } from "@remix-run/node"; import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react"; import { cssBundleHref } from "@remix-run/css-bundle"; @@ -192,7 +126,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import { useLoaderData } from "@remix-run/react"; export function shouldRevalidate(args) { return true; @@ -207,7 +141,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ } `, - "app/routes/about.tsx": js` + "app/routes/about.tsx": js` import Counter from "../components/counter"; export default function About() { return ( @@ -218,7 +152,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ ) } `, - "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' + "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' export const loader = () => "crazy" export const Component = () => { const data = useLoaderData() @@ -230,7 +164,7 @@ whatsup `, - "app/components/counter.tsx": js` + "app/components/counter.tsx": js` import * as React from "react"; export default function Counter({ id }) { let [count, setCount] = React.useState(0); @@ -241,50 +175,135 @@ whatsup ); } `, - }, -}); - -let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -let wait = async ( - callback: () => boolean, - { timeoutMs = 1000, intervalMs = 250 } = {} -) => { - let start = Date.now(); - while (Date.now() - start <= timeoutMs) { - if (callback()) { - return; - } - await sleep(intervalMs); - } - throw Error(`wait: timeout ${timeoutMs}ms`); }; -let bufferize = (stream: Readable): (() => string) => { - let buffer = ""; - stream.on("data", (data) => (buffer += data.toString())); - return () => buffer; +let packageJson = (options: { devScript: string; deps?: string[] }) => { + return json({ + private: true, + sideEffects: false, + type: "module", + scripts: { + dev: options.devScript, + }, + dependencies: deps([ + ...(options.deps ?? []), + "@remix-run/css-bundle", + "@remix-run/express", + "@remix-run/node", + "@remix-run/react", + "cross-env", + "express", + "isbot", + "postcss-import", + "react", + "react-dom", + "tailwindcss", + ]), + devDependencies: deps([ + "@remix-run/dev", + "@types/react", + "@types/react-dom", + "typescript", + ]), + engines: { + node: ">=18.0.0", + }, + }); }; -let logConsoleError = (error: Error) => { - console.error(`[console] ${error.name}: ${error.message}`); -}; +let customServer = (options: { appPort: number; devReady: string }) => { + return js` + import path from "path"; + import url from "url"; + import express from "express"; + import { createRequestHandler } from "@remix-run/express"; + import { ${options.devReady}, installGlobals } from "@remix-run/node"; -let expectConsoleError = ( - isExpected: (error: Error) => boolean, - unexpected = logConsoleError -) => { - return (error: Error) => { - if (isExpected(error)) { - return; - } - unexpected(error); - }; + installGlobals(); + + const app = express(); + app.use(express.static("public", { immutable: true, maxAge: "1y" })); + + const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); + + app.all( + "*", + createRequestHandler({ + build: await import(BUILD_PATH), + mode: process.env.NODE_ENV, + }) + ); + + let port = ${options.appPort}; + app.listen(port, async () => { + let build = await import(BUILD_PATH); + console.log('โ app ready: http://localhost:' + port); + if (process.env.NODE_ENV === 'development') { + ${options.devReady}(build); + } + }); + `; }; -let HMR_TIMEOUT_MS = 10_000; +let HMR_TIMEOUT_MS = 30_000; + +let remix = "node ./node_modules/@remix-run/dev/dist/cli.js"; +let serve = "node ./node_modules/@remix-run/serve/dist/cli.js"; + +test("HMR for remix-serve", async ({ page }) => { + await dev(page, { + files: (appPort) => ({ + ...files, + "package.json": packageJson({ + devScript: `cross-env PORT=${appPort} ${remix} dev -c "${serve} ./build/index.js"`, + deps: ["@remix-run/serve"], + }), + }), + appReadyPattern: /\[remix-serve\] /, + }); +}); + +test("HMR for custom server with broadcast", async ({ page }) => { + await dev(page, { + files: (appPort) => ({ + ...files, + "package.json": packageJson({ + devScript: `${remix} dev -c "node ./server.js"`, + deps: ["@remix-run/express"], + }), + "server.js": customServer({ + appPort, + devReady: "broadcastDevReady", + }), + }), + appReadyPattern: /โ app ready: /, + }); +}); -test("HMR", async ({ page, browserName }) => { +test("HMR for custom server with log", async ({ page }) => { + await dev(page, { + files: (appPort) => ({ + ...files, + "package.json": packageJson({ + devScript: `${remix} dev -c "node ./server.js"`, + deps: ["@remix-run/express"], + }), + "server.js": customServer({ + appPort, + devReady: "logDevReady", + }), + }), + appReadyPattern: /โ app ready: /, + }); +}); + +async function dev( + page: Page, + options: { + files: (appPort: number) => Record; + appReadyPattern: RegExp; + } +) { // uncomment for debugging // page.on("console", (msg) => console.log(msg.text())); page.on("pageerror", logConsoleError); @@ -296,24 +315,32 @@ test("HMR", async ({ page, browserName }) => { } }); - let portRange = makeRange(3080, 3099); - let appPort = await getPort({ port: portRange }); - let devPort = await getPort({ port: portRange }); - let projectDir = await createFixtureProject(fixture({ appPort, devPort })); + let appPort = await getPort(); + let devPort = await getPort(); + + let fixture: FixtureInit = { + config: { + dev: { + port: devPort, + }, + }, + files: options.files(appPort), + }; + + let projectDir = await createFixtureProject(fixture); + + let devProc = execa("npm", ["run", "dev"], { cwd: projectDir }); + let devStdout = bufferize(devProc.stdout!); + let devStderr = bufferize(devProc.stderr!); - // spin up dev server - let dev = execa("npm", ["run", "dev"], { cwd: projectDir }); - let devStdout = bufferize(dev.stdout!); - let devStderr = bufferize(dev.stderr!); try { await wait( () => { - if (dev.exitCode) throw Error("Dev server exited early"); - return /โ app ready: /.test(devStdout()); + if (devProc.exitCode) throw Error("Dev server exited early"); + return options.appReadyPattern.test(devStdout()); }, { timeoutMs: HMR_TIMEOUT_MS } ); - await page.goto(`http://localhost:${appPort}`, { waitUntil: "networkidle", }); @@ -590,7 +617,7 @@ whatsup // let expectedErrorCount = 0; let expectDestructureTypeError = expectConsoleError((error) => { let expectedMessage = new Set([ - // chrome, edge + // chrome, msedge "Cannot destructure property 'hello' of 'useLoaderData(...)' as it is null.", // firefox "(intermediate value)() is null", @@ -636,6 +663,105 @@ whatsup console.log("stderr end -------------------------"); throw e; } finally { - dev.kill(); + devProc.pid && (await killtree(devProc.pid)); } -}); +} + +let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +let wait = async ( + callback: () => boolean, + { timeoutMs = 1000, intervalMs = 250 } = {} +) => { + let start = Date.now(); + while (Date.now() - start <= timeoutMs) { + if (callback()) { + return; + } + await sleep(intervalMs); + } + throw Error(`wait: timeout ${timeoutMs}ms`); +}; + +let bufferize = (stream: Readable): (() => string) => { + let buffer = ""; + stream.on("data", (data) => (buffer += data.toString())); + return () => buffer; +}; + +let logConsoleError = (error: Error) => { + console.error(`[console] ${error.name}: ${error.message}`); +}; + +let expectConsoleError = ( + isExpected: (error: Error) => boolean, + unexpected = logConsoleError +) => { + return (error: Error) => { + if (isExpected(error)) { + return; + } + unexpected(error); + }; +}; + +let deps = (packages: string[]): Record => { + return Object.fromEntries( + packages.map((pkg) => [pkg, "0.0.0-local-version"]) + ); +}; + +let isWindows = process.platform === "win32"; + +let 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); + }); +}; + +let isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return false; + } +}; + +let 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/playwright.config.ts b/integration/playwright.config.ts index 8b30808720e..62c3e39cfd3 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -26,8 +26,13 @@ const config: PlaywrightTestConfig = { use: devices["Desktop Safari"], }, { - name: "edge", - use: devices["Desktop Edge"], + name: "msedge", + use: { + ...devices["Desktop Edge"], + // Desktop Edge uses chromium by default + // https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/deviceDescriptorsSource.json#L1502 + channel: "msedge", + }, }, { name: "firefox", diff --git a/package.json b/package.json index b895b422f02..aa73cead05d 100644 --- a/package.json +++ b/package.json @@ -97,8 +97,10 @@ "eslint": "^8.23.1", "eslint-plugin-markdown": "^2.2.1", "eslint-plugin-prefer-let": "^3.0.1", + "execa": "5.1.1", "express": "^4.17.1", "front-matter": "^4.0.2", + "get-port": "5.1.1", "glob": "8.0.3", "isbot": "^3.5.1", "jest": "^27.5.1", @@ -109,6 +111,7 @@ "mime": "^3.0.0", "npm-run-all": "^4.1.5", "patch-package": "^6.5.0", + "pidtree": "^0.6.0", "postcss-import": "^15.1.0", "prettier": "^2.7.1", "prompt-confirm": "^2.0.4", diff --git a/packages/remix-node/__tests__/sessions-test.ts b/packages/remix-node/__tests__/sessions-test.ts index b10b33bda93..5aa8c24f52a 100644 --- a/packages/remix-node/__tests__/sessions-test.ts +++ b/packages/remix-node/__tests__/sessions-test.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { promises as fsp } from "node:fs"; import os from "node:os"; -import { createFileSessionStorage } from "../sessions/fileStorage"; +import { createFileSessionStorage, getFile } from "../sessions/fileStorage"; function getCookieFromSetCookie(setCookie: string): string { return setCookie.split(/;\s*/)[0]; @@ -62,6 +62,39 @@ describe("File session storage", () => { await expect(destroySession(session)).resolves.not.toThrowError(); }); + it("saves expires to file if expires provided to commitSession when creating new cookie", async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let date = new Date(Date.now() + 1000 * 60); + let cookieHeader = await commitSession(session, { expires: date }); + let createdSession = await getSession(cookieHeader); + + let { id } = createdSession; + let fileContents = await fsp.readFile(getFile(dir, id), "utf8"); + let fileData = JSON.parse(fileContents); + expect(fileData.expires).toEqual(date.toISOString()); + }); + + it("saves expires to file if maxAge provided to commitSession when creating new cookie", async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let cookieHeader = await commitSession(session, { maxAge: 60 }); + let createdSession = await getSession(cookieHeader); + + let { id } = createdSession; + let fileContents = await fsp.readFile(getFile(dir, id), "utf8"); + let fileData = JSON.parse(fileContents); + expect(typeof fileData.expires).toBe("string"); + }); + 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 } = createFileSessionStorage({ diff --git a/packages/remix-node/sessions/fileStorage.ts b/packages/remix-node/sessions/fileStorage.ts index a7eedf3b8cb..042539710cf 100644 --- a/packages/remix-node/sessions/fileStorage.ts +++ b/packages/remix-node/sessions/fileStorage.ts @@ -103,7 +103,7 @@ export function createFileSessionStorage({ }); } -function getFile(dir: string, id: string): string { +export function getFile(dir: string, id: string): string { // Divide the session id up into a directory (first 2 bytes) and filename // (remaining 6 bytes) to reduce the chance of having very large directories, // which should speed up file access. This is a maximum of 2^16 directories, diff --git a/packages/remix-serve/cli.ts b/packages/remix-serve/cli.ts index 97dd18dd33a..f1ffe8b3791 100644 --- a/packages/remix-serve/cli.ts +++ b/packages/remix-serve/cli.ts @@ -1,13 +1,15 @@ import "@remix-run/node/install"; -import path from "node:path"; +import fs from "node:fs"; import os from "node:os"; +import path from "node:path"; import url from "node:url"; import { type ServerBuild, broadcastDevReady, installGlobals, } from "@remix-run/node"; -import { createRequestHandler } from "@remix-run/express"; +import { type RequestHandler, createRequestHandler } from "@remix-run/express"; +import chokidar from "chokidar"; import compression from "compression"; import express from "express"; import morgan from "morgan"; @@ -32,11 +34,43 @@ async function run() { process.exit(1); } - let buildPath = url.pathToFileURL( - path.resolve(process.cwd(), buildPathArg) - ).href; + let buildPath = path.resolve(buildPathArg); - let build: ServerBuild = await import(buildPath); + async function reimportServer() { + let stat = fs.statSync(buildPath); + + // use a timestamp query parameter to bust the import cache + return import(url.pathToFileURL(buildPath).href + "?t=" + stat.mtimeMs); + } + + function createDevRequestHandler(initialBuild: ServerBuild): RequestHandler { + let build = initialBuild; + async function handleServerUpdate() { + // 1. re-import the server build + build = await reimportServer(); + // 2. tell Remix that this app server is now up-to-date and ready + broadcastDevReady(build); + } + + chokidar + .watch(buildPath, { ignoreInitial: true }) + .on("add", handleServerUpdate) + .on("change", handleServerUpdate); + + // wrap request handler to make sure its recreated with the latest build for every request + return async (req, res, next) => { + try { + return createRequestHandler({ + build, + mode: "development", + })(req, res, next); + } catch (error) { + next(error); + } + }; + } + + let build: ServerBuild = await reimportServer(); let onListen = () => { let address = @@ -47,10 +81,10 @@ async function run() { ?.address; if (!address) { - console.log(`Remix App Server started at http://localhost:${port}`); + console.log(`[remix-serve] http://localhost:${port}`); } else { console.log( - `Remix App Server started at http://localhost:${port} (http://${address}:${port})` + `[remix-serve] http://localhost:${port} (http://${address}:${port})` ); } if (process.env.NODE_ENV === "development") { @@ -71,22 +105,15 @@ async function run() { app.use(express.static("public", { maxAge: "1h" })); app.use(morgan("tiny")); - let requestHandler: ReturnType | undefined; - app.all("*", async (req, res, next) => { - try { - if (!requestHandler) { - let build = await import(buildPath); - requestHandler = createRequestHandler({ + app.all( + "*", + process.env.NODE_ENV === "development" + ? createDevRequestHandler(build) + : createRequestHandler({ build, mode: process.env.NODE_ENV, - }); - } - - return await requestHandler(req, res, next); - } catch (error) { - next(error); - } - }); + }) + ); let server = process.env.HOST ? app.listen(port, process.env.HOST, onListen) diff --git a/packages/remix-serve/package.json b/packages/remix-serve/package.json index ac6d7552c31..06524c6b710 100644 --- a/packages/remix-serve/package.json +++ b/packages/remix-serve/package.json @@ -17,6 +17,7 @@ "dependencies": { "@remix-run/express": "1.19.3", "@remix-run/node": "1.19.3", + "chokidar": "^3.5.3", "compression": "^1.7.4", "express": "^4.17.1", "morgan": "^1.10.0", diff --git a/packages/remix-server-runtime/sessions.ts b/packages/remix-server-runtime/sessions.ts index bf809b65414..2d4a6c90806 100644 --- a/packages/remix-server-runtime/sessions.ts +++ b/packages/remix-server-runtime/sessions.ts @@ -277,11 +277,17 @@ export const createSessionStorageFactory = }, async commitSession(session, options) { let { id, data } = session; + let expires = + options?.maxAge != null + ? new Date(Date.now() + options.maxAge * 1000) + : options?.expires != null + ? options.expires + : cookie.expires; if (id) { - await updateData(id, data, cookie.expires); + await updateData(id, data, expires); } else { - id = await createData(data, cookie.expires); + id = await createData(data, expires); } return cookie.serialize(id, options); diff --git a/templates/remix-javascript/package.json b/templates/remix-javascript/package.json index 7143725f8e2..521a8e35ae7 100644 --- a/templates/remix-javascript/package.json +++ b/templates/remix-javascript/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "remix build", - "dev": "remix dev", + "dev": "remix dev --manual", "start": "remix-serve build" }, "dependencies": { diff --git a/templates/remix/package.json b/templates/remix/package.json index b79f377eec7..22c4b044d8d 100644 --- a/templates/remix/package.json +++ b/templates/remix/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "remix build", - "dev": "remix dev", + "dev": "remix dev --manual", "start": "remix-serve build", "typecheck": "tsc" }, diff --git a/yarn.lock b/yarn.lock index f161055e2d5..30e78f2d70c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6076,9 +6076,9 @@ get-package-type@^0.1.0: resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== -get-port@^5.1.1: +get-port@5.1.1, get-port@^5.1.1: version "5.1.1" - resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz" + resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== get-stream@^5.0.0, get-stream@^5.1.0:
but Edge puts it in some weird code editor markup: + // + // "LUNCH" + expect(await app.getHtml()).toContain(CHEESESTEAK); }); }); diff --git a/integration/helpers/create-fixture.ts b/integration/helpers/create-fixture.ts index 743dd98b152..c513017ff4c 100644 --- a/integration/helpers/create-fixture.ts +++ b/integration/helpers/create-fixture.ts @@ -137,7 +137,7 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) { let newChunk = chunk.toString(); stdout += newChunk; let match: RegExpMatchArray | null = stdout.match( - /started at http:\/\/localhost:(\d+)\s/ + /\[remix-serve\] http:\/\/localhost:(\d+)\s/ ); if (match) { clearTimeout(rejectTimeout); @@ -223,6 +223,26 @@ export async function createFixtureProject( path.join(projectDir, "node_modules"), { overwrite: true } ); + // let remixDev = path.join( + // projectDir, + // "node_modules/@remix-run/dev/dist/cli.js" + // ); + // await fse.chmod(remixDev, 0o755); + // await fse.ensureSymlink( + // remixDev, + // path.join(projectDir, "node_modules/.bin/remix") + // ); + // + // let remixServe = path.join( + // projectDir, + // "node_modules/@remix-run/serve/dist/cli.js" + // ); + // await fse.chmod(remixServe, 0o755); + // await fse.ensureSymlink( + // remixServe, + // path.join(projectDir, "node_modules/.bin/remix-serve") + // ); + await writeTestFiles(init, projectDir); // We update the config file *after* writing test files so that tests can provide a custom diff --git a/integration/helpers/node-template/package.json b/integration/helpers/node-template/package.json index e202cff6ca6..05953d0ec46 100644 --- a/integration/helpers/node-template/package.json +++ b/integration/helpers/node-template/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "node ../../../build/node_modules/@remix-run/dev/dist/cli.js build", "dev": "node ../../../build/node_modules/@remix-run/dev/dist/cli.js dev", - "start": "node ../../../build/node_modules/@remix-run/serve/dist/cli.js build" + "start": "node ../../../build/node_modules/@remix-run/serve/dist/cli.js ./build/index.js" }, "dependencies": { "@remix-run/node": "0.0.0-local-version", diff --git a/integration/hmr-log-test.ts b/integration/hmr-log-test.ts deleted file mode 100644 index 750b893430d..00000000000 --- a/integration/hmr-log-test.ts +++ /dev/null @@ -1,561 +0,0 @@ -import { test, expect } from "@playwright/test"; -import execa from "execa"; -import fs from "node:fs"; -import path from "node:path"; -import type { Readable } from "node:stream"; -import getPort, { makeRange } from "get-port"; - -import type { FixtureInit } from "./helpers/create-fixture.js"; -import { - createFixtureProject, - css, - js, - json, -} from "./helpers/create-fixture.js"; - -test.setTimeout(120_000); - -let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ - config: { - dev: { - port: options.devPort, - }, - }, - files: { - "package.json": json({ - private: true, - sideEffects: false, - type: "module", - scripts: { - dev: `node ./node_modules/@remix-run/dev/dist/cli.js dev -c "node ./server.js"`, - }, - dependencies: { - "@remix-run/css-bundle": "0.0.0-local-version", - "@remix-run/node": "0.0.0-local-version", - "@remix-run/react": "0.0.0-local-version", - "cross-env": "0.0.0-local-version", - express: "0.0.0-local-version", - isbot: "0.0.0-local-version", - react: "0.0.0-local-version", - "react-dom": "0.0.0-local-version", - tailwindcss: "0.0.0-local-version", - }, - devDependencies: { - "@remix-run/dev": "0.0.0-local-version", - "@types/react": "0.0.0-local-version", - "@types/react-dom": "0.0.0-local-version", - typescript: "0.0.0-local-version", - }, - engines: { - node: ">=18.0.0", - }, - }), - - "server.js": js` - import path from "path"; - import url from "url"; - import express from "express"; - import { createRequestHandler } from "@remix-run/express"; - import { logDevReady, installGlobals } from "@remix-run/node"; - - installGlobals(); - - const app = express(); - app.use(express.static("public", { immutable: true, maxAge: "1y" })); - - const MODE = process.env.NODE_ENV; - const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); - - app.all( - "*", - createRequestHandler({ - build: await import(BUILD_PATH), - mode: MODE, - }) - ); - - let port = ${options.appPort}; - app.listen(port, async () => { - let build = await import(BUILD_PATH); - console.log('โ app ready: http://localhost:' + port); - if (process.env.NODE_ENV === 'development') { - logDevReady(build); - } - }); - `, - - "tailwind.config.js": js` - /** @type {import('tailwindcss').Config} */ - module.exports = { - content: ["./app/**/*.{ts,tsx,jsx,js}"], - theme: { - extend: {}, - }, - plugins: [], - }; - `, - - "app/tailwind.css": css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, - - "app/styles.module.css": css` - .test { - color: initial; - } - `, - - "app/root.tsx": js` - import type { LinksFunction } from "@remix-run/node"; - import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react"; - import { cssBundleHref } from "@remix-run/css-bundle"; - - import Counter from "./components/counter"; - import styles from "./tailwind.css"; - - export const links: LinksFunction = () => [ - { rel: "stylesheet", href: styles }, - ...cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : [], - ]; - - // dummy loader to make sure that HDR is granular - export const loader = () => { - return null; - }; - - export default function Root() { - return ( - - - - - - - - Root Input - - - - - Home - About - MDX - - - - - - - - - ); - } - `, - - "app/routes/_index.tsx": js` - import { useLoaderData } from "@remix-run/react"; - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - const t = useLoaderData(); - return ( - - Index Title - - ) - } - `, - - "app/routes/about.tsx": js` - import Counter from "../components/counter"; - export default function About() { - return ( - - About Title - - - ) - } - `, - "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' -export const loader = () => "crazy" -export const Component = () => { - const data = useLoaderData() - return {data} -} - -# heyo -whatsup - - -`, - - "app/components/counter.tsx": js` - import * as React from "react"; - export default function Counter({ id }) { - let [count, setCount] = React.useState(0); - return ( - - setCount(count + 1)}>inc {count} - - ); - } - `, - }, -}); - -let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -let wait = async ( - callback: () => boolean, - { timeoutMs = 1000, intervalMs = 250 } = {} -) => { - let start = Date.now(); - while (Date.now() - start <= timeoutMs) { - if (callback()) { - return; - } - await sleep(intervalMs); - } - throw Error(`wait: timeout ${timeoutMs}ms`); -}; - -let bufferize = (stream: Readable): (() => string) => { - let buffer = ""; - stream.on("data", (data) => (buffer += data.toString())); - return () => buffer; -}; - -let logConsoleError = (error: Error) => { - console.error(`[console] ${error.name}: ${error.message}`); -}; - -let expectConsoleError = ( - isExpected: (error: Error) => boolean, - unexpected = logConsoleError -) => { - return (error: Error) => { - if (isExpected(error)) { - return; - } - unexpected(error); - }; -}; - -let HMR_TIMEOUT_MS = 10_000; - -test("HMR", async ({ page, browserName }) => { - // uncomment for debugging - // page.on("console", (msg) => console.log(msg.text())); - page.on("pageerror", logConsoleError); - let dataRequests = 0; - page.on("request", (request) => { - let url = new URL(request.url()); - if (url.searchParams.has("_data")) { - dataRequests++; - } - }); - - let portRange = makeRange(4080, 4099); - let appPort = await getPort({ port: portRange }); - let devPort = await getPort({ port: portRange }); - let projectDir = await createFixtureProject(fixture({ appPort, devPort })); - - // spin up dev server - let dev = execa("npm", ["run", "dev"], { cwd: projectDir }); - let devStdout = bufferize(dev.stdout!); - let devStderr = bufferize(dev.stderr!); - try { - await wait( - () => { - if (dev.exitCode) throw Error("Dev server exited early"); - return /โ app ready: /.test(devStdout()); - }, - { timeoutMs: HMR_TIMEOUT_MS } - ); - - await page.goto(`http://localhost:${appPort}`, { - waitUntil: "networkidle", - }); - - // `` value as page state that - // would be wiped out by a full page refresh - // but should be persisted by hmr - let input = page.getByLabel("Root Input"); - expect(input).toBeVisible(); - await input.type("asdfasdf"); - - let counter = await page.waitForSelector("#root-counter"); - await counter.click(); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - let indexPath = path.join(projectDir, "app", "routes", "_index.tsx"); - let originalIndex = fs.readFileSync(indexPath, "utf8"); - let counterPath = path.join(projectDir, "app", "components", "counter.tsx"); - let originalCounter = fs.readFileSync(counterPath, "utf8"); - let cssModulePath = path.join(projectDir, "app", "styles.module.css"); - let originalCssModule = fs.readFileSync(cssModulePath, "utf8"); - let mdxPath = path.join(projectDir, "app", "routes", "mdx.mdx"); - let originalMdx = fs.readFileSync(mdxPath, "utf8"); - - // make content and style changed to index route - let newCssModule = ` - .test { - background: black; - color: white; - } - `; - fs.writeFileSync(cssModulePath, newCssModule); - - let newIndex = ` - import { useLoaderData } from "@remix-run/react"; - import styles from "~/styles.module.css"; - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - const t = useLoaderData(); - return ( - - Changed - - ) - } - `; - fs.writeFileSync(indexPath, newIndex); - - // detect HMR'd content and style changes - await page.waitForLoadState("networkidle"); - - let h1 = page.getByText("Changed"); - await h1.waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(h1).toHaveCSS("color", "rgb(255, 255, 255)"); - expect(h1).toHaveCSS("background-color", "rgb(0, 0, 0)"); - - // verify that `` value was persisted (i.e. hmr, not full page refresh) - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - // undo change - fs.writeFileSync(indexPath, originalIndex); - fs.writeFileSync(cssModulePath, originalCssModule); - await page.getByText("Index Title").waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - // We should not have done any revalidation yet as only UI has changed - expect(dataRequests).toBe(0); - - // add loader - let withLoader1 = ` - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - export let loader = () => json({ hello: "world" }); - - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - let { hello } = useLoaderData(); - return ( - - Hello, {hello} - - ) - } - `; - fs.writeFileSync(indexPath, withLoader1); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(1); - await page.waitForLoadState("networkidle"); - - await page.getByText("Hello, world").waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - expect(dataRequests).toBe(1); - - let withLoader2 = ` - import { json } from "@remix-run/node"; - import { useLoaderData } from "@remix-run/react"; - - export function loader() { - return json({ hello: "planet" }) - } - - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - let { hello } = useLoaderData(); - return ( - - Hello, {hello} - - ) - } - `; - fs.writeFileSync(indexPath, withLoader2); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(2); - await page.waitForLoadState("networkidle"); - - await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS }); - expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); - await page.waitForSelector(`#root-counter:has-text("inc 1")`); - - // change shared component - let updatedCounter = ` - import * as React from "react"; - export default function Counter({ id }) { - let [count, setCount] = React.useState(0); - return ( - - setCount(count - 1)}>dec {count} - - ); - } - `; - fs.writeFileSync(counterPath, updatedCounter); - await page.waitForSelector(`#root-counter:has-text("dec 1")`); - counter = await page.waitForSelector("#root-counter"); - await counter.click(); - await counter.click(); - await page.waitForSelector(`#root-counter:has-text("dec -1")`); - - await page.click(`a[href="/about"]`); - let aboutCounter = await page.waitForSelector( - `#about-counter:has-text("dec 0")` - ); - await aboutCounter.click(); - await page.waitForSelector(`#about-counter:has-text("dec -1")`); - - // undo change - fs.writeFileSync(counterPath, originalCounter); - - counter = await page.waitForSelector(`#root-counter:has-text("inc -1")`); - await counter.click(); - counter = await page.waitForSelector(`#root-counter:has-text("inc 0")`); - - aboutCounter = await page.waitForSelector( - `#about-counter:has-text("inc -1")` - ); - await aboutCounter.click(); - aboutCounter = await page.waitForSelector( - `#about-counter:has-text("inc 0")` - ); - - expect(dataRequests).toBe(2); - - // mdx - await page.click(`a[href="/mdx"]`); - await page.waitForSelector(`#crazy`); - let mdx = `import { useLoaderData } from '@remix-run/react' -export const loader = () => "hot" -export const Component = () => { - const data = useLoaderData() - return {data} -} - -# heyo -whatsup - - -`; - fs.writeFileSync(mdxPath, mdx); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(4); - await page.waitForSelector(`#hot`); - - fs.writeFileSync(mdxPath, originalMdx); - await expect.poll(() => dataRequests, { timeout: HMR_TIMEOUT_MS }).toBe(5); - await page.waitForSelector(`#crazy`); - - // dev server doesn't crash when rebuild fails - await page.click(`a[href="/"]`); - await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS }); - await page.waitForLoadState("networkidle"); - - let stderr = devStderr(); - let withSyntaxError = ` - import { useLoaderData } from "@remix-run/react"; - export function shouldRevalidate(args) { - return true; - } - eport efault functio Index() { - const t = useLoaderData(); - return ( - - With Syntax Error - - ) - } - `; - fs.writeFileSync(indexPath, withSyntaxError); - await wait( - () => - devStderr() - .replace(stderr, "") - .includes('Expected ";" but found "efault"'), - { - timeoutMs: HMR_TIMEOUT_MS, - } - ); - - // React Router integration w/ React Refresh has a bug where sometimes rerenders happen with old UI and new data - // in this case causing `TypeError: Cannot destructure property`. - // Need to fix that bug, but it only shows a harmless console error in the browser in dev - page.removeListener("pageerror", logConsoleError); - // let expectedErrorCount = 0; - let expectDestructureTypeError = expectConsoleError((error) => { - let expectedMessage = new Set([ - // chrome, edge - "Cannot destructure property 'hello' of 'useLoaderData(...)' as it is null.", - // firefox - "(intermediate value)() is null", - // webkit - "Right side of assignment cannot be destructured", - ]); - let isExpected = - error.name === "TypeError" && expectedMessage.has(error.message); - // if (isExpected) expectedErrorCount += 1; - return isExpected; - }); - page.on("pageerror", expectDestructureTypeError); - - let withFix = ` - import { useLoaderData } from "@remix-run/react"; - export function shouldRevalidate(args) { - return true; - } - export default function Index() { - // const t = useLoaderData(); - return ( - - With Fix - - ) - } - `; - fs.writeFileSync(indexPath, withFix); - await page.waitForLoadState("networkidle"); - await page.getByText("With Fix").waitFor({ timeout: HMR_TIMEOUT_MS }); - - // Restore normal console error handling - page.removeListener("pageerror", expectDestructureTypeError); - // expect(expectedErrorCount).toBe(browserName === "webkit" ? 1 : 2); - page.addListener("pageerror", logConsoleError); - } catch (e) { - console.log("stdout begin -----------------------"); - console.log(devStdout()); - console.log("stdout end -------------------------"); - - console.log("stderr begin -----------------------"); - console.log(devStderr()); - console.log("stderr end -------------------------"); - throw e; - } finally { - dev.kill(); - } -}); diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts index 0339e97f5c1..dfec1d43ad8 100644 --- a/integration/hmr-test.ts +++ b/integration/hmr-test.ts @@ -1,9 +1,11 @@ -import { test, expect } from "@playwright/test"; -import execa from "execa"; import fs from "node:fs"; import path from "node:path"; import type { Readable } from "node:stream"; -import getPort, { makeRange } from "get-port"; +import type { Page } from "@playwright/test"; +import { test, expect } from "@playwright/test"; +import execa from "execa"; +import getPort from "get-port"; +import pidtree from "pidtree"; import type { FixtureInit } from "./helpers/create-fixture.js"; import { @@ -15,76 +17,8 @@ import { test.setTimeout(150_000); -let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ - config: { - dev: { - port: options.devPort, - }, - }, - files: { - "package.json": json({ - private: true, - sideEffects: false, - type: "module", - scripts: { - dev: `node ./node_modules/@remix-run/dev/dist/cli.js dev -c "node ./server.js"`, - }, - dependencies: { - "@remix-run/css-bundle": "0.0.0-local-version", - "@remix-run/node": "0.0.0-local-version", - "@remix-run/react": "0.0.0-local-version", - "cross-env": "0.0.0-local-version", - express: "0.0.0-local-version", - isbot: "0.0.0-local-version", - "postcss-import": "0.0.0-local-version", - react: "0.0.0-local-version", - "react-dom": "0.0.0-local-version", - tailwindcss: "0.0.0-local-version", - }, - devDependencies: { - "@remix-run/dev": "0.0.0-local-version", - "@types/react": "0.0.0-local-version", - "@types/react-dom": "0.0.0-local-version", - typescript: "0.0.0-local-version", - }, - engines: { - node: ">=18.0.0", - }, - }), - - "server.js": js` - import path from "path"; - import url from "url"; - import express from "express"; - import { createRequestHandler } from "@remix-run/express"; - import { broadcastDevReady, installGlobals } from "@remix-run/node"; - - installGlobals(); - - const app = express(); - app.use(express.static("public", { immutable: true, maxAge: "1y" })); - - const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); - - app.all( - "*", - createRequestHandler({ - build: await import(BUILD_PATH), - mode: process.env.NODE_ENV, - }) - ); - - let port = ${options.appPort}; - app.listen(port, async () => { - let build = await import(BUILD_PATH); - console.log('โ app ready: http://localhost:' + port); - if (process.env.NODE_ENV === 'development') { - broadcastDevReady(build); - } - }); - `, - - "postcss.config.cjs": js` +let files = { + "postcss.config.cjs": js` module.exports = { plugins: { "postcss-import": {}, // Testing PostCSS cache invalidation @@ -93,7 +27,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ }; `, - "tailwind.config.js": js` + "tailwind.config.js": js` /** @type {import('tailwindcss').Config} */ export default { content: ["./app/**/*.{ts,tsx,jsx,js}"], @@ -104,45 +38,45 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ }; `, - "app/tailwind.css": css` - @tailwind base; - @tailwind components; - @tailwind utilities; - `, + "app/tailwind.css": css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, - "app/stylesWithImport.css": css` - @import "./importedStyle.css"; - `, + "app/stylesWithImport.css": css` + @import "./importedStyle.css"; + `, - "app/importedStyle.css": css` - .importedStyle { - font-weight: normal; - } - `, + "app/importedStyle.css": css` + .importedStyle { + font-weight: normal; + } + `, - "app/sideEffectStylesWithImport.css": css` - @import "./importedSideEffectStyle.css"; - `, + "app/sideEffectStylesWithImport.css": css` + @import "./importedSideEffectStyle.css"; + `, - "app/importedSideEffectStyle.css": css` - .importedSideEffectStyle { - font-size: initial; - } - `, + "app/importedSideEffectStyle.css": css` + .importedSideEffectStyle { + font-size: initial; + } + `, - "app/style.module.css": css` - .test { - composes: color from "./composedStyle.module.css"; - } - `, + "app/style.module.css": css` + .test { + composes: color from "./composedStyle.module.css"; + } + `, - "app/composedStyle.module.css": css` - .color { - color: initial; - } - `, + "app/composedStyle.module.css": css` + .color { + color: initial; + } + `, - "app/root.tsx": js` + "app/root.tsx": js` import type { LinksFunction } from "@remix-run/node"; import { Link, Links, LiveReload, Meta, Outlet, Scripts } from "@remix-run/react"; import { cssBundleHref } from "@remix-run/css-bundle"; @@ -192,7 +126,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ } `, - "app/routes/_index.tsx": js` + "app/routes/_index.tsx": js` import { useLoaderData } from "@remix-run/react"; export function shouldRevalidate(args) { return true; @@ -207,7 +141,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ } `, - "app/routes/about.tsx": js` + "app/routes/about.tsx": js` import Counter from "../components/counter"; export default function About() { return ( @@ -218,7 +152,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ ) } `, - "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' + "app/routes/mdx.mdx": `import { useLoaderData } from '@remix-run/react' export const loader = () => "crazy" export const Component = () => { const data = useLoaderData() @@ -230,7 +164,7 @@ whatsup `, - "app/components/counter.tsx": js` + "app/components/counter.tsx": js` import * as React from "react"; export default function Counter({ id }) { let [count, setCount] = React.useState(0); @@ -241,50 +175,135 @@ whatsup ); } `, - }, -}); - -let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -let wait = async ( - callback: () => boolean, - { timeoutMs = 1000, intervalMs = 250 } = {} -) => { - let start = Date.now(); - while (Date.now() - start <= timeoutMs) { - if (callback()) { - return; - } - await sleep(intervalMs); - } - throw Error(`wait: timeout ${timeoutMs}ms`); }; -let bufferize = (stream: Readable): (() => string) => { - let buffer = ""; - stream.on("data", (data) => (buffer += data.toString())); - return () => buffer; +let packageJson = (options: { devScript: string; deps?: string[] }) => { + return json({ + private: true, + sideEffects: false, + type: "module", + scripts: { + dev: options.devScript, + }, + dependencies: deps([ + ...(options.deps ?? []), + "@remix-run/css-bundle", + "@remix-run/express", + "@remix-run/node", + "@remix-run/react", + "cross-env", + "express", + "isbot", + "postcss-import", + "react", + "react-dom", + "tailwindcss", + ]), + devDependencies: deps([ + "@remix-run/dev", + "@types/react", + "@types/react-dom", + "typescript", + ]), + engines: { + node: ">=18.0.0", + }, + }); }; -let logConsoleError = (error: Error) => { - console.error(`[console] ${error.name}: ${error.message}`); -}; +let customServer = (options: { appPort: number; devReady: string }) => { + return js` + import path from "path"; + import url from "url"; + import express from "express"; + import { createRequestHandler } from "@remix-run/express"; + import { ${options.devReady}, installGlobals } from "@remix-run/node"; -let expectConsoleError = ( - isExpected: (error: Error) => boolean, - unexpected = logConsoleError -) => { - return (error: Error) => { - if (isExpected(error)) { - return; - } - unexpected(error); - }; + installGlobals(); + + const app = express(); + app.use(express.static("public", { immutable: true, maxAge: "1y" })); + + const BUILD_PATH = url.pathToFileURL(path.join(process.cwd(), "build", "index.js")); + + app.all( + "*", + createRequestHandler({ + build: await import(BUILD_PATH), + mode: process.env.NODE_ENV, + }) + ); + + let port = ${options.appPort}; + app.listen(port, async () => { + let build = await import(BUILD_PATH); + console.log('โ app ready: http://localhost:' + port); + if (process.env.NODE_ENV === 'development') { + ${options.devReady}(build); + } + }); + `; }; -let HMR_TIMEOUT_MS = 10_000; +let HMR_TIMEOUT_MS = 30_000; + +let remix = "node ./node_modules/@remix-run/dev/dist/cli.js"; +let serve = "node ./node_modules/@remix-run/serve/dist/cli.js"; + +test("HMR for remix-serve", async ({ page }) => { + await dev(page, { + files: (appPort) => ({ + ...files, + "package.json": packageJson({ + devScript: `cross-env PORT=${appPort} ${remix} dev -c "${serve} ./build/index.js"`, + deps: ["@remix-run/serve"], + }), + }), + appReadyPattern: /\[remix-serve\] /, + }); +}); + +test("HMR for custom server with broadcast", async ({ page }) => { + await dev(page, { + files: (appPort) => ({ + ...files, + "package.json": packageJson({ + devScript: `${remix} dev -c "node ./server.js"`, + deps: ["@remix-run/express"], + }), + "server.js": customServer({ + appPort, + devReady: "broadcastDevReady", + }), + }), + appReadyPattern: /โ app ready: /, + }); +}); -test("HMR", async ({ page, browserName }) => { +test("HMR for custom server with log", async ({ page }) => { + await dev(page, { + files: (appPort) => ({ + ...files, + "package.json": packageJson({ + devScript: `${remix} dev -c "node ./server.js"`, + deps: ["@remix-run/express"], + }), + "server.js": customServer({ + appPort, + devReady: "logDevReady", + }), + }), + appReadyPattern: /โ app ready: /, + }); +}); + +async function dev( + page: Page, + options: { + files: (appPort: number) => Record; + appReadyPattern: RegExp; + } +) { // uncomment for debugging // page.on("console", (msg) => console.log(msg.text())); page.on("pageerror", logConsoleError); @@ -296,24 +315,32 @@ test("HMR", async ({ page, browserName }) => { } }); - let portRange = makeRange(3080, 3099); - let appPort = await getPort({ port: portRange }); - let devPort = await getPort({ port: portRange }); - let projectDir = await createFixtureProject(fixture({ appPort, devPort })); + let appPort = await getPort(); + let devPort = await getPort(); + + let fixture: FixtureInit = { + config: { + dev: { + port: devPort, + }, + }, + files: options.files(appPort), + }; + + let projectDir = await createFixtureProject(fixture); + + let devProc = execa("npm", ["run", "dev"], { cwd: projectDir }); + let devStdout = bufferize(devProc.stdout!); + let devStderr = bufferize(devProc.stderr!); - // spin up dev server - let dev = execa("npm", ["run", "dev"], { cwd: projectDir }); - let devStdout = bufferize(dev.stdout!); - let devStderr = bufferize(dev.stderr!); try { await wait( () => { - if (dev.exitCode) throw Error("Dev server exited early"); - return /โ app ready: /.test(devStdout()); + if (devProc.exitCode) throw Error("Dev server exited early"); + return options.appReadyPattern.test(devStdout()); }, { timeoutMs: HMR_TIMEOUT_MS } ); - await page.goto(`http://localhost:${appPort}`, { waitUntil: "networkidle", }); @@ -590,7 +617,7 @@ whatsup // let expectedErrorCount = 0; let expectDestructureTypeError = expectConsoleError((error) => { let expectedMessage = new Set([ - // chrome, edge + // chrome, msedge "Cannot destructure property 'hello' of 'useLoaderData(...)' as it is null.", // firefox "(intermediate value)() is null", @@ -636,6 +663,105 @@ whatsup console.log("stderr end -------------------------"); throw e; } finally { - dev.kill(); + devProc.pid && (await killtree(devProc.pid)); } -}); +} + +let sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +let wait = async ( + callback: () => boolean, + { timeoutMs = 1000, intervalMs = 250 } = {} +) => { + let start = Date.now(); + while (Date.now() - start <= timeoutMs) { + if (callback()) { + return; + } + await sleep(intervalMs); + } + throw Error(`wait: timeout ${timeoutMs}ms`); +}; + +let bufferize = (stream: Readable): (() => string) => { + let buffer = ""; + stream.on("data", (data) => (buffer += data.toString())); + return () => buffer; +}; + +let logConsoleError = (error: Error) => { + console.error(`[console] ${error.name}: ${error.message}`); +}; + +let expectConsoleError = ( + isExpected: (error: Error) => boolean, + unexpected = logConsoleError +) => { + return (error: Error) => { + if (isExpected(error)) { + return; + } + unexpected(error); + }; +}; + +let deps = (packages: string[]): Record => { + return Object.fromEntries( + packages.map((pkg) => [pkg, "0.0.0-local-version"]) + ); +}; + +let isWindows = process.platform === "win32"; + +let 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); + }); +}; + +let isAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return false; + } +}; + +let 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/playwright.config.ts b/integration/playwright.config.ts index 8b30808720e..62c3e39cfd3 100644 --- a/integration/playwright.config.ts +++ b/integration/playwright.config.ts @@ -26,8 +26,13 @@ const config: PlaywrightTestConfig = { use: devices["Desktop Safari"], }, { - name: "edge", - use: devices["Desktop Edge"], + name: "msedge", + use: { + ...devices["Desktop Edge"], + // Desktop Edge uses chromium by default + // https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/deviceDescriptorsSource.json#L1502 + channel: "msedge", + }, }, { name: "firefox", diff --git a/package.json b/package.json index b895b422f02..aa73cead05d 100644 --- a/package.json +++ b/package.json @@ -97,8 +97,10 @@ "eslint": "^8.23.1", "eslint-plugin-markdown": "^2.2.1", "eslint-plugin-prefer-let": "^3.0.1", + "execa": "5.1.1", "express": "^4.17.1", "front-matter": "^4.0.2", + "get-port": "5.1.1", "glob": "8.0.3", "isbot": "^3.5.1", "jest": "^27.5.1", @@ -109,6 +111,7 @@ "mime": "^3.0.0", "npm-run-all": "^4.1.5", "patch-package": "^6.5.0", + "pidtree": "^0.6.0", "postcss-import": "^15.1.0", "prettier": "^2.7.1", "prompt-confirm": "^2.0.4", diff --git a/packages/remix-node/__tests__/sessions-test.ts b/packages/remix-node/__tests__/sessions-test.ts index b10b33bda93..5aa8c24f52a 100644 --- a/packages/remix-node/__tests__/sessions-test.ts +++ b/packages/remix-node/__tests__/sessions-test.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { promises as fsp } from "node:fs"; import os from "node:os"; -import { createFileSessionStorage } from "../sessions/fileStorage"; +import { createFileSessionStorage, getFile } from "../sessions/fileStorage"; function getCookieFromSetCookie(setCookie: string): string { return setCookie.split(/;\s*/)[0]; @@ -62,6 +62,39 @@ describe("File session storage", () => { await expect(destroySession(session)).resolves.not.toThrowError(); }); + it("saves expires to file if expires provided to commitSession when creating new cookie", async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let date = new Date(Date.now() + 1000 * 60); + let cookieHeader = await commitSession(session, { expires: date }); + let createdSession = await getSession(cookieHeader); + + let { id } = createdSession; + let fileContents = await fsp.readFile(getFile(dir, id), "utf8"); + let fileData = JSON.parse(fileContents); + expect(fileData.expires).toEqual(date.toISOString()); + }); + + it("saves expires to file if maxAge provided to commitSession when creating new cookie", async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let cookieHeader = await commitSession(session, { maxAge: 60 }); + let createdSession = await getSession(cookieHeader); + + let { id } = createdSession; + let fileContents = await fsp.readFile(getFile(dir, id), "utf8"); + let fileData = JSON.parse(fileContents); + expect(typeof fileData.expires).toBe("string"); + }); + 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 } = createFileSessionStorage({ diff --git a/packages/remix-node/sessions/fileStorage.ts b/packages/remix-node/sessions/fileStorage.ts index a7eedf3b8cb..042539710cf 100644 --- a/packages/remix-node/sessions/fileStorage.ts +++ b/packages/remix-node/sessions/fileStorage.ts @@ -103,7 +103,7 @@ export function createFileSessionStorage({ }); } -function getFile(dir: string, id: string): string { +export function getFile(dir: string, id: string): string { // Divide the session id up into a directory (first 2 bytes) and filename // (remaining 6 bytes) to reduce the chance of having very large directories, // which should speed up file access. This is a maximum of 2^16 directories, diff --git a/packages/remix-serve/cli.ts b/packages/remix-serve/cli.ts index 97dd18dd33a..f1ffe8b3791 100644 --- a/packages/remix-serve/cli.ts +++ b/packages/remix-serve/cli.ts @@ -1,13 +1,15 @@ import "@remix-run/node/install"; -import path from "node:path"; +import fs from "node:fs"; import os from "node:os"; +import path from "node:path"; import url from "node:url"; import { type ServerBuild, broadcastDevReady, installGlobals, } from "@remix-run/node"; -import { createRequestHandler } from "@remix-run/express"; +import { type RequestHandler, createRequestHandler } from "@remix-run/express"; +import chokidar from "chokidar"; import compression from "compression"; import express from "express"; import morgan from "morgan"; @@ -32,11 +34,43 @@ async function run() { process.exit(1); } - let buildPath = url.pathToFileURL( - path.resolve(process.cwd(), buildPathArg) - ).href; + let buildPath = path.resolve(buildPathArg); - let build: ServerBuild = await import(buildPath); + async function reimportServer() { + let stat = fs.statSync(buildPath); + + // use a timestamp query parameter to bust the import cache + return import(url.pathToFileURL(buildPath).href + "?t=" + stat.mtimeMs); + } + + function createDevRequestHandler(initialBuild: ServerBuild): RequestHandler { + let build = initialBuild; + async function handleServerUpdate() { + // 1. re-import the server build + build = await reimportServer(); + // 2. tell Remix that this app server is now up-to-date and ready + broadcastDevReady(build); + } + + chokidar + .watch(buildPath, { ignoreInitial: true }) + .on("add", handleServerUpdate) + .on("change", handleServerUpdate); + + // wrap request handler to make sure its recreated with the latest build for every request + return async (req, res, next) => { + try { + return createRequestHandler({ + build, + mode: "development", + })(req, res, next); + } catch (error) { + next(error); + } + }; + } + + let build: ServerBuild = await reimportServer(); let onListen = () => { let address = @@ -47,10 +81,10 @@ async function run() { ?.address; if (!address) { - console.log(`Remix App Server started at http://localhost:${port}`); + console.log(`[remix-serve] http://localhost:${port}`); } else { console.log( - `Remix App Server started at http://localhost:${port} (http://${address}:${port})` + `[remix-serve] http://localhost:${port} (http://${address}:${port})` ); } if (process.env.NODE_ENV === "development") { @@ -71,22 +105,15 @@ async function run() { app.use(express.static("public", { maxAge: "1h" })); app.use(morgan("tiny")); - let requestHandler: ReturnType | undefined; - app.all("*", async (req, res, next) => { - try { - if (!requestHandler) { - let build = await import(buildPath); - requestHandler = createRequestHandler({ + app.all( + "*", + process.env.NODE_ENV === "development" + ? createDevRequestHandler(build) + : createRequestHandler({ build, mode: process.env.NODE_ENV, - }); - } - - return await requestHandler(req, res, next); - } catch (error) { - next(error); - } - }); + }) + ); let server = process.env.HOST ? app.listen(port, process.env.HOST, onListen) diff --git a/packages/remix-serve/package.json b/packages/remix-serve/package.json index ac6d7552c31..06524c6b710 100644 --- a/packages/remix-serve/package.json +++ b/packages/remix-serve/package.json @@ -17,6 +17,7 @@ "dependencies": { "@remix-run/express": "1.19.3", "@remix-run/node": "1.19.3", + "chokidar": "^3.5.3", "compression": "^1.7.4", "express": "^4.17.1", "morgan": "^1.10.0", diff --git a/packages/remix-server-runtime/sessions.ts b/packages/remix-server-runtime/sessions.ts index bf809b65414..2d4a6c90806 100644 --- a/packages/remix-server-runtime/sessions.ts +++ b/packages/remix-server-runtime/sessions.ts @@ -277,11 +277,17 @@ export const createSessionStorageFactory = }, async commitSession(session, options) { let { id, data } = session; + let expires = + options?.maxAge != null + ? new Date(Date.now() + options.maxAge * 1000) + : options?.expires != null + ? options.expires + : cookie.expires; if (id) { - await updateData(id, data, cookie.expires); + await updateData(id, data, expires); } else { - id = await createData(data, cookie.expires); + id = await createData(data, expires); } return cookie.serialize(id, options); diff --git a/templates/remix-javascript/package.json b/templates/remix-javascript/package.json index 7143725f8e2..521a8e35ae7 100644 --- a/templates/remix-javascript/package.json +++ b/templates/remix-javascript/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "remix build", - "dev": "remix dev", + "dev": "remix dev --manual", "start": "remix-serve build" }, "dependencies": { diff --git a/templates/remix/package.json b/templates/remix/package.json index b79f377eec7..22c4b044d8d 100644 --- a/templates/remix/package.json +++ b/templates/remix/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "remix build", - "dev": "remix dev", + "dev": "remix dev --manual", "start": "remix-serve build", "typecheck": "tsc" }, diff --git a/yarn.lock b/yarn.lock index f161055e2d5..30e78f2d70c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6076,9 +6076,9 @@ get-package-type@^0.1.0: resolved "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== -get-port@^5.1.1: +get-port@5.1.1, get-port@^5.1.1: version "5.1.1" - resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz" + resolved "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193" integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ== get-stream@^5.0.0, get-stream@^5.1.0:
- setCount(count + 1)}>inc {count} -
- setCount(count - 1)}>dec {count} -