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