diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 8eaa85c1a3d..b2d4b0d66ac 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -4,12 +4,12 @@ on: schedule: - cron: "0 7 * * *" # every day at 12AM PST -# HEADS UP! this workflow will only ever run on the `main` branch due to it being a cron job, -# and the last commit on main will be what github shows as the trigger -# however in the checkout below we specify the `dev` branch, so all the scripts -# will be ran from that, confusing i know, so in some cases we'll need to create -# multiple PRs when modifying nightly release processes jobs: + # HEADS UP! this "nightly" job will only ever run on the `main` branch due to it being a cron job, + # and the last commit on main will be what github shows as the trigger + # however in the checkout below we specify the `dev` branch, so all the scripts + # in this job will be ran from that, confusing i know, so in some cases we'll need to create + # multiple PRs when modifying nightly release processes nightly: name: πŸŒ’ Nightly Release if: github.repository == 'remix-run/remix' @@ -99,4 +99,4 @@ jobs: with: token: ${{ secrets.NIGHTLY_PAT }} event-type: release - client-payload: '{ "ref": "refs/tags/v${{ needs.nightly.outputs.NEXT_VERSION }}" }' + client-payload: '{ "ref": "refs/tags/v${{ needs.nightly.outputs.NEXT_VERSION }}", "version": "${{ needs.nightly.outputs.NEXT_VERSION }}" }' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6d223faa99..d79cb61885b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,4 +54,4 @@ jobs: with: token: ${{ secrets.NIGHTLY_PAT }} event-type: release - client-payload: '{ "ref": "${{ github.ref }}" }' + client-payload: '{ "ref": "${{ github.ref }}", "version": "${{ github.ref_name }}" }' diff --git a/.github/workflows/stacks.yml b/.github/workflows/stacks.yml new file mode 100644 index 00000000000..d0f5c1fae61 --- /dev/null +++ b/.github/workflows/stacks.yml @@ -0,0 +1,248 @@ +name: πŸ₯ž Remix Stacks Test + +on: + repository_dispatch: + types: [release] + +jobs: + setup: + name: Remix Stacks Test + if: github.repository == 'remix-run/remix' + runs-on: ubuntu-latest + strategy: + matrix: + stack: + - repo: "remix-run/indie-stack" + name: "indie" + - repo: "remix-run/blues-stack" + name: "blues" + - repo: "remix-run/grunge-stack" + name: "grunge" + steps: + - name: πŸ›‘ Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: βŽ” Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: βš’οΈ Create new ${{ matrix.stack.name }} app with ${{ github.event.client_payload.version }} + run: | + npx -y create-remix@${{ github.event.client_payload.version }} ${{ matrix.stack.name }} --template ${{ matrix.stack.repo }} --typescript --no-install + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + with: + working-directory: ${{ matrix.stack.name }} + useLockFile: false + + - name: Run `remix init` + run: | + cd ${{ matrix.stack.name }} + npx remix init + + - name: πŸ„ Copy test env vars + run: | + cd ${{ matrix.stack.name }} + cp .env.example .env + + - name: πŸ“ Zip artifact + run: zip ${{ matrix.stack.name }}.zip ./${{ matrix.stack.name }} -r -x "**/node_modules/*" + + - name: πŸ—„οΈ Archive ${{ matrix.stack.name }} + uses: actions/upload-artifact@v3 + with: + name: ${{ matrix.stack.name }}-archive + path: ${{ matrix.stack.name }}.zip + + lint: + name: ⬣ ESLint + if: github.repository == 'remix-run/remix' + needs: [setup] + runs-on: ubuntu-latest + strategy: + matrix: + stack: + - repo: "remix-run/indie-stack" + name: "indie" + - repo: "remix-run/blues-stack" + name: "blues" + - repo: "remix-run/grunge-stack" + name: "grunge" + steps: + - name: πŸ›‘ Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: βŽ” Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: πŸ—„οΈ Restore ${{ matrix.stack.name }} + uses: actions/download-artifact@v3 + with: + name: ${{ matrix.stack.name }}-archive + + - name: πŸ“ Unzip artifact + run: unzip ${{ matrix.stack.name }}.zip + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + with: + working-directory: ${{ matrix.stack.name }} + + - name: πŸ”¬ Lint + run: | + cd ${{ matrix.stack.name }} + npm run lint + + typecheck: + name: Κ¦ TypeScript + needs: [setup] + if: github.repository == 'remix-run/remix' + runs-on: ubuntu-latest + strategy: + matrix: + stack: + - repo: "remix-run/indie-stack" + name: "indie" + - repo: "remix-run/blues-stack" + name: "blues" + - repo: "remix-run/grunge-stack" + name: "grunge" + steps: + - name: πŸ›‘ Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: βŽ” Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: πŸ—„οΈ Restore ${{ matrix.stack.name }} + uses: actions/download-artifact@v3 + with: + name: ${{ matrix.stack.name }}-archive + + - name: πŸ“ Unzip artifact + run: unzip ${{ matrix.stack.name }}.zip + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + with: + working-directory: ${{ matrix.stack.name }} + + - name: πŸ”Ž Type check + run: | + cd ${{ matrix.stack.name }} + npm run typecheck --if-present + + vitest: + name: ⚑ Vitest + if: github.repository == 'remix-run/remix' + needs: [setup] + runs-on: ubuntu-latest + strategy: + matrix: + stack: + - repo: "remix-run/indie-stack" + name: "indie" + - repo: "remix-run/blues-stack" + name: "blues" + - repo: "remix-run/grunge-stack" + name: "grunge" + steps: + - name: πŸ›‘ Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: βŽ” Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: πŸ—„οΈ Restore ${{ matrix.stack.name }} + uses: actions/download-artifact@v3 + with: + name: ${{ matrix.stack.name }}-archive + + - name: πŸ“ Unzip artifact + run: unzip ${{ matrix.stack.name }}.zip + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + with: + working-directory: ${{ matrix.stack.name }} + + - name: ⚑ Run vitest + run: | + cd ${{ matrix.stack.name }} + npm run test -- --coverage + + cypress: + name: ⚫️ Cypress + if: github.repository == 'remix-run/remix' + needs: [setup] + runs-on: ubuntu-latest + strategy: + matrix: + stack: + - repo: "remix-run/indie-stack" + name: "indie" + cypress: "npm run start:mocks" + - repo: "remix-run/blues-stack" + name: "blues" + cypress: "npm run start:mocks" + - repo: "remix-run/grunge-stack" + name: "grunge" + cypress: "npm run dev" + steps: + - name: πŸ›‘ Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: βŽ” Setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: πŸ—„οΈ Restore ${{ matrix.stack.name }} + uses: actions/download-artifact@v3 + with: + name: ${{ matrix.stack.name }}-archive + + - name: πŸ“ Unzip artifact + run: unzip ${{ matrix.stack.name }}.zip + + - name: πŸ“₯ Download deps + uses: bahmutov/npm-install@v1 + with: + working-directory: ${{ matrix.stack.name }} + + - name: 🐳 Docker compose + if: ${{ matrix.stack.name == 'blues' }} + # the sleep is just there to give time for postgres to get started + run: | + cd ${{ matrix.stack.name }} + docker-compose up -d && sleep 3 + env: + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/postgres" + + - name: πŸ›  Setup Database + if: ${{ matrix.stack.name != 'grunge' }} + run: | + cd ${{ matrix.stack.name }} + npx prisma migrate reset --force + + - name: βš™οΈ Build + run: | + cd ${{ matrix.stack.name }} + npm run build + + - name: 🌳 Cypress run + uses: cypress-io/github-action@v3 + with: + start: ${{ matrix.stack.cypress }} + wait-on: "http://localhost:8811" + working-directory: ${{ matrix.stack.name }} + env: + PORT: "8811" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 55f4862f081..682f6dfe753 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,10 @@ on: - "docs/**" - "scripts/**" - "**/README.md" - pull_request: {} + pull_request: + paths-ignore: + - "docs/**" + - "**/*.md" jobs: build: diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 083cc59eed9..633359fa823 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -44,9 +44,13 @@ git checkout dev # create a prerelease tag. yarn release start patch|minor|major -# Once you create the pre-release, you can run tests and even publish a pre-release -# directly to ensure everything works as expected. If there are any issues, fix the bugs and commit directly to the pre-release branch. Once you're done working, you -# can iterate with a new pre-release with the following command: +# At this point you can push to GitHub... +git push origin/release- --follow-tags +# ...then publish the pre-release by creating a release in the GitHub UI. Don't +# forget to check the pre-release checkbox! + +# If there are any issues with the pre-release, fix the bugs and commit directly +# to the release branch. You can iterate with a new pre-release with the following # command, then publish via GitHub the same as before. yarn release bump # Once all tests have passed and the release is ready to be made stable, the following @@ -54,10 +58,12 @@ yarn release bump # and prompt you to push the changes and tags to GitHub yarn release finish git push origin/release- --follow-tags - -# Now you can create the release from GitHub from the new tag and write release notes! ``` +Once the release is finished, you should see tests run in GitHub actions. Assuming there are no issues (you should also run tests locally before pushing) you can trigger publishing by creating a new release in the GitHub UI, this time using the stable release tag. + +After the release process is complete, be sure to merge the release branch back into `dev` and `main` and push both branches to GitHub. + ### `create-remix` All packages are published together except for `create-remix`, which is diff --git a/contributors.yml b/contributors.yml index 59d68d42a14..a00b6884246 100644 --- a/contributors.yml +++ b/contributors.yml @@ -3,6 +3,7 @@ - abereghici - abotsi - accidentaldeveloper +- achinchen - adicuco - ahbruns - ahmedeldessouki @@ -12,6 +13,7 @@ - Alarid - alex-ketch - alexuxui +- alireza-bonab - alvinthen - amorriscode - andrelandgraf @@ -28,6 +30,7 @@ - arganaphangquestian - AriGunawan - arvigeus +- arvindell - ascorbic - ashleyryan - ashocean @@ -35,6 +38,7 @@ - axel-habermaier - BasixKOR - BenMcH +- bmarvinb - bmontalvo - bogas04 - BogdanDevBst @@ -50,10 +54,13 @@ - ccssmnn - chaance - chenc041 +- chenxsan +- chiangs - christianhg - christophgockel - clarkmitchell - cliffordfajardo +- cloudy9101 - codymjarrett - confix - coryhouse @@ -72,6 +79,8 @@ - dhargitai - dhmacs - dima-takoy +- DNLHC +- dogukanakkaya - dokeet - donavon - Dueen @@ -95,6 +104,7 @@ - fgiuliani - fishel-feng - francisudeji +- frontsideair - fx109138 - gabimor - gautamkrishnar @@ -135,6 +145,8 @@ - JacobParis - jakewtaylor - jamiebuilds +- janhoogeveen +- Jannis-Morgenstern - jaydiablo - jca41 - jdeniau @@ -156,6 +168,7 @@ - jssisodiya - jstafman - juhanakristian +- JulesBlm - justinnoel - juwiragiye - jveldridge @@ -164,6 +177,7 @@ - kanermichael - karimsan - kauffmanes +- kbariotis - KenanYusuf - kentcdodds - kevinrambaud @@ -173,6 +187,7 @@ - kimdontdoit - klauspaiva - knowler +- konradkalemba - kubaprzetakiewicz - kuldar - kumard3 @@ -224,6 +239,7 @@ - mehulmpt - memark - mennopruijssers +- michaeldebetaz - michaeldeboey - michaelfriedman - michaseel @@ -241,6 +257,7 @@ - na2hiro - nareshbhatia - navid-kalaei +- nexxeln - nicholaschiang - niconiahi - nielsdb97 @@ -325,6 +342,7 @@ - VictorPeralta - vimutti77 - visormatt +- vkrol - weavdale - wKovacs64 - wladiston @@ -333,6 +351,7 @@ - yauri-io - yesmeck - yomeshgupta +- youbicode - youngvform - zachdtaylor - zainfathoni diff --git a/docs/api/conventions.md b/docs/api/conventions.md index f2ade008d3e..e5ac896216a 100644 --- a/docs/api/conventions.md +++ b/docs/api/conventions.md @@ -263,7 +263,7 @@ import { useParams } from "@remix-run/react"; import type { LoaderFunction, ActionFunction, -} from "@remix-run/{runtime}"; +} from "@remix-run/node"; // or "@remix-run/cloudflare" export const loader: LoaderFunction = async ({ params, @@ -356,7 +356,7 @@ For example, all of your marketing pages could be in `app/routes/__marketing/*` Be careful, pathless layout routes introduce the possibility of URL conflicts -#### Dot Delimeters +#### Dot Delimiters ```markdown [8] @@ -426,7 +426,7 @@ import { useParams } from "@remix-run/react"; import type { LoaderFunction, ActionFunction, -} from "@remix-run/{runtime}"; +} from "@remix-run/node"; // or "@remix-run/cloudflare" export const loader: LoaderFunction = async ({ params, @@ -488,7 +488,7 @@ import { renderToString } from "react-dom/server"; import type { EntryContext, HandleDataRequestFunction, -} from "@remix-run/{runtime}"; +} from "@remix-run/node"; // or "@remix-run/cloudflare" import { RemixServer } from "@remix-run/react"; export default function handleRequest( @@ -549,7 +549,7 @@ export default function SomeRouteComponent() { Each route can define a "loader" function that will be called on the server before rendering to provide data to the route. You may think of this as a "GET" request handler in that you should not be reading the body of the request; that is the job of an [`action`](#action). ```js -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" export const loader = async () => { // The `json` function converts a serializable object into a JSON response @@ -560,8 +560,8 @@ export const loader = async () => { ```ts // Typescript -import { json } from "@remix-run/{runtime}"; -import type { LoaderFunction } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" +import type { LoaderFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" export const loader: LoaderFunction = async () => { return json({ ok: true }); @@ -573,7 +573,7 @@ This function is only ever run on the server. On the initial server render it wi Using the database ORM Prisma as an example: ```tsx lines=[1-2,6-8,11] -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData } from "@remix-run/react"; import { prisma } from "../db"; @@ -684,7 +684,7 @@ export const loader: LoaderFunction = async () => { Using the `json` helper simplifies this so you don't have to construct them yourself, but these two examples are effectively the same! ```tsx -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" export const loader: LoaderFunction = async () => { const users = await fakeDb.users.findMany(); @@ -695,7 +695,7 @@ export const loader: LoaderFunction = async () => { You can see how `json` just does a little of the work to make your loader a lot cleaner. You can also use the `json` helper to add headers or a status code to your response: ```tsx -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" export const loader: LoaderFunction = async ({ params, @@ -724,7 +724,7 @@ Along with returning responses, you can also throw Response objects from your lo Here is a full example showing how you can create utility functions that throw responses to stop code execution in the loader and move over to an alternative UI. ```ts filename=app/db.ts -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import type { ThrownResponse } from "@remix-run/react"; export type InvoiceNotFoundResponse = ThrownResponse< @@ -742,7 +742,7 @@ export function getInvoice(id, user) { ``` ```ts filename=app/http.ts -import { redirect } from "@remix-run/{runtime}"; +import { redirect } from "@remix-run/node"; // or "@remix-run/cloudflare" import { getSession } from "./session"; @@ -838,7 +838,7 @@ Actions have the same API as loaders, the only difference is when they are calle This enables you to co-locate everything about a data set in a single route module: the data read, the component that renders the data, and the data writes: ```tsx -import { json, redirect } from "@remix-run/{runtime}"; +import { json, redirect } from "@remix-run/node"; // or "@remix-run/cloudflare" import { Form } from "@remix-run/react"; import { fakeGetTodos, fakeCreateTodo } from "~/utils/db"; @@ -894,7 +894,11 @@ See also: Each route can define its own HTTP headers. One of the common headers is the `Cache-Control` header that indicates to browser and CDN caches where and for how long a page is able to be cached. ```tsx -export function headers({ loaderHeaders, parentHeaders }) { +export function headers({ + actionHeaders, + loaderHeaders, + parentHeaders, +}) { return { "X-Stretchy-Pants": "its for fun", "Cache-Control": "max-age=300, s-maxage=3600", @@ -902,7 +906,7 @@ export function headers({ loaderHeaders, parentHeaders }) { } ``` -Usually your data is a better indicator of your cache duration than your route module (data tends to be more dynamic than markup), so the loader's headers are passed in to `headers()` too: +Usually your data is a better indicator of your cache duration than your route module (data tends to be more dynamic than markup), so the `action`'s & `loader`'s headers are passed in to `headers()` too: ```tsx export function headers({ loaderHeaders }) { @@ -912,7 +916,7 @@ export function headers({ loaderHeaders }) { } ``` -Note: `loaderHeaders` is an instance of the [Web Fetch API][headers] `Headers` class. +Note: `actionHeaders` & `loaderHeaders` are an instance of the [Web Fetch API][headers] `Headers` class. Because Remix has nested routes, there's a battle of the headers to be won when nested routes match. In this case, the deepest route wins. Consider these files in the routes directory: @@ -971,7 +975,7 @@ Note that you can also add headers in your `entry.server` file for things that s ```tsx lines=[16] import { renderToString } from "react-dom/server"; import { RemixServer } from "@remix-run/react"; -import type { EntryContext } from "@remix-run/{runtime}"; +import type { EntryContext } from "@remix-run/node"; // or "@remix-run/cloudflare" export default function handleRequest( request: Request, @@ -1000,7 +1004,7 @@ Just keep in mind that doing this will apply to _all_ document requests, but doe The meta export will set meta tags for your html document. We highly recommend setting the title and description on every route besides layout routes (their index route will set the meta). ```tsx -import type { MetaFunction } from "@remix-run/{runtime}"; +import type { MetaFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" export const meta: MetaFunction = () => { return { @@ -1025,12 +1029,12 @@ The `meta` object can also hold a `title` reference which maps to the [HTML ``. -As a last option, you can also pass an object of attribute/value pairs as the value. This can be used as an escape-hetch for meta tags like the [`http-equiv` tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#attr-http-equiv) which uses `http-equiv` instead of `name`. +As a last option, you can also pass an object of attribute/value pairs as the value. This can be used as an escape-hatch for meta tags like the [`http-equiv` tag](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#attr-http-equiv) which uses `http-equiv` instead of `name`. Examples: ```tsx -import type { MetaFunction } from "@remix-run/{runtime}"; +import type { MetaFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" export const meta: MetaFunction = () => ({ // Special cases @@ -1038,7 +1042,7 @@ export const meta: MetaFunction = () => ({ "og:image": "https://josiesshakeshack.com/logo.jpg", // title: "Josie's Shake Shack", // Josie's Shake Shack - // content => name + // name => content description: "Delicious shakes", // viewport: "width=device-width,initial-scale=1", // @@ -1081,7 +1085,7 @@ export const meta: MetaFunction = ({ data, params }) => { The links function defines which `` elements to add to the page when the user visits a route. ```tsx -import type { LinksFunction } from "@remix-run/{runtime}"; +import type { LinksFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" export const links: LinksFunction = () => { return [ @@ -1115,7 +1119,7 @@ The `links` export from a route should return an array of `HtmlLinkDescriptor` o Examples: ```tsx -import type { LinksFunction } from "@remix-run/{runtime}"; +import type { LinksFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" import stylesHref from "../styles/something.css"; @@ -1392,7 +1396,7 @@ Any files inside the `app` folder can be imported into your modules. Remix will: It's most common for stylesheets, but can used for anything. ```tsx filename=app/routes/root.tsx -import type { LinksFunction } from "@remix-run/{runtime}"; +import type { LinksFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" import styles from "./styles/app.css"; import banner from "./images/banner.jpg"; diff --git a/docs/api/remix.md b/docs/api/remix.md index cb4f4086e3e..87ac3e38b62 100644 --- a/docs/api/remix.md +++ b/docs/api/remix.md @@ -33,7 +33,7 @@ These components are to be used once inside of your root route (`root.tsx`). The import type { LinksFunction, MetaFunction, -} from "@remix-run/{runtime}"; +} from "@remix-run/node"; // or "@remix-run/cloudflare" import { Links, LiveReload, @@ -331,7 +331,7 @@ In order to avoid (usually) the client-side routing "scroll flash" on refresh or This hook returns the JSON parsed data from your route loader function. ```tsx lines=[2,9] -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData } from "@remix-run/react"; export async function loader() { @@ -349,7 +349,7 @@ export default function Invoices() { This hook returns the JSON parsed data from your route action. It returns `undefined` if there hasn't been a submission at the current location yet. ```tsx lines=[2,11,20] -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useActionData, Form } from "@remix-run/react"; export async function action({ request }) { @@ -377,7 +377,7 @@ export default function Invoices() { The most common use-case for this hook is form validation errors. If the form isn't right, you can simply return the errors and let the user try again (instead of pushing all the errors into sessions and back out of the loader). ```tsx lines=[22, 31, 39-41, 45-47] -import { redirect, json } from "@remix-run/{runtime}"; +import { redirect, json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { Form, useActionData } from "@remix-run/react"; export async function action({ request }) { @@ -522,7 +522,7 @@ Returns the function that may be used to submit a `
` (or some raw `FormDat This is useful whenever you need to programmatically submit a form. For example, you may wish to save a user preferences form whenever any field changes. ```tsx filename=app/routes/prefs.tsx lines=[2,14,18] -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useSubmit, useTransition } from "@remix-run/react"; export async function loader() { @@ -794,8 +794,6 @@ You can know the state of the fetcher with `fetcher.state`. It will be one of: - **submitting** - A form has been submitted. If the method is GET, then the route loader is being called. If POST, PUT, PATCH, or DELETE, then the route action is being called. - **loading** - The loaders for the routes are being reloaded after an action submission -. - #### `fetcher.type` This is the type of state the fetcher is in. It's like `fetcher.state`, but more granular. Depending on the fetcher's state, the types can be the following: @@ -1433,8 +1431,8 @@ function SomeForm() { This is a shortcut for creating `application/json` responses. It assumes you are using `utf-8` encoding. ```ts lines=[2,6] -import type { LoaderFunction } from "@remix-run/{runtime}"; -import { json } from "@remix-run/{runtime}"; +import type { LoaderFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" export const loader: LoaderFunction = async () => { // So you can write this: @@ -1470,8 +1468,8 @@ export const loader: LoaderFunction = async () => { This is shortcut for sending 30x responses. ```ts lines=[2,8] -import type { ActionFunction } from "@remix-run/{runtime}"; -import { redirect } from "@remix-run/{runtime}"; +import type { ActionFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" +import { redirect } from "@remix-run/node"; // or "@remix-run/cloudflare" export const action: ActionFunction = async () => { const userSession = await getUserSessionOrWhatever(); @@ -1662,7 +1660,7 @@ import { unstable_composeUploadHandlers, unstable_createMemoryUploadHandler, } from "@remix-run/{runtime}"; -// writeAsyncIterableToWritable is a node only utility +// writeAsyncIterableToWritable is a Node-only utility import { writeAsyncIterableToWritable } from "@remix-run/node"; import type { UploadApiOptions, @@ -1748,8 +1746,8 @@ Your job is to do whatever you need with the `stream` and return a value that's We have the built-in `unstable_createFileUploadHandler` and `unstable_createMemoryUploadHandler` and we also expect more upload handler utilities to be developed in the future. If you have a form that needs to use different upload handlers, you can compose them together with a custom handler, here's a theoretical example: ```tsx filename=file-upload-handler.server.tsx -import type { UploadHandler } from "@remix-run/{runtime}"; -import { unstable_createFileUploadHandler } from "@remix-run/{runtime}"; +import type { UploadHandler } from "@remix-run/node"; // or "@remix-run/cloudflare" +import { unstable_createFileUploadHandler } from "@remix-run/node"; // or "@remix-run/cloudflare" import { createCloudinaryUploadHandler } from "some-handy-remix-util"; export const standardFileUploadHandler = @@ -1790,7 +1788,7 @@ Let's say you have a banner on your e-commerce site that prompts users to check First, create a cookie: ```js filename=app/cookies.js -import { createCookie } from "@remix-run/{runtime}"; +import { createCookie } from "@remix-run/node"; // or "@remix-run/cloudflare" export const userPrefs = createCookie("user-prefs", { maxAge: 604_800, // one week @@ -1802,7 +1800,7 @@ Then, you can `import` the cookie and use it in your `loader` and/or `action`. T **Note:** We recommend (for now) that you create all the cookies your app needs in `app/cookies.js` and `import` them into your route modules. This allows the Remix compiler to correctly prune these imports out of the browser build where they are not needed. We hope to eventually remove this caveat. ```tsx filename=app/routes/index.tsx lines=[4,8-9,15-16,20] -import { json, redirect } from "@remix-run/{runtime}"; +import { json, redirect } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData } from "@remix-run/react"; import { userPrefs } from "~/cookies"; @@ -1922,7 +1920,7 @@ export async function loader({ request }) { Creates a logical container for managing a browser cookie from the server. ```ts -import { createCookie } from "@remix-run/{runtime}"; +import { createCookie } from "@remix-run/node"; // or "@remix-run/cloudflare" const cookie = createCookie("cookie-name", { // all of these are optional defaults that can be overridden at runtime @@ -1944,7 +1942,7 @@ To learn more about each attribute, please see the [MDN Set-Cookie docs](https:/ Returns `true` if an object is a Remix cookie container. ```ts -import { isCookie } from "@remix-run/{runtime}"; +import { isCookie } from "@remix-run/node"; // or "@remix-run/cloudflare" const cookie = createCookie("user-prefs"); console.log(isCookie(cookie)); // true @@ -2036,7 +2034,7 @@ This is an example of a cookie session storage: ```js filename=app/sessions.js // app/sessions.js -import { createCookieSessionStorage } from "@remix-run/{runtime}"; +import { createCookieSessionStorage } from "@remix-run/node"; // or "@remix-run/cloudflare" const { getSession, commitSession, destroySession } = createCookieSessionStorage({ @@ -2068,7 +2066,7 @@ You'll use methods to get access to sessions in your `loader` and `action` funct A login form might look something like this: ```tsx filename=app/routes/login.js lines=[4,7-9,11,16,20,26-28,39,44,49,54] -import { json, redirect } from "@remix-run/{runtime}"; +import { json, redirect } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData } from "@remix-run/react"; import { getSession, commitSession } from "../sessions"; @@ -2195,7 +2193,7 @@ TODO: Returns `true` if an object is a Remix session. ```js -import { isSession } from "@remix-run/{runtime}"; +import { isSession } from "@remix-run/node"; // or "@remix-run/cloudflare" const sessionData = { foo: "bar" }; const session = createSession(sessionData, "remix-session"); @@ -2210,7 +2208,7 @@ Remix makes it easy to store sessions in your own database if needed. The `creat The following example shows how you could do this using a generic database client: ```js -import { createSessionStorage } from "@remix-run/{runtime}"; +import { createSessionStorage } from "@remix-run/node"; // or "@remix-run/cloudflare" function createDatabaseSessionStorage({ cookie, @@ -2267,7 +2265,7 @@ The main advantage of cookie session storage is that you don't need any addition The downside is that you have to `commitSession` in almost every loader and action. If your loader or action changes the session at all, it must be committed. That means if you `session.flash` in an action, and then `session.get` in another, you must commit it for that flashed message to go away. With other session storage strategies you only have to commit it when it's created (the browser cookie doesn't need to change because it doesn't store the session data, just the key to find it elsewhere). ```js -import { createCookieSessionStorage } from "@remix-run/{runtime}"; +import { createCookieSessionStorage } from "@remix-run/node"; // or "@remix-run/cloudflare" const { getSession, commitSession, destroySession } = createCookieSessionStorage({ @@ -2291,7 +2289,7 @@ This storage keeps all the cookie information in your server's memory. import { createCookie, createMemorySessionStorage, -} from "@remix-run/{runtime}"; +} from "@remix-run/node"; // or "@remix-run/cloudflare" // In this example the Cookie is created separately. const sessionCookie = createCookie("__session", { @@ -2473,7 +2471,7 @@ Now we can read the message in a loader. You must commit the session whenever you read a `flash`. This is different than you might be used to where some type of middleware automatically sets the cookie header for you. ```jsx -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { Meta, Links, @@ -2560,7 +2558,7 @@ This component is a wrapper around React Router's Outlet with the ability to pas Here's a practical example of when you may want to use this feature. Let's say you've got a list of companies that have invoices and you want to display those companies in an accordion. We'll render our outlet in that accordion, but we want the invoice sorting to be controlled by the parent (so changing companies preserves the invoice sorting). This is a perfect use case for ``. ```tsx filename=app/routes/companies.tsx lines=[5,28-31,36-44,53-57,68] -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData, useParams, @@ -2645,8 +2643,8 @@ This hook returns the context from the `` that rendered you. Continuing from the `` example above, here's what the child route could do to use the sort order. ```tsx filename=app/routes/companies/$companyId.tsx lines=[5,8,25,27-30] -import type { LoaderFunction } from "@remix-run/{runtime}"; -import { json } from "@remix-run/{runtime}"; +import type { LoaderFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData, useOutletContext, diff --git a/docs/guides/api-routes.md b/docs/guides/api-routes.md index de9219065ff..4d5d8f94bf6 100644 --- a/docs/guides/api-routes.md +++ b/docs/guides/api-routes.md @@ -28,7 +28,7 @@ Whenever the user clicks a link to ``, Remix in the browser There are times, however, that you want to get the data from a loader but not because the user is visiting the route, but the current page needs that route's data for some reason. A very clear example is a `` component that queries the database for records and suggests them to the user. -You can `useFetcher` for cases like this. And once again, since Remix in the browser knows about Remix on the server, you don't have to do much to get the data. Remix's error handling kicks in, and race conditions, interruptions, and fetch cancelations are handled for you, too. +You can `useFetcher` for cases like this. And once again, since Remix in the browser knows about Remix on the server, you don't have to do much to get the data. Remix's error handling kicks in, and race conditions, interruptions, and fetch cancellations are handled for you, too. For example, you could have a route to handle the search: diff --git a/docs/guides/constraints.md b/docs/guides/constraints.md index 13a5cf057f2..a9ab4eef531 100644 --- a/docs/guides/constraints.md +++ b/docs/guides/constraints.md @@ -19,7 +19,7 @@ The Remix compiler will automatically remove server code from the browser bundle Consider a route module that exports `loader`, `meta`, and a component: ```tsx -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData } from "@remix-run/react"; import PostsView from "../PostsView"; @@ -77,7 +77,7 @@ Simply put, a **side effect** is any code that might _do something_. A **module Taking our code from earlier, we saw how the compiler can remove the exports and their imports that aren't used. But if we add this seemingly harmless line of code your app will break! ```tsx bad lines=[7] -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData } from "@remix-run/react"; import PostsView from "../PostsView"; @@ -124,7 +124,7 @@ The loader is gone but the prisma dependency stayed! Had we logged something har To fix this, remove the side effect by simply moving the code _into the loader_. ```tsx lines=[8] -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData } from "@remix-run/react"; import PostsView from "../PostsView"; @@ -154,7 +154,7 @@ Occasionally, the build may have trouble tree-shaking code that should only run Some Remix newcomers try to abstract their loaders with "higher order functions". Something like this: ```js bad filename=app/http.js -import { redirect } from "@remix-run/{runtime}"; +import { redirect } from "@remix-run/node"; // or "@remix-run/cloudflare" export function removeTrailingSlash(loader) { return function (arg) { @@ -188,7 +188,7 @@ You can probably now see that this is a module side effect so the compiler can't This type of abstraction is introduced to try to return a response early. Since you can throw a Response in a loader, we can make this simpler and remove the module side effect at the same time so that the server code can be pruned: ```js filename=app/http.js -import { redirect } from "@remix-run/{runtime}"; +import { redirect } from "@remix-run/node"; // or "@remix-run/cloudflare" export function removeTrailingSlash(url) { if (url.pathname !== "/" && url.pathname.endsWith("/")) { @@ -202,7 +202,7 @@ export function removeTrailingSlash(url) { And then use it like this: ```js bad filename=app/root.js -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { removeTrailingSlash } from "~/http"; @@ -263,7 +263,7 @@ The most common scenario is initializing a third party API when your module is i #### Document Guard -This ensures the library is only initialized if there is a `document`, meaning you're in the browser. We recomend `document` over `window` because server runtimes like Deno has a global `window` available. +This ensures the library is only initialized if there is a `document`, meaning you're in the browser. We recommend `document` over `window` because server runtimes like Deno has a global `window` available. ```js [3] import firebase from "firebase/app"; diff --git a/docs/guides/data-loading.md b/docs/guides/data-loading.md index bde8289083e..d3a650c5d41 100644 --- a/docs/guides/data-loading.md +++ b/docs/guides/data-loading.md @@ -22,8 +22,8 @@ One of the primary features of Remix is simplifying interactions with the server Each [route module][route-module] can export a component and a [`loader`][loader]. [`useLoaderData`][useloaderdata] will provide the loader's data to your component: ```tsx filename=app/routes/products.tsx lines=[1-3,5-10,13] -import type { LoaderFunction } from "@remix-run/{runtime}"; -import { json } from "@remix-run/{runtime}"; +import type { LoaderFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData } from "@remix-run/react"; export const loader: LoaderFunction = async () => { @@ -55,7 +55,7 @@ If your server side modules end up in client bundles, move the imports for those When you name a file with `$` like `routes/users/$userId.tsx` and `routes/users/$userId/projects/$projectId.tsx` the dynamic segments (the ones starting with `$`) will be parsed from the URL and passed to your loader on a `params` object. ```tsx filename=routes/users/$userId/projects/$projectId.tsx -import type { LoaderFunction } from "@remix-run/{runtime}"; +import type { LoaderFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" export const loader: LoaderFunction = async ({ params, @@ -75,8 +75,8 @@ Given the following URLs, the params would be parsed as follows: These params are most useful for looking up data: ```tsx filename=routes/users/$userId/projects/$projectId.tsx lines=[8,9] -import { json } from "@remix-run/{runtime}"; -import type { LoaderFunction } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" +import type { LoaderFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" export const loader: LoaderFunction = async ({ params, @@ -98,7 +98,7 @@ Because these params come from the URL and not your source code, you can't know ```tsx filename=routes/users/$userId/projects/$projectId.tsx lines=[1,7-8] import invariant from "tiny-invariant"; -import type { LoaderFunction } from "@remix-run/{runtime}"; +import type { LoaderFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" export const loader: LoaderFunction = async ({ params, @@ -117,7 +117,7 @@ While you may be uncomfortable throwing errors like this with `invariant` when i Remix polyfills the `fetch` API on your server so it's very easy to fetch data from existing JSON APIs. Instead of managing state, errors, race conditions, and more yourself, you can do the fetch from your loader (on the server) and let Remix handle the rest. ```tsx filename=app/routes/gists.jsx lines=[5] -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData } from "@remix-run/react"; export async function loader() { @@ -154,8 +154,8 @@ export { db }; And then your routes can import it and make queries against it: ```tsx filename=app/routes/products/$categoryId.tsx -import type { LoaderFunction } from "@remix-run/{runtime}"; -import { json } from "@remix-run/{runtime}"; +import type { LoaderFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData } from "@remix-run/react"; import { db } from "~/db.server"; @@ -183,29 +183,38 @@ export default function ProductCategory() { } ``` -If you are using TypeScript, you can use type inference to use Prisma Client generated types on when calling `useLoaderData`. This allowes better type safety and intellisense when writing your code that uses the loaded data. +If you are using TypeScript, you can use type inference to use Prisma Client generated types on when calling `useLoaderData`. This allows better type safety and intellisense when writing your code that uses the loaded data. ```tsx filename=tsx filename=app/routes/products/$productId.tsx -import { json } from "@remix-run/{runtime}"; +import type { LoaderFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData } from "@remix-run/react"; import { db } from "~/db.server"; type LoaderData = Awaited>; -async function getLoaderData() { - const products = await db.product.findMany({ +async function getLoaderData(productId: string) { + const product = await db.product.findUnique({ + where: { + id: productId, + }, select: { id: true, name: true, imgSrc: true, }, }); - return { products }; + + return product; } -export const loader = async () => { - return json(await getLoaderData()); +export const loader: LoaderFunction = async ({ + params, +}) => { + return json( + await getLoaderData(params.productId) + ); }; export default function Product() { @@ -224,8 +233,8 @@ export default function Product() { If you picked Cloudflare Workers as your environment, [Cloudflare Key Value][cloudflare-kv] storage allows you to persist data at the edge as if it were a static resource. You'll need to [do some configuration][cloudflare-kv-setup] but then you can access the data from your loaders: ```tsx filename=app/routes/products/$productId.tsx -import type { LoaderFunction } from "@remix-run/{runtime}"; -import { json } from "@remix-run/{runtime}"; +import type { LoaderFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useLoaderData } from "@remix-run/react"; export const loader: LoaderFunction = async ({ @@ -282,8 +291,8 @@ export const loader: LoaderFunction = async ({ URL Search Params are the portion of the URL after a `?`. Other names for this are "query string", "search string", or "location search". You can access the values by creating a URL out of the `request.url`: ```tsx filename=routes/products.tsx lines=[7,8] -import { json } from "@remix-run/{runtime}"; -import type { LoaderFunction } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" +import type { LoaderFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" export const loader: LoaderFunction = async ({ request, @@ -363,7 +372,7 @@ Then the url will be: `/products/shoes?brand=nike&brand=adidas` Note that `brand` is repeated in the URL search string since both checkboxes were named `"brand"`. In your loader you can get access to all of those values with [`searchParams.getAll`][search-params-getall] ```tsx lines=[5] -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" export async function loader({ request }) { const url = new URL(request.url); diff --git a/docs/guides/data-writes.md b/docs/guides/data-writes.md index 62c2a2b7980..637039d4a99 100644 --- a/docs/guides/data-writes.md +++ b/docs/guides/data-writes.md @@ -29,11 +29,11 @@ Since Remix `` works identically to `` (with a couple extra goodies ### HTML Form HTTP Verbs -Native forms support two HTTP verbs: `GET` and `POST`. Remix uses these verbs to understand your intent. If it's a get, Remix will figure out what parts of the page are changing and only fetch the data for the changing layouts, and use the cached data for the layouts that don't change. When it's a POST, Remix will reload all data to ensure it captures the update from the server. Let's take a look at both. +Native forms support two HTTP verbs: `GET` and `POST`. Remix uses these verbs to understand your intent. If it's a GET, Remix will figure out what parts of the page are changing and only fetch the data for the changing layouts, and use the cached data for the layouts that don't change. When it's a POST, Remix will reload all data to ensure it captures the update from the server. Let's take a look at both. ### HTML Form GET -A `GET` is just a normal navigation where the form data is passed in the URL search params. You use it for normal navigation, just like `` except the user gets to provide the data in the search params through the form. Aside from search pages, it's use with `` is pretty rare. +A `GET` is just a normal navigation where the form data is passed in the URL search params. You use it for normal navigation, just like `` except the user gets to provide the data in the search params through the form. Aside from search pages, its use with `` is pretty rare. Consider this form: @@ -114,7 +114,7 @@ export async function action({ request }) { } ``` -The browser started at `/projects/new`, then posted to `/projects` with the form data in the request, then the server redirected the browser to `/projects/123`. While this is all happening, the browser goes into it's normal "loading" state: the address progress bar fills up, the favicon turns into a spinner, etc. It's actually a decent user experience. +The browser started at `/projects/new`, then posted to `/projects` with the form data in the request, then the server redirected the browser to `/projects/123`. While this is all happening, the browser goes into its normal "loading" state: the address progress bar fills up, the favicon turns into a spinner, etc. It's actually a decent user experience. If you're newer to web development, you may not have ever used a form this way. Lots of folks have always done: @@ -176,8 +176,8 @@ export default function NewProject() { Now add the route action. Any form submissions that are "post" will call your data "action". Any "get" submissions (``) will be handled by your "loader". ```tsx [5-11] -import type { ActionFunction } from "@remix-run/{runtime}"; -import { redirect } from "@remix-run/{runtime}"; +import type { ActionFunction } from "@remix-run/node"; // or "@remix-run/cloudflare" +import { redirect } from "@remix-run/node"; // or "@remix-run/cloudflare" // Note the "action" export name, this will handle our form POST export const action: ActionFunction = async ({ @@ -230,7 +230,7 @@ export const action: ActionFunction = async ({ Just like `useLoaderData` returns the values from the `loader`, `useActionData` will return the data from the action. It will only be there if the navigation was a form submission, so you always have to check if you've got it or not. ```tsx [2,11,21,26-30,38,43-47] -import { redirect } from "@remix-run/{runtime}"; +import { redirect } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useActionData } from "@remix-run/react"; export const action: ActionFunction = async ({ @@ -295,7 +295,7 @@ You can ship this code as-is. The browser will handle the pending UI and interru Let's use progressive enhancement to make this UX a bit more fancy. By changing it from `` to ``, Remix will emulate the browser behavior with `fetch`. It will also give you access to the pending form data so you can build pending UI. ```tsx [2, 11] -import { redirect } from "@remix-run/{runtime}"; +import { redirect } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useActionData, Form } from "@remix-run/react"; // ... @@ -319,7 +319,7 @@ If you don't have the time or drive to do the rest of the job here, use ``FormData` object. You'll be most interested in the `formData.get()` method.. ```tsx [5, 13, 19, 65-67] -import { redirect } from "@remix-run/{runtime}"; +import { redirect } from "@remix-run/node"; // or "@remix-run/cloudflare" import { useActionData, Form, diff --git a/docs/guides/mdx.md b/docs/guides/mdx.md index cda2e71fd50..cb15ca602de 100644 --- a/docs/guides/mdx.md +++ b/docs/guides/mdx.md @@ -87,7 +87,7 @@ The following example demonstrates how you might build a simple blog with MDX, i In `app/routes/index.jsx`: ```tsx -import { json } from "@remix-run/{runtime}"; +import { json } from "@remix-run/node"; // or "@remix-run/cloudflare" import { Link, useLoaderData } from "@remix-run/react"; // Import all your posts from the app/routes/posts directory. Since these are diff --git a/docs/guides/migrating-react-router-app.md b/docs/guides/migrating-react-router-app.md new file mode 100644 index 00000000000..c8dfd32794b --- /dev/null +++ b/docs/guides/migrating-react-router-app.md @@ -0,0 +1,612 @@ +--- +title: Migrating your React Router App to Remix +description: Migrating your React Router app to Remix can be done all at once or in stages. This guide will walk you through an iterative approach to get your app running quickly. +--- + +If you want a TL;DR version along with a repo outlining a simplified migration, check out our example React Router-to-Remix repo. + +# Migrating your React Router App to Remix + +Millions of React applications deployed worldwide are powered by [React Router](https://reactrouter.com/). Chances are you've shipped a few of them! Because Remix is built on top of React Router, we wave worked to make migration an easy process you can work through iteratively to avoid huge refactors. + +If you aren't already using React Router, we think there are several compelling reasons to reconsider! History management, dynamic path matching, nested routing, and much more. Take a look at the [React Router docs](https://reactrouter.com/docs/en/v6/getting-started/concepts) and see all what we have to offer. + +## Ensure your app uses React Router v6 + +If you are using an older version of React Router, the first step is to upgrade to v6. Check out the [migration guide from v5 to v6](https://reactrouter.com/docs/en/v6/upgrading/v5) and our [backwards compatibility package](https://www.npmjs.com/package/react-router-dom-v5-compat) to upgrade your app to v6 quickly and iteratively. + +## Installing Remix + +First, you'll need a few of our packages to build on Remix. Follow the instructions below, running all commands from the root of your project. + +```shell +npm install @remix-run/react @remix-run/node @remix-run/serve +npm install -D @remix-run/dev +``` + +## Creating server and browser entrypoints + +Most React Router apps run primarily in the browser. The server's only job is to send a single static HTML page while React Router manages the route-based views client-side. These apps generally have a browser entrypoint file like a root `index.js` that looks something like this: + +```jsx filename=index.js +import * as ReactDOM from "react-dom"; + +import App from "./App"; + +ReactDOM.render(, document.getElementById("app")); +``` + +Server-rendered React apps are a little different. The browser script is not rendering your app, but is "hydrating" the DOM provided by the server. Hydration is the process of mapping the elements in the DOM to their React component counterparts and setting up event listeners so that your app is interative. + +Let's start by creating two new files: + +- `app/entry.server.jsx` (or `entry.server.tsx`) +- `app/entry.client.jsx` (or `entry.client.tsx`) + +All of your app code in Remix will live in an `app` directory by convention. If your existing app uses a directory with the same name, rename it to something like `src` or `old-app` to differentiate as we migrate to Remix. + +```js filename=entry.server.jsx +import { RemixServer } from "@remix-run/react"; +import { renderToString } from "react-dom/server"; + +export default function handleRequest( + request, + responseStatusCode, + responseHeaders, + remixContext +) { + let markup = renderToString( + + ); + responseHeaders.set("Content-Type", "text/html"); + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} +``` + +If you are using React 17, your client entrypoint will look like this: + +```js filename=entry.client.jsx lines=[2,4] +import { RemixBrowser } from "@remix-run/react"; +import { hydrate } from "react-dom"; + +hydrate(, document); +``` + +In React 18, you'll use `hydrateRoot` instead of `hydrate`. + +```js filename=entry.client.jsx lines=[2,4] +import { RemixBrowser } from "@remix-run/react"; +import { hydrateRoot } from "react-dom/client"; + +hydrateRoot(document, ); +``` + +## Creating The `root` route + +We mentioned that Remix is built on top of React Router. Your app likely renders a `BrowserRouter` with your routes defined in JSX `Route` components. We don't need to do that in Remix, but more on that later. For now we need to provide the lowest level route our Remix app needs to work. + +The root route (or the "root root" if you're Wes Bos) is responsible for providing the structure of the application. Its default export is a component that renders the full HTML tree that every other route loads and depends on. Think of it as the scaffold or shell of your app. + +In a client-rendered app, you will have an index HTML file that includes the DOM node for mounting your React app. The root route will render markup that mirrors the structure of this file. + +Create a new file called `root.jsx` (or `root.tsx`) in your `app` directory. The contents of that file will vary, but let's assume that your `index.html` looks something like this: + +```html filename=index.html + + + + + + + + + + + My React App + + + +
+ + +``` + +In your `root.jsx`, export a component that mirrors its structure: + +```js filename=root.jsx +import { Outlet } from "@remix-run/react"; + +export default function Root() { + return ( + + + + + + + + + + My React App + + +
+ +
+ + + ); +} +``` + +Notice a few things here: + +- We got rid of the `noscript` tag. We're server rendering now, which means users who disable JavaScript will still be able to see our app (and over time, as you make [a few tweaks to improve progressive enahancement](../pages/philosophy#progressive-enhancement), much of your app should still work). +- Inside of the root element we render an `Outlet` component from `@remix-run/react`. This is the same component that you would normally use to render your matched route in a React Router app; it serves the same function here, but it's adapter for the router in Remix. + +Important: be sure to delete the `index.html` from your `public` directory after you've created your root route. Keeping the file around may cause your server to send that HTML instead of your Remix app when accessing the `/` route. + +## Adapting your existing app code + +First, move the root of your existing React code into your `app` directory. So if your root app code lives in an `src` directory in the project root, it should now be in `app/src`. + +We also suggest renaming this directory to make it clear that this is your old code so that, eventually, you can delete it after migrating all of its contents. The beauty of this approach is that you don't have to do it all at once for your app to run as usual. In our demo project we name this directory `old-app`. + +Lastly, in your root `App` component (the one that would have been mounted to the `root` element), remove the `` from React Router. Remix takes care of this for you without needing to render the provider directly. + +## Creating a catch-all route + +Remix needs routes beyond the root route to know what to render in ``. Fortunately you already render `` components in your app, and Remix can use those as you migrate to use our [routing conventions](./routing.md). + +To start, create a new directory in `app` called `routes`. In that directory, create a file called `$.jsx`. This is called [a **catch-all route**](./routing#splats) and it will be useful to let your old app handle routes that you haven't moved into the `routes` directory yet. + +Inside of your `$.jsx` file, all we need to do is export the code from our old root `App`: + +```js filename=$.jsx +export { default } from "~/old-app/app"; +``` + +## Replacing the bundler with Remix + +Remix provides its own bundler and CLI tools for development and building your app. Chances are your app used something like Create React App to bootstrap, or perhaps you have a custom build set up with Webpack. + +In your `package.json` file, update your scripts to use `remix` commands instead of your current build and dev scripts. + +```json filename=package.json +{ + "scripts": { + "build": "remix build", + "dev": "remix dev", + "start": "remix-serve build" + } +} +``` + +And poof! Your app is now server-rendered and your build went from 90 seconds to 0.5 seconds ⚑ + +## Creating your routes + +Over time you'll want to migrate the routes rendered by React Router's `` components into their own route files. The filenames and directory structure outlined in our [routing conventions](./routing.md) will guide this migration. + +The default export in your route file is the component rendered in the ``. So if you have a route in your `App` that looks like this: + +```jsx filename=app/old-app/app.jsx +function About() { + return ( +
+

About us

+ +
+ ); +} + +function App() { + return ( + + } /> + + ); +} +``` + +Your route file should look like this: + +```jsx filename=app/routes/about.jsx +export default function About() { + return ( +
+

About us

+ +
+ ); +} +``` + +Once you create this file, you can delete the `` component from your `App`. After all of your routes have been migrated you can delete `` and ultimately all of the code in `old-app`. + +## Gotchas and next steps + +At this point you _might_ be able to say you are done with the initial migration. Congrats! However Remix does things a bit differently than your typical React app. If it didn't, why would we have bothered building it in the first place? πŸ˜… + +### Unsafe browser references + +A common pain-point in migrating a client-rendered codebase to a server-rendered one is that you may have references to browser APIs in code that runs on the server. A common example can be found when initializing values in state: + +```jsx +function Count() { + let [count, setCount] = React.useState( + () => localStorage.getItem("count") || 0 + ); + + React.useEffect(() => { + localStorage.setItem("count", count); + }, [count]); + + return ( +
+

Count: {count}

+ +
+ ); +} +``` + +In this example, `localStorage` is used as a global store to persist some data across page reloads. We update `localStorage` with the current value of `count` in `useEffect`, which is perfectly safe because `useEffect` is only ever called in the browser! However initializing state based on `localStorage` is a problem, as this callback is executed on both the server and in the browser. + +Your go-to solution may be to check for the `window` object and only run the callback in the browser. However this can lead to another problem, which is the dreaded [hydration mismatch](https://reactjs.org/docs/react-dom.html#hydrate). React relies on markup rendered by the server to be identical to what is rendered during client hydration. This ensures that `react-dom` knows how to match DOM elements with their corresponding React components so that it can attach event listeners and perform updates as state changes. So if local storage gives us a different value than whatever we initiate on the server, we'll have a new problem to deal with. + +#### Client-only components + +One potential solution here is using a different caching mechanism that can be used on the server and passed to the component via props passed from a route's [loader data](../api/conventions#loader). But if it isn't crucial for your app to render the component on the server, a simpler solution may be to skip rendering altogether on the server and wait until hydration is complete to render it in the browser. + +```jsx +// We can safely track hydration in memory state +// outside of the component because it is only +// updated once after the version instance of +// `SomeComponent` has been hydrated. From there, +// the browser takes over rendering duties across +// route changes and we no longer need to worry +// about hydration mismatches until the page is +// reloaded and `isHydrating` is reset to true. +let isHydrating = true; + +function SomeComponent() { + let [isHydrated, setIsHydrated] = React.useState( + !isHydrating + ); + + React.useEffect(() => { + isHydrating = false; + setIsHydrated(true); + }, []); + + if (isHydrated) { + return ; + } else { + return ; + } +} +``` + +To simplify this solution, we recommend the using the [`ClientOnly` component](https://github.com/sergiodxa/remix-utils/blob/main/src/react/client-only.tsx) in the [`remix-utils`](https://www.npmjs.com/package/remix-utils) community package. An example of its usage can be found in the [`examples` directory of the Remix repo](https://github.com/remix-run/remix/blob/main/examples/client-only-components/app/routes/index.tsx). + +### `React.lazy` and `React.Suspense` + +If you are lazy-loading components with [`React.lazy`](https://reactjs.org/docs/code-splitting.html#reactlazy) and [`React.Suspense`](https://reactjs.org/docs/react-api.html#reactsuspense), you may run into issues depending on the version of React you are using. Until React 18, this would not work on the server as `React.Suspense` was originally implemented as a browser-only feature. + +If you are using React 17, you have a few options: + +- Upgrade to React 18 +- Use the [client-only approach](#client-only-components) outlined above +- Use an alternative lazy-loading solution such as [Loadable Components](https://loadable-components.com/docs/loadable-vs-react-lazy/) +- Remove `React.lazy` and `React.Suspense` altogether + +Keep in mind that Remix automatically handles code-splitting for all your routes that it manages, so as you move things into the `routes` directory you should rarelyβ€”if everβ€”need to use `React.lazy` manually. + +### Configuration + +Further configuration is optional, but the following may be helpful to optimize your development workflow. + +#### `remix.config.js` + +Every Remix app accepts a `remix.config.js` file in the project root. While its settings are optional, we recommend you include a few of them for clarity's sake. See the [docs on configuration](../api/conventions#remixconfigjs) for more information about all available options. + +```js filename=remix.config.js +module.exports = { + appDirectory: "app", + ignoredRouteFiles: ["**/.*"], + assetsBuildDirectory: "public/build", +}; +``` + +#### `jsconfig.json` or `tsconfig.json` + +If you are using TypeScript, you likely already have a `tsconfig.json` in your project. `jsconfig.json` is optional but provides helpful context for many editors. These are the minimal settings we recommend including in your language configuration. + +Remix uses the ~/_ path alias to easily import modules from the root no matter where your file lives in the project. If you change the `appDirectory` in your `remix.config.js`, you'll need to update your path alias for ~/_ as well. + +```json filename=jsconfig.json +{ + "compilerOptions": { + "jsx": "react-jsx", + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + } + } +} +``` + +```json filename=tsconfig.json +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "resolveJsonModule": true, + "moduleResolution": "node", + "baseUrl": ".", + "noEmit": true, + "paths": { + "~/*": ["./app/*"] + } + } +} +``` + +If you are using TypeScript, you also need to create the `remix.env.d.ts` file in the root of your project with the appropriate global type references. + +```ts filename=remix.env.d.ts +/// +/// +``` + +### A note about non-standard imports + +At this point, you _might_ be able to run your app with no changes. If you are using Create React App or a highly-configured Webpack app, you likely use `import` to include non-JavaScript modules like stylesheets and images. + +Remix does not support most non-standard imports, and we think for good reason. Below is a non-exhaustive list of some of the differences you'll encounter in Remix, and how to refactor as you migrate. + +#### Asset imports + +Many bundlers use plugins to allow importing various assets like images and fonts. These typically come into your component as string representing the filepath of the asset. + +```js +import logo from "./logo.png"; + +export function Logo() { + return My logo; +} +``` + +In Remix, this works basically the same way. For assets like fonts that are loaded by a `` element, you'll generally import these in a route module and include the filename in an object returned by a `links` function. [See our docs on route `links` for more information.](../api/conventions#links) + +#### SVG imports + +Create React App and some Webpack plugins allow you to import SVG files as a React component. This is a common use case for SVG files, but it's not supported by default in Remix. + +```js bad nocopy +// This will not work in Remix! +import MyLogo from "./logo.svg"; + +export function Logo() { + return ; +} +``` + +If you want to use SVG files as React components, you'll need to first create the components and import them directly. [React SVGR](https://react-svgr.com/) is a great toolset that can help you generate these components from the [command line](https://react-svgr.com/docs/cli/) or in an [online playground](https://react-svgr.com/playground/) if you prefer to copy and paste. + +```svg filename=icon.svg + + + +``` + +```jsx filename=icon.jsx good +export default function Icon() { + return ( + + + + ); +} +``` + +#### CSS imports + +Create React App and many Webpack plugins support importing CSS in your components in many ways. While this is common practice in the React ecosystem, it's not supported the same way in Remix for a few different reasons. We'll discuss this in depth in the next section, but for now just know that you need to import your stylesheets in route modules. Importing stylesheets directly in non-route components is not currently supported. + +[Read more about route styles and why Remix does things a bit differently.](#route-stylesheets) + +### Route styles + +Let's talk a bit more about styles. Remix does not handle CSS imports the same way your bundler likely does, and we think that's for a good reason. + +Assume you have a plain CSS import in your `App` component: + +```jsx filename=app.jsx lines=[5] +import { Outlet } from "react-router-dom"; + +import Logo from "./logo"; +import SiteNav from "./site-nav"; +import "./styles.css"; + +export default function App() { + return ( +
+
+ + +
+
+ +
+
© Remix Software
+
+ ); +} +``` + +While this is a convenient API, consider a few questions: + +- How do the styles actually end up on the page? Do you get a `` or an inline ` + + + + + + + + ); +} diff --git a/examples/session-flash/app/routes/index.tsx b/examples/session-flash/app/routes/index.tsx new file mode 100644 index 00000000000..c022cf08bea --- /dev/null +++ b/examples/session-flash/app/routes/index.tsx @@ -0,0 +1,151 @@ +import type { ActionFunction, LoaderFunction } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { useLoaderData, Form, useActionData } from "@remix-run/react"; +import { useEffect, useRef } from "react"; +import type { FlashMessage as FlashMessageType } from "~/utils/session.server"; +import { getSession, storage } from "~/utils/session.server"; +import { getSessionFlash } from "~/utils/session.server"; + +interface LoaderData { + message?: FlashMessageType; +} + +interface ActionData { + formError: string; +} + +export const loader: LoaderFunction = async ({ request }) => { + const flash = await getSessionFlash(request); + if (flash && flash.message) { + return json({ message: flash.message }, { headers: flash.headers }); + } + + return null; +}; + +export const action: ActionFunction = async ({ request }) => { + const form = await request.formData(); + const text = form.get("messageText"); + const color = form.get("messageColor"); + + if (!text || !color) { + return json({ formError: `Invalid form submission` }, { status: 400 }); + } + + const session = await getSession(request); + session.flash("messageText", text); + session.flash("messageColor", color); + + return redirect(".", { + headers: { "Set-Cookie": await storage.commitSession(session) }, + status: 200, + }); +}; + +export default function Index() { + const loaderData = useLoaderData(); + const actionData = useActionData(); + return ( + <> + {loaderData?.message ? ( + + ) : null} + + + +
+ +
+ Pick a color + + + + + + + + +
+ +
+ + {actionData?.formError ? ( +
{actionData.formError}
+ ) : null} + + + ); +} + +function FlashMessage({ message }: { message: FlashMessageType }) { + const dialogRef = useRef(null); + + useEffect(() => { + if (message && !dialogRef.current?.open) dialogRef.current?.show(); + }, [message]); + + return message ? ( + +

{message.text}

+ {/* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-method */} +
+ +
+
+ ) : null; +} + +/* +Source: TypeScript-DOM-lib-generator +https://github.com/microsoft/TypeScript-DOM-lib-generator/blob/31e9b893980c91991f45a565dfbd6280798e2b4f/baselines/dom.generated.d.ts#L6234 +*/ +interface HTMLDialogElement extends HTMLElement { + open: boolean; + returnValue: string; + /** + * Closes the dialog element. + * + * The argument, if provided, provides a return value. + */ + close(returnValue?: string): void; + /** Displays the dialog element. */ + show(): void; + showModal(): void; + addEventListener( + type: K, + listener: (this: HTMLDialogElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | AddEventListenerOptions + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions + ): void; + removeEventListener( + type: K, + listener: (this: HTMLDialogElement, ev: HTMLElementEventMap[K]) => any, + options?: boolean | EventListenerOptions + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions + ): void; +} diff --git a/examples/session-flash/app/utils/session.server.ts b/examples/session-flash/app/utils/session.server.ts new file mode 100644 index 00000000000..126fa833336 --- /dev/null +++ b/examples/session-flash/app/utils/session.server.ts @@ -0,0 +1,36 @@ +import { createCookieSessionStorage } from "@remix-run/node"; + +export interface FlashMessage { + color: string; + text: string; +} + +// Create a minimal cookie sesssion +export const storage = createCookieSessionStorage({ + cookie: { + name: "session-flash__session", + secrets: ["mySESSIONsecret"], + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 30, + httpOnly: true, + }, +}); + +export async function getSession(request: Request) { + return await storage.getSession(request.headers.get("Cookie")); +} + +export async function getSessionFlash(request: Request) { + const session = await getSession(request); + + const message: FlashMessage = { + color: session.get("messageColor"), + text: session.get("messageText"), + }; + if (!message.color || !message.text) return null; + + const headers = { "Set-Cookie": await storage.commitSession(session) }; + + return { message, headers }; +} diff --git a/examples/session-flash/package.json b/examples/session-flash/package.json new file mode 100644 index 00000000000..1ace5631db3 --- /dev/null +++ b/examples/session-flash/package.json @@ -0,0 +1,33 @@ +{ + "name": "remix-example-session-flash", + "private": true, + "description": "", + "license": "", + "sideEffects": false, + "scripts": { + "build": "remix build", + "dev": "remix dev", + "start": "remix-serve build" + }, + "dependencies": { + "@fontsource/fira-sans": "^4.5.8", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-router-dom": "^6.3.0" + }, + "devDependencies": { + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", + "@types/react": "^17.0.39", + "@types/react-dom": "^17.0.13", + "eslint": "^8.10.0", + "prettier": "2.6.2", + "typescript": "^4.6.2" + }, + "engines": { + "node": ">=14" + } +} diff --git a/examples/session-flash/public/favicon.ico b/examples/session-flash/public/favicon.ico new file mode 100644 index 00000000000..8830cf6821b Binary files /dev/null and b/examples/session-flash/public/favicon.ico differ diff --git a/examples/session-flash/remix.config.js b/examples/session-flash/remix.config.js new file mode 100644 index 00000000000..260b82c7cb1 --- /dev/null +++ b/examples/session-flash/remix.config.js @@ -0,0 +1,10 @@ +/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ["**/.*"], + // appDirectory: "app", + // assetsBuildDirectory: "public/build", + // serverBuildPath: "build/index.js", + // publicPath: "/build/", +}; diff --git a/examples/session-flash/remix.env.d.ts b/examples/session-flash/remix.env.d.ts new file mode 100644 index 00000000000..72e2affe311 --- /dev/null +++ b/examples/session-flash/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/examples/session-flash/sandbox.config.json b/examples/session-flash/sandbox.config.json new file mode 100644 index 00000000000..4363d87a30d --- /dev/null +++ b/examples/session-flash/sandbox.config.json @@ -0,0 +1,6 @@ +{ + "hardReloadOnChange": true, + "container": { + "port": 3000 + } +} diff --git a/examples/session-flash/tsconfig.json b/examples/session-flash/tsconfig.json new file mode 100644 index 00000000000..914a10ebe84 --- /dev/null +++ b/examples/session-flash/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "ES2019", + "strict": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "noEmit": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true + } +} diff --git a/examples/sharing-loader-data/package.json b/examples/sharing-loader-data/package.json index 82454e9e9cc..50e97e9dc22 100644 --- a/examples/sharing-loader-data/package.json +++ b/examples/sharing-loader-data/package.json @@ -10,15 +10,15 @@ "start": "remix-serve build" }, "dependencies": { - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", - "@remix-run/serve": "1.4.3", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", "react": "^17.0.2", "react-dom": "^17.0.2" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.13", "eslint": "^8.10.0", diff --git a/examples/sharing-loader-data/tsconfig.json b/examples/sharing-loader-data/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/sharing-loader-data/tsconfig.json +++ b/examples/sharing-loader-data/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/socket.io/package.json b/examples/socket.io/package.json index 1eca1802ec4..01f16b357c9 100644 --- a/examples/socket.io/package.json +++ b/examples/socket.io/package.json @@ -11,9 +11,9 @@ "start:dev": "cross-env NODE_ENV=development node server/index.js" }, "dependencies": { - "@remix-run/express": "1.4.3", - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", + "@remix-run/express": "1.5.0", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", "compression": "^1.7.4", "cross-env": "^7.0.3", "express": "^4.17.3", @@ -24,8 +24,8 @@ "socket.io-client": "^4.4.1" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.13", "eslint": "^8.10.0", diff --git a/examples/socket.io/tsconfig.json b/examples/socket.io/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/socket.io/tsconfig.json +++ b/examples/socket.io/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/stitches/package.json b/examples/stitches/package.json index 9e163e699ee..21ba536116c 100644 --- a/examples/stitches/package.json +++ b/examples/stitches/package.json @@ -10,16 +10,16 @@ "start": "remix-serve build" }, "dependencies": { - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", - "@remix-run/serve": "1.4.3", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", "@stitches/react": "^1.2.7", "react": "^17.0.2", "react-dom": "^17.0.2" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.13", "eslint": "^8.10.0", diff --git a/examples/stitches/tsconfig.json b/examples/stitches/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/stitches/tsconfig.json +++ b/examples/stitches/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/strapi/package.json b/examples/strapi/package.json index d59a5b0be10..2c27c6a81b3 100755 --- a/examples/strapi/package.json +++ b/examples/strapi/package.json @@ -12,16 +12,16 @@ "start": "remix-serve build" }, "dependencies": { - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", - "@remix-run/serve": "1.4.3", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", "marked": "^4.0.12", "react": "^17.0.2", "react-dom": "^17.0.2" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/marked": "^4.0.2", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.13", diff --git a/examples/strapi/tsconfig.json b/examples/strapi/tsconfig.json index 749e4b959e8..20f8a386a6c 100755 --- a/examples/strapi/tsconfig.json +++ b/examples/strapi/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/stripe-integration/package.json b/examples/stripe-integration/package.json index 710c38dd606..6998b7ed78a 100644 --- a/examples/stripe-integration/package.json +++ b/examples/stripe-integration/package.json @@ -10,16 +10,16 @@ "start": "remix-serve build" }, "dependencies": { - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", - "@remix-run/serve": "1.4.3", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", "react": "^17.0.2", "react-dom": "^17.0.2", "stripe": "^8.209.0" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/react": "^17.0.40", "@types/react-dom": "^17.0.13", "eslint": "^8.11.0", diff --git a/examples/stripe-integration/tsconfig.json b/examples/stripe-integration/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/stripe-integration/tsconfig.json +++ b/examples/stripe-integration/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/styled-components/package.json b/examples/styled-components/package.json index adf7ea0a1b3..b4a8758b182 100644 --- a/examples/styled-components/package.json +++ b/examples/styled-components/package.json @@ -10,16 +10,16 @@ "start": "remix-serve build" }, "dependencies": { - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", - "@remix-run/serve": "1.4.3", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", "react": "^17.0.2", "react-dom": "^17.0.2", "styled-components": "^5.3.3" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.13", "@types/styled-components": "^5.1.24", diff --git a/examples/styled-components/tsconfig.json b/examples/styled-components/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/styled-components/tsconfig.json +++ b/examples/styled-components/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/styletron/package.json b/examples/styletron/package.json index 02ca12a4570..c6a797e577b 100644 --- a/examples/styletron/package.json +++ b/examples/styletron/package.json @@ -10,17 +10,17 @@ "start": "remix-serve build" }, "dependencies": { - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", - "@remix-run/serve": "1.4.3", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", "react": "^17.0.2", "react-dom": "^17.0.2", "styletron-engine-atomic": "^1.4.8", "styletron-react": "^6.0.2" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.13", "@types/styled-components": "^5.1.24", diff --git a/examples/styletron/tsconfig.json b/examples/styletron/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/styletron/tsconfig.json +++ b/examples/styletron/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/supabase-subscription/package.json b/examples/supabase-subscription/package.json index 20f510ac0da..90342708665 100644 --- a/examples/supabase-subscription/package.json +++ b/examples/supabase-subscription/package.json @@ -10,17 +10,17 @@ "start": "remix-serve build" }, "dependencies": { - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", - "@remix-run/serve": "1.4.3", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", "@supabase/supabase-js": "^1.31.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-supabase": "^0.2.0" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.13", "eslint": "^8.10.0", diff --git a/examples/supabase-subscription/tsconfig.json b/examples/supabase-subscription/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/supabase-subscription/tsconfig.json +++ b/examples/supabase-subscription/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/tailwindcss/package.json b/examples/tailwindcss/package.json index 4faf8d72f36..c39c46c401d 100644 --- a/examples/tailwindcss/package.json +++ b/examples/tailwindcss/package.json @@ -15,15 +15,15 @@ "start": "remix-serve build" }, "dependencies": { - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", - "@remix-run/serve": "1.4.3", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", "react": "^17.0.2", "react-dom": "^17.0.2" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.13", "eslint": "^8.10.0", diff --git a/examples/tailwindcss/tsconfig.json b/examples/tailwindcss/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/tailwindcss/tsconfig.json +++ b/examples/tailwindcss/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/template/package.json b/examples/template/package.json index 593acca0841..33f73397644 100644 --- a/examples/template/package.json +++ b/examples/template/package.json @@ -10,15 +10,15 @@ "start": "remix-serve build" }, "dependencies": { - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", - "@remix-run/serve": "1.4.3", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", "react": "^17.0.2", "react-dom": "^17.0.2" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.13", "eslint": "^8.10.0", diff --git a/examples/template/tsconfig.json b/examples/template/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/template/tsconfig.json +++ b/examples/template/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/tiptap-collab-editing/package.json b/examples/tiptap-collab-editing/package.json index c73412bf444..4e490b35373 100644 --- a/examples/tiptap-collab-editing/package.json +++ b/examples/tiptap-collab-editing/package.json @@ -10,9 +10,9 @@ "start": "remix-serve build" }, "dependencies": { - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", - "@remix-run/serve": "1.4.3", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", "@tiptap/extension-collaboration": "^2.0.0-beta.33", "@tiptap/react": "^2.0.0-beta.108", "@tiptap/starter-kit": "^2.0.0-beta.183", @@ -22,8 +22,8 @@ "yjs": "^13.5.34" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.13", "eslint": "^8.10.0", diff --git a/examples/tiptap-collab-editing/tsconfig.json b/examples/tiptap-collab-editing/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/tiptap-collab-editing/tsconfig.json +++ b/examples/tiptap-collab-editing/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/toast-message/package.json b/examples/toast-message/package.json index f10e0ba3dcf..732cd5ae5b1 100644 --- a/examples/toast-message/package.json +++ b/examples/toast-message/package.json @@ -10,16 +10,16 @@ "start": "remix-serve build" }, "dependencies": { - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", - "@remix-run/serve": "1.4.3", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", "react": "^17.0.2", "react-dom": "^17.0.2", "react-hot-toast": "^2.2.0" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.13", "eslint": "^8.10.0", diff --git a/examples/toast-message/tsconfig.json b/examples/toast-message/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/toast-message/tsconfig.json +++ b/examples/toast-message/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/turborepo-vercel/apps/remix-app/tsconfig.json b/examples/turborepo-vercel/apps/remix-app/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/turborepo-vercel/apps/remix-app/tsconfig.json +++ b/examples/turborepo-vercel/apps/remix-app/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/examples/twind/.eslintrc.js b/examples/twind/.eslintrc.js new file mode 100644 index 00000000000..ced78085f86 --- /dev/null +++ b/examples/twind/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], +}; diff --git a/examples/twind/.gitignore b/examples/twind/.gitignore new file mode 100644 index 00000000000..3f7bf98da3e --- /dev/null +++ b/examples/twind/.gitignore @@ -0,0 +1,6 @@ +node_modules + +/.cache +/build +/public/build +.env diff --git a/examples/twind/README.md b/examples/twind/README.md new file mode 100644 index 00000000000..98bc604375b --- /dev/null +++ b/examples/twind/README.md @@ -0,0 +1,25 @@ +# Twind Example + +Integrate Twind with Remix with SSR. + +## Preview + +Open this example on [CodeSandbox](https://codesandbox.com): + +[![Open in CodeSandbox](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/remix-run/remix/tree/main/examples/twind) + +## Example + +This example shows how to use Twind in Remix. Twind is a small compiler (~13kB) that converts Tailwind utility classes into CSS at runtime. + +Relevant files: + +- [app/entry.server.tsx](./app/entry.server.tsx) where the twind styles have been added to the markup to enable server-side-rendering (SSR) of styles. +- [app/root.tsx](./app/root.tsx) where twind has been set up. +- [app/routes/index.tsx](./app/routes/index.tsx) and [app/routes/anything.tsx](./app/routes/anything.tsx) where some basic styling has been demonstrated. +- [remix.config.js](./remix.config.js) where the twind modules have been added to [`serverDependenciesToBundle`](https://remix.run/docs/en/v1/api/conventions#serverdependenciestobundle). +- [twind.config.ts](./twind.config.ts) (optional) where twind can be [configured](https://twind.dev/handbook/configuration.html). + +## Related Links + +[Twind](https://twind.dev/) diff --git a/examples/twind/app/entry.client.tsx b/examples/twind/app/entry.client.tsx new file mode 100644 index 00000000000..3eec1fd0a02 --- /dev/null +++ b/examples/twind/app/entry.client.tsx @@ -0,0 +1,4 @@ +import { RemixBrowser } from "@remix-run/react"; +import { hydrate } from "react-dom"; + +hydrate(, document); diff --git a/examples/twind/app/entry.server.tsx b/examples/twind/app/entry.server.tsx new file mode 100644 index 00000000000..43e84cdd874 --- /dev/null +++ b/examples/twind/app/entry.server.tsx @@ -0,0 +1,25 @@ +import { renderToString } from "react-dom/server"; +import type { EntryContext } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import inline from "@twind/with-remix/server"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + let markup = renderToString( + + ); + + // Add the twind styles to the markup + markup = inline(markup); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/examples/twind/app/root.tsx b/examples/twind/app/root.tsx new file mode 100644 index 00000000000..524768e393f --- /dev/null +++ b/examples/twind/app/root.tsx @@ -0,0 +1,37 @@ +import type { MetaFunction } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; +import install from "@twind/with-remix"; + +import config from "../twind.config"; + +install(config); + +export const meta: MetaFunction = () => ({ + charset: "utf-8", + title: "Remix + Twind", + viewport: "width=device-width,initial-scale=1", +}); + +export default function App() { + return ( + + + + + + + + + + + + + ); +} diff --git a/examples/twind/app/routes/anything.tsx b/examples/twind/app/routes/anything.tsx new file mode 100644 index 00000000000..97e877d604b --- /dev/null +++ b/examples/twind/app/routes/anything.tsx @@ -0,0 +1,15 @@ +import { Link } from "@remix-run/react"; + +const linkClass = + "transition-all duration-300 opacity-75 hover:(opacity-100 text-blue-500)"; + +export default function Index() { + return ( +
+

This works

+ + Back to Home + +
+ ); +} diff --git a/examples/twind/app/routes/index.tsx b/examples/twind/app/routes/index.tsx new file mode 100644 index 00000000000..66b89ed0ec7 --- /dev/null +++ b/examples/twind/app/routes/index.tsx @@ -0,0 +1,52 @@ +import { Link } from "@remix-run/react"; + +const linkClass = + "transition-all duration-300 opacity-75 hover:(opacity-100 text-blue-500)"; + +export default function Index() { + const links = [ + { + href: "https://remix.run/tutorials/blog", + text: "15m Quickstart Blog Tutorial", + }, + { + href: "https://remix.run/tutorials/jokes", + text: "Deep Dive Jokes App Tutorial", + }, + { + href: "https://remix.run/docs", + text: "Remix Docs", + }, + { + href: "https://twind.dev/", + text: "Twind Docs", + }, + ]; + + return ( +
+

+ Welcome to Remix + Twind πŸ’ΏπŸš€ +

+ +
+ ); +} diff --git a/examples/twind/package.json b/examples/twind/package.json new file mode 100644 index 00000000000..99d93c266af --- /dev/null +++ b/examples/twind/package.json @@ -0,0 +1,35 @@ +{ + "name": "remix-example-twind", + "private": true, + "description": "", + "license": "", + "sideEffects": false, + "scripts": { + "build": "remix build", + "dev": "remix dev", + "start": "remix-serve build" + }, + "dependencies": { + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", + "@twind/preset-autoprefix": "1.0.0-next.38", + "@twind/preset-tailwind": "1.0.0-next.38", + "@twind/with-remix": "1.0.0-next.38", + "cross-env": "^7.0.3", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "twind": "^1.0.0-next.32" + }, + "devDependencies": { + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", + "@types/react": "^17.0.39", + "@types/react-dom": "^17.0.13", + "eslint": "^8.10.0", + "typescript": "^4.6.2" + }, + "engines": { + "node": ">=14" + } +} diff --git a/examples/twind/public/favicon.ico b/examples/twind/public/favicon.ico new file mode 100644 index 00000000000..8830cf6821b Binary files /dev/null and b/examples/twind/public/favicon.ico differ diff --git a/examples/twind/remix.config.js b/examples/twind/remix.config.js new file mode 100644 index 00000000000..fbdc21f9882 --- /dev/null +++ b/examples/twind/remix.config.js @@ -0,0 +1,16 @@ +/** + * @type {import('@remix-run/dev').AppConfig} + */ +module.exports = { + ignoredRouteFiles: ["**/.*"], + appDirectory: "app", + assetsBuildDirectory: "public/build", + serverBuildPath: "build/index.js", + publicPath: "/build/", + serverDependenciesToBundle: [ + "@twind/with-remix", + "twind", + "@twind/preset-autoprefix", + "@twind/preset-tailwind", + ], +}; diff --git a/examples/twind/remix.env.d.ts b/examples/twind/remix.env.d.ts new file mode 100644 index 00000000000..72e2affe311 --- /dev/null +++ b/examples/twind/remix.env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/examples/twind/sandbox.config.json b/examples/twind/sandbox.config.json new file mode 100644 index 00000000000..4363d87a30d --- /dev/null +++ b/examples/twind/sandbox.config.json @@ -0,0 +1,6 @@ +{ + "hardReloadOnChange": true, + "container": { + "port": 3000 + } +} diff --git a/examples/twind/tsconfig.json b/examples/twind/tsconfig.json new file mode 100644 index 00000000000..914a10ebe84 --- /dev/null +++ b/examples/twind/tsconfig.json @@ -0,0 +1,20 @@ +{ + "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2019"], + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "moduleResolution": "node", + "resolveJsonModule": true, + "target": "ES2019", + "strict": true, + "baseUrl": ".", + "paths": { + "~/*": ["./app/*"] + }, + "noEmit": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true + } +} diff --git a/examples/twind/twind.config.ts b/examples/twind/twind.config.ts new file mode 100644 index 00000000000..0de3dac9c94 --- /dev/null +++ b/examples/twind/twind.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "twind"; +import presetAutoprefix from "@twind/preset-autoprefix"; +import presetTailwind from "@twind/preset-tailwind"; + +export default defineConfig({ + presets: [presetAutoprefix(), presetTailwind()], + // config goes here +}); diff --git a/examples/usematches-loader-data/package.json b/examples/usematches-loader-data/package.json index 8c95113e6d3..498488a22be 100644 --- a/examples/usematches-loader-data/package.json +++ b/examples/usematches-loader-data/package.json @@ -10,15 +10,15 @@ "start": "remix-serve build" }, "dependencies": { - "@remix-run/node": "1.4.3", - "@remix-run/react": "1.4.3", - "@remix-run/serve": "1.4.3", + "@remix-run/node": "1.5.0", + "@remix-run/react": "1.5.0", + "@remix-run/serve": "1.5.0", "react": "^17.0.2", "react-dom": "^17.0.2" }, "devDependencies": { - "@remix-run/dev": "1.4.3", - "@remix-run/eslint-config": "1.4.3", + "@remix-run/dev": "1.5.0", + "@remix-run/eslint-config": "1.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.13", "eslint": "^8.10.0", diff --git a/examples/usematches-loader-data/tsconfig.json b/examples/usematches-loader-data/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/examples/usematches-loader-data/tsconfig.json +++ b/examples/usematches-loader-data/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/integration/helpers/cf-template/tsconfig.json b/integration/helpers/cf-template/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/integration/helpers/cf-template/tsconfig.json +++ b/integration/helpers/cf-template/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/integration/helpers/node-template/tsconfig.json b/integration/helpers/node-template/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/integration/helpers/node-template/tsconfig.json +++ b/integration/helpers/node-template/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/packages/create-remix/package.json b/packages/create-remix/package.json index c779581a092..601522364e5 100644 --- a/packages/create-remix/package.json +++ b/packages/create-remix/package.json @@ -1,6 +1,6 @@ { "name": "create-remix", - "version": "1.4.3", + "version": "1.5.0", "description": "Create a new Remix app", "homepage": "https://remix.run", "license": "MIT", @@ -16,6 +16,6 @@ "create-remix": "cli.js" }, "dependencies": { - "@remix-run/dev": "1.4.3" + "@remix-run/dev": "1.5.0" } } diff --git a/packages/remix-architect/package.json b/packages/remix-architect/package.json index b2759271f19..dc711ff9c6c 100644 --- a/packages/remix-architect/package.json +++ b/packages/remix-architect/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/architect", "description": "Architect server request handler for Remix", - "version": "1.4.3", + "version": "1.5.0", "license": "MIT", "repository": { "type": "git", @@ -13,7 +13,7 @@ }, "dependencies": { "@architect/functions": "^5.0.2", - "@remix-run/node": "1.4.3", + "@remix-run/node": "1.5.0", "@types/aws-lambda": "^8.10.82" }, "devDependencies": { diff --git a/packages/remix-cloudflare-pages/package.json b/packages/remix-cloudflare-pages/package.json index 162112baf73..8a9803a9615 100644 --- a/packages/remix-cloudflare-pages/package.json +++ b/packages/remix-cloudflare-pages/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/cloudflare-pages", "description": "Cloudflare Pages request handler for Remix", - "version": "1.4.3", + "version": "1.5.0", "license": "MIT", "main": "./index.js", "module": "./esm/index.js", @@ -14,7 +14,7 @@ "url": "https://github.com/remix-run/remix/issues" }, "dependencies": { - "@remix-run/cloudflare": "1.4.3" + "@remix-run/cloudflare": "1.5.0" }, "peerDependencies": { "@cloudflare/workers-types": "^3.0.0" diff --git a/packages/remix-cloudflare-workers/package.json b/packages/remix-cloudflare-workers/package.json index 6991cbf77ea..a88f33a9913 100644 --- a/packages/remix-cloudflare-workers/package.json +++ b/packages/remix-cloudflare-workers/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/cloudflare-workers", "description": "Cloudflare worker request handler for Remix", - "version": "1.4.3", + "version": "1.5.0", "license": "MIT", "main": "./index.js", "module": "./esm/index.js", @@ -15,7 +15,7 @@ }, "dependencies": { "@cloudflare/kv-asset-handler": "^0.1.3", - "@remix-run/cloudflare": "1.4.3" + "@remix-run/cloudflare": "1.5.0" }, "peerDependencies": { "@cloudflare/workers-types": "^2.0.0 || ^3.0.0" diff --git a/packages/remix-cloudflare/package.json b/packages/remix-cloudflare/package.json index ebc0ad14be7..b9cc6402cd9 100644 --- a/packages/remix-cloudflare/package.json +++ b/packages/remix-cloudflare/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/cloudflare", "description": "Cloudflare platform abstractions for Remix", - "version": "1.4.3", + "version": "1.5.0", "license": "MIT", "repository": { "type": "git", @@ -13,7 +13,7 @@ }, "dependencies": { "@cloudflare/kv-asset-handler": "^0.1.3", - "@remix-run/server-runtime": "1.4.3" + "@remix-run/server-runtime": "1.5.0" }, "peerDependencies": { "@cloudflare/workers-types": "^2.0.0 || ^3.0.0" diff --git a/packages/remix-deno/package.json b/packages/remix-deno/package.json index 0ea3be90c14..d799e4eb9eb 100644 --- a/packages/remix-deno/package.json +++ b/packages/remix-deno/package.json @@ -1,6 +1,6 @@ { "name": "@remix-run/deno", - "version": "1.4.3", + "version": "1.5.0", "description": "Deno platform abstractions for Remix", "homepage": "https://remix.run", "license": "MIT", @@ -14,7 +14,7 @@ }, "sideEffects": false, "dependencies": { - "@remix-run/server-runtime": "*", + "@remix-run/server-runtime": "1.5.0", "mime": "^3.0.0" } } diff --git a/packages/remix-dev/__tests__/fixtures/stack/tsconfig.json b/packages/remix-dev/__tests__/fixtures/stack/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/packages/remix-dev/__tests__/fixtures/stack/tsconfig.json +++ b/packages/remix-dev/__tests__/fixtures/stack/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 6d67b24d58a..480f9e7c2ef 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -1,6 +1,6 @@ { "name": "@remix-run/dev", - "version": "1.4.3", + "version": "1.5.0", "description": "Dev tools and CLI for Remix", "homepage": "https://remix.run", "license": "MIT", @@ -21,7 +21,7 @@ "@babel/preset-typescript": "7.16.7", "@esbuild-plugins/node-modules-polyfill": "^0.1.4", "@npmcli/package-json": "^2.0.0", - "@remix-run/server-runtime": "1.4.3", + "@remix-run/server-runtime": "1.5.0", "cacache": "^15.0.5", "chalk": "^4.1.2", "chokidar": "^3.5.1", diff --git a/packages/remix-eslint-config/package.json b/packages/remix-eslint-config/package.json index b902a6009ee..2ef1fc9f2c1 100644 --- a/packages/remix-eslint-config/package.json +++ b/packages/remix-eslint-config/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/eslint-config", "description": "ESLint configuration for Remix projects", - "version": "1.4.3", + "version": "1.5.0", "repository": { "type": "git", "url": "https://github.com/remix-run/remix", diff --git a/packages/remix-express/package.json b/packages/remix-express/package.json index 52881ecacfd..094ae0def08 100644 --- a/packages/remix-express/package.json +++ b/packages/remix-express/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/express", "description": "Express server request handler for Remix", - "version": "1.4.3", + "version": "1.5.0", "license": "MIT", "repository": { "type": "git", @@ -12,7 +12,7 @@ "url": "https://github.com/remix-run/remix/issues" }, "dependencies": { - "@remix-run/node": "1.4.3" + "@remix-run/node": "1.5.0" }, "peerDependencies": { "express": "^4.17.1" diff --git a/packages/remix-netlify/package.json b/packages/remix-netlify/package.json index 8ba7947dd7f..7c80d0a27e0 100644 --- a/packages/remix-netlify/package.json +++ b/packages/remix-netlify/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/netlify", "description": "Netlify server request handler for Remix", - "version": "1.4.3", + "version": "1.5.0", "license": "MIT", "repository": { "type": "git", @@ -12,7 +12,7 @@ "url": "https://github.com/remix-run/remix/issues" }, "dependencies": { - "@remix-run/node": "1.4.3" + "@remix-run/node": "1.5.0" }, "peerDependencies": { "@netlify/functions": "^0.10.0 || ^1.0.0" diff --git a/packages/remix-node/package.json b/packages/remix-node/package.json index 81d36d9761d..e307bcff6ae 100644 --- a/packages/remix-node/package.json +++ b/packages/remix-node/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/node", "description": "Node.js platform abstractions for Remix", - "version": "1.4.3", + "version": "1.5.0", "license": "MIT", "repository": { "type": "git", @@ -12,7 +12,7 @@ "url": "https://github.com/remix-run/remix/issues" }, "dependencies": { - "@remix-run/server-runtime": "1.4.3", + "@remix-run/server-runtime": "1.5.0", "@remix-run/web-fetch": "^4.1.3", "@remix-run/web-file": "^3.0.2", "@remix-run/web-stream": "^1.0.3", diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index cf85a70a9c9..17ea44a86de 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/react", "description": "React DOM bindings for Remix", - "version": "1.4.3", + "version": "1.5.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/remix-serve/package.json b/packages/remix-serve/package.json index bdaa2c3af1d..eedf3869984 100644 --- a/packages/remix-serve/package.json +++ b/packages/remix-serve/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/serve", "description": "Production application server for Remix", - "version": "1.4.3", + "version": "1.5.0", "license": "MIT", "repository": { "type": "git", @@ -15,7 +15,7 @@ "remix-serve": "cli.js" }, "dependencies": { - "@remix-run/express": "1.4.3", + "@remix-run/express": "1.5.0", "compression": "^1.7.4", "express": "^4.17.1", "morgan": "^1.10.0" diff --git a/packages/remix-server-runtime/README.md b/packages/remix-server-runtime/README.md index 157c8a8346b..97d479b23de 100644 --- a/packages/remix-server-runtime/README.md +++ b/packages/remix-server-runtime/README.md @@ -10,7 +10,7 @@ Support for each runtime is provided by a corresponding Remix package: - [`@remix-run/node`](https://github.com/remix-run/remix/tree/main/packages/remix-node) - [`@remix-run/cloudflare`](https://github.com/remix-run/remix/tree/main/packages/remix-cloudflare) -- [`remix-deno`](https://github.com/remix-run/remix/tree/main/templates/deno-ts/remix-deno) (will be renamed to `@remix-run/deno` when Deno support is stable) +- [`remix-deno`](https://github.com/remix-run/remix/tree/main/templates/deno/remix-deno) (will be renamed to `@remix-run/deno` when Deno support is stable) This package defines a "Remix server runtime interface" that each runtime package must conform to. diff --git a/packages/remix-server-runtime/__tests__/handler-test.ts b/packages/remix-server-runtime/__tests__/handler-test.ts new file mode 100644 index 00000000000..fd19fb4556a --- /dev/null +++ b/packages/remix-server-runtime/__tests__/handler-test.ts @@ -0,0 +1,30 @@ +import { json } from "../responses"; +import { createRequestHandler } from "../server"; + +describe("createRequestHandler", () => { + it("retains request headers when stripping body off for loaders", async () => { + let handler = createRequestHandler({ + routes: { + root: { + id: "routes/test", + path: "/test", + module: { + loader: ({ request }) => json(request.headers.get("X-Foo")), + } as any, + }, + }, + assets: {} as any, + entry: { module: {} as any }, + }); + + let response = await handler( + new Request("http://.../test", { + headers: { + "X-Foo": "bar", + }, + }) + ); + + expect(await response.json()).toBe("bar"); + }); +}); diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index 9044b8671df..b0cdd1b1b32 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/server-runtime", "description": "Server runtime for Remix", - "version": "1.4.3", + "version": "1.5.0", "license": "MIT", "main": "./index.js", "module": "./esm/index.js", diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index aa6dc89a057..c785fab83f9 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -294,8 +294,11 @@ async function handleDocumentRequest({ } let loaderRequest = new Request(request.url, { - ...request, body: null, + headers: request.headers, + method: request.method, + redirect: request.redirect, + signal: request.signal, }); let routeLoaderResults = await Promise.allSettled( diff --git a/packages/remix-vercel/package.json b/packages/remix-vercel/package.json index 0837b6834b3..eba62c70c03 100644 --- a/packages/remix-vercel/package.json +++ b/packages/remix-vercel/package.json @@ -1,7 +1,7 @@ { "name": "@remix-run/vercel", "description": "Vercel server request handler for Remix", - "version": "1.4.3", + "version": "1.5.0", "license": "MIT", "repository": { "type": "git", @@ -12,7 +12,7 @@ "url": "https://github.com/remix-run/remix/issues" }, "dependencies": { - "@remix-run/node": "1.4.3" + "@remix-run/node": "1.5.0" }, "peerDependencies": { "@vercel/node": "^1.8.3" diff --git a/packages/remix/package.json b/packages/remix/package.json index 06084d59c60..d59665b6e59 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -1,7 +1,7 @@ { "name": "remix", "description": "A framework for building better websites", - "version": "1.4.3", + "version": "1.5.0", "homepage": "https://remix.run", "license": "MIT", "repository": { diff --git a/scripts/deployment-test/package.json b/scripts/deployment-test/package.json index 7275060ae81..fc0bf074a44 100644 --- a/scripts/deployment-test/package.json +++ b/scripts/deployment-test/package.json @@ -10,7 +10,7 @@ "@cloudflare/wrangler": "latest", "@iarna/toml": "^2.2.5", "@octokit/rest": "^18.12.0", - "@remix-run/dev": "1.4.3", + "@remix-run/dev": "1.5.0", "@testing-library/cypress": "^8.0.2", "aws-sdk": "^2.1055.0", "cypress": "9.2.0", diff --git a/scripts/playground/template/app/routes/join.tsx b/scripts/playground/template/app/routes/join.tsx index 618e8239df6..bae18b1f345 100644 --- a/scripts/playground/template/app/routes/join.tsx +++ b/scripts/playground/template/app/routes/join.tsx @@ -9,7 +9,7 @@ import * as React from "react"; import { getUserId, createUserSession } from "~/session.server"; import { createUser, getUserByEmail } from "~/models/user.server"; -import { validateEmail } from "~/utils"; +import { safeRedirect, validateEmail } from "~/utils"; export const loader: LoaderFunction = async ({ request }) => { let userId = await getUserId(request); @@ -28,7 +28,7 @@ export const action: ActionFunction = async ({ request }) => { let formData = await request.formData(); let email = formData.get("email"); let password = formData.get("password"); - let redirectTo = formData.get("redirectTo"); + let redirectTo = safeRedirect(formData.get("redirectTo"), "/"); if (!validateEmail(email)) { return json( @@ -65,7 +65,7 @@ export const action: ActionFunction = async ({ request }) => { request, userId: user.id, remember: false, - redirectTo: typeof redirectTo === "string" ? redirectTo : "/", + redirectTo, }); }; diff --git a/scripts/playground/template/app/routes/login.tsx b/scripts/playground/template/app/routes/login.tsx index 5be68058dfe..e399fe7f065 100644 --- a/scripts/playground/template/app/routes/login.tsx +++ b/scripts/playground/template/app/routes/login.tsx @@ -7,9 +7,9 @@ import { json, redirect } from "@remix-run/node"; import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; import * as React from "react"; -import { createUserSession, getUserId } from "~/session.server"; import { verifyLogin } from "~/models/user.server"; -import { validateEmail } from "~/utils"; +import { createUserSession, getUserId } from "~/session.server"; +import { safeRedirect, validateEmail } from "~/utils"; export const loader: LoaderFunction = async ({ request }) => { let userId = await getUserId(request); @@ -28,7 +28,7 @@ export const action: ActionFunction = async ({ request }) => { let formData = await request.formData(); let email = formData.get("email"); let password = formData.get("password"); - let redirectTo = formData.get("redirectTo"); + let redirectTo = safeRedirect(formData.get("redirectTo"), "/notes"); let remember = formData.get("remember"); if (!validateEmail(email)) { @@ -65,7 +65,7 @@ export const action: ActionFunction = async ({ request }) => { request, userId: user.id, remember: remember === "on" ? true : false, - redirectTo: typeof redirectTo === "string" ? redirectTo : "/notes", + redirectTo, }); }; diff --git a/scripts/playground/template/app/session.server.ts b/scripts/playground/template/app/session.server.ts index 2dd31bc1a5b..87aabcde0d0 100644 --- a/scripts/playground/template/app/session.server.ts +++ b/scripts/playground/template/app/session.server.ts @@ -25,13 +25,15 @@ export async function getSession(request: Request) { return sessionStorage.getSession(cookie); } -export async function getUserId(request: Request): Promise { +export async function getUserId( + request: Request +): Promise { let session = await getSession(request); let userId = session.get(USER_SESSION_KEY); return userId; } -export async function getUser(request: Request): Promise { +export async function getUser(request: Request) { let userId = await getUserId(request); if (userId === undefined) return null; @@ -44,7 +46,7 @@ export async function getUser(request: Request): Promise { export async function requireUserId( request: Request, redirectTo: string = new URL(request.url).pathname -): Promise { +) { let userId = await getUserId(request); if (!userId) { let searchParams = new URLSearchParams([["redirectTo", redirectTo]]); diff --git a/scripts/playground/template/app/utils.ts b/scripts/playground/template/app/utils.ts index b85838f4e40..60a61a5bd13 100644 --- a/scripts/playground/template/app/utils.ts +++ b/scripts/playground/template/app/utils.ts @@ -3,6 +3,30 @@ import { useMemo } from "react"; import type { User } from "~/models/user.server"; +const DEFAULT_REDIRECT = "/"; + +/** + * This should be used any time the redirect path is user-provided + * (Like the query string on our login/signup pages). This avoids + * open-redirect vulnerabilities. + * @param {string} to The redirect destination + * @param {string} defaultRedirect The redirect to use if the to is unsafe. + */ +export function safeRedirect( + to: FormDataEntryValue | string | null | undefined, + defaultRedirect: string = DEFAULT_REDIRECT +) { + if (!to || typeof to !== "string") { + return defaultRedirect; + } + + if (!to.startsWith("/") || to.startsWith("//")) { + return defaultRedirect; + } + + return to; +} + /** * This base hook is used in other hooks to quickly search for specific data * across all loader data using useMatches. diff --git a/scripts/playground/template/package.json b/scripts/playground/template/package.json index 781bfd4da7b..d4512f0b20f 100644 --- a/scripts/playground/template/package.json +++ b/scripts/playground/template/package.json @@ -22,36 +22,36 @@ "/public/build" ], "dependencies": { - "@prisma/client": "^3.12.0", - "@remix-run/express": "^1.3.4", - "@remix-run/node": "^1.3.4", - "@remix-run/react": "^1.3.4", - "@remix-run/serve": "^1.3.4", - "@remix-run/server-runtime": "^1.3.4", + "@prisma/client": "^3.13.0", + "@remix-run/express": "^1.4.3", + "@remix-run/node": "^1.4.3", + "@remix-run/react": "^1.4.3", + "@remix-run/serve": "^1.4.3", + "@remix-run/server-runtime": "^1.4.3", "bcryptjs": "^2.4.3", - "express": "^4.17.3", + "express": "^4.18.1", "get-port": "^6.1.2", "morgan": "^1.10.0", - "react": "^18.0.0", - "react-dom": "^18.0.0", + "react": "^18.1.0", + "react-dom": "^18.1.0", "tiny-invariant": "^1.2.0" }, "devDependencies": { - "@faker-js/faker": "^6.1.2", - "@remix-run/dev": "^1.3.4", + "@faker-js/faker": "^6.3.1", + "@remix-run/dev": "^1.4.3", "@types/bcryptjs": "^2.4.2", - "@types/node": "^17.0.23", - "@types/react": "^17.0.43", - "@types/react-dom": "^17.0.14", - "autoprefixer": "^10.4.4", + "@types/node": "^17.0.31", + "@types/react": "^17.0.44", + "@types/react-dom": "^17.0.16", + "autoprefixer": "^10.4.7", "cross-env": "^7.0.3", "npm-run-all": "^4.1.5", - "postcss": "^8.4.12", - "prisma": "^3.12.0", - "tailwindcss": "^3.0.23", + "postcss": "^8.4.13", + "prisma": "^3.13.0", + "tailwindcss": "^3.0.24", "ts-node": "^10.7.0", - "tsconfig-paths": "^3.14.1", - "typescript": "^4.6.3" + "tsconfig-paths": "^4.0.0", + "typescript": "^4.6.4" }, "engines": { "node": ">=14" diff --git a/scripts/playground/template/remix.config.js b/scripts/playground/template/remix.config.js index a83517ba5f8..457709942de 100644 --- a/scripts/playground/template/remix.config.js +++ b/scripts/playground/template/remix.config.js @@ -3,5 +3,5 @@ */ module.exports = { cacheDirectory: "./node_modules/.cache/remix", - ignoredRouteFiles: [".*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"], + ignoredRouteFiles: ["**/.*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"], }; diff --git a/scripts/release/comment.mjs b/scripts/release/comment.mjs index 0f00e9d3df1..2e965770423 100644 --- a/scripts/release/comment.mjs +++ b/scripts/release/comment.mjs @@ -37,7 +37,10 @@ async function commentOnIssuesAndPrsAboutRelease() { }) ); - let issuesClosed = await getIssuesClosedByPullRequests(pr.html_url); + let issuesClosed = await getIssuesClosedByPullRequests( + pr.html_url, + pr.body + ); for (let issue of issuesClosed) { if (issuesCommentedOn.has(issue.number)) { diff --git a/scripts/release/octokit.mjs b/scripts/release/octokit.mjs index 82c23e3ae53..4496f40f302 100644 --- a/scripts/release/octokit.mjs +++ b/scripts/release/octokit.mjs @@ -123,27 +123,64 @@ export async function commentOnIssue({ owner, repo, issue, version }) { }); } -export async function getIssuesClosedByPullRequests(prHtmlUrl) { +async function getIssuesLinkedToPullRequest(prHtmlUrl, nodes = [], after) { let res = await graphqlWithAuth( gql` - query GET_ISSUES_CLOSED_BY_PR($prHtmlUrl: URI!) { + query GET_ISSUES_CLOSED_BY_PR($prHtmlUrl: URI!, $after: String) { resource(url: $prHtmlUrl) { ... on PullRequest { - closingIssuesReferences(first: 100) { + closingIssuesReferences(first: 100, after: $after) { nodes { number } + pageInfo { + hasNextPage + endCursor + } } } } } `, - { prHtmlUrl } + { prHtmlUrl, after } ); - return res?.resource?.closingIssuesReferences?.nodes ?? []; + let newNodes = res?.resource?.closingIssuesReferences?.nodes ?? []; + nodes.push(...newNodes); + + if (res?.resource?.closingIssuesReferences?.pageInfo?.hasNextPage) { + return getIssuesLinkedToPullRequest( + prHtmlUrl, + nodes, + res?.resource?.closingIssuesReferences?.pageInfo?.endCursor + ); + } + + return nodes; +} + +export async function getIssuesClosedByPullRequests(prHtmlUrl, prBody) { + let linked = await getIssuesLinkedToPullRequest(prHtmlUrl); + if (!prBody) return linked; + + /** + * This regex matches for one of github's issue references for auto linking an issue to a PR + * as that only happens when the PR is sent to the default branch of the repo + * https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword + */ + let regex = + /(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)\s#([0-9]+)/gi; + let matches = prBody.match(regex); + if (!matches) return linked; + + let issues = matches.map((match) => { + let [, issueNumber] = match.split(" #"); + return { number: parseInt(issueNumber, 10) }; + }); + + return [...linked, ...issues.filter((issue) => issue !== null)]; } -function checkIfStringStartsWith(str, substrs) { - return substrs.some((substr) => str.startsWith(substr)); +function checkIfStringStartsWith(string, substrings) { + return substrings.some((substr) => string.startsWith(substr)); } diff --git a/templates/.gitignore b/templates/.gitignore new file mode 100644 index 00000000000..00da837c3fc --- /dev/null +++ b/templates/.gitignore @@ -0,0 +1,4 @@ +package-lock.json +yarn.lock +pnpm-lock.yaml +pnpm-lock.yml diff --git a/templates/arc/package.json b/templates/arc/package.json index b19eb4e0b99..def1228f627 100644 --- a/templates/arc/package.json +++ b/templates/arc/package.json @@ -22,11 +22,11 @@ "devDependencies": { "@remix-run/dev": "*", "@remix-run/eslint-config": "*", - "@types/react": "^17.0.24", - "@types/react-dom": "^17.0.9", - "eslint": "^8.11.0", + "@types/react": "^17.0.45", + "@types/react-dom": "^17.0.17", + "eslint": "^8.15.0", "npm-run-all": "^4.1.5", - "typescript": "^4.5.5" + "typescript": "^4.6.4" }, "engines": { "node": ">=14" diff --git a/templates/arc/tsconfig.json b/templates/arc/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/templates/arc/tsconfig.json +++ b/templates/arc/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/templates/cloudflare-pages/.gitignore b/templates/cloudflare-pages/.gitignore index e42f442a59f..ccbe641499b 100644 --- a/templates/cloudflare-pages/.gitignore +++ b/templates/cloudflare-pages/.gitignore @@ -2,5 +2,6 @@ node_modules /.cache /functions/\[\[path\]\].js +/functions/\[\[path\]\].js.map /public/build .env diff --git a/templates/cloudflare-pages/package.json b/templates/cloudflare-pages/package.json index 0f040fbeea2..51a979fe3e8 100644 --- a/templates/cloudflare-pages/package.json +++ b/templates/cloudflare-pages/package.json @@ -20,14 +20,14 @@ "react-dom": "^17.0.2" }, "devDependencies": { - "@cloudflare/workers-types": "^3.4.0", + "@cloudflare/workers-types": "^3.10.0", "@remix-run/dev": "*", "@remix-run/eslint-config": "*", - "@types/react": "^17.0.24", - "@types/react-dom": "^17.0.9", - "eslint": "^8.11.0", + "@types/react": "^17.0.45", + "@types/react-dom": "^17.0.17", + "eslint": "^8.15.0", "npm-run-all": "^4.1.5", - "typescript": "^4.5.5", + "typescript": "^4.6.4", "wrangler": "beta" }, "engines": { diff --git a/templates/cloudflare-pages/tsconfig.json b/templates/cloudflare-pages/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/templates/cloudflare-pages/tsconfig.json +++ b/templates/cloudflare-pages/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/templates/cloudflare-workers/package.json b/templates/cloudflare-workers/package.json index 77f3db4f4ba..91004b9cdfb 100644 --- a/templates/cloudflare-workers/package.json +++ b/templates/cloudflare-workers/package.json @@ -22,15 +22,15 @@ "react-dom": "^17.0.2" }, "devDependencies": { - "@cloudflare/workers-types": "^3.4.0", + "@cloudflare/workers-types": "^3.10.0", "@remix-run/dev": "*", "@remix-run/eslint-config": "*", - "@types/react": "^17.0.24", - "@types/react-dom": "^17.0.9", - "eslint": "^8.11.0", - "miniflare": "^2.1.0", + "@types/react": "^17.0.45", + "@types/react-dom": "^17.0.17", + "eslint": "^8.15.0", + "miniflare": "^2.4.0", "npm-run-all": "^4.1.5", - "typescript": "^4.5.5" + "typescript": "^4.6.4" }, "engines": { "node": ">=14" diff --git a/templates/cloudflare-workers/tsconfig.json b/templates/cloudflare-workers/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/templates/cloudflare-workers/tsconfig.json +++ b/templates/cloudflare-workers/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/templates/express/README.md b/templates/express/README.md index 4b7950d7666..a456eb3b67d 100644 --- a/templates/express/README.md +++ b/templates/express/README.md @@ -4,9 +4,7 @@ ## Development -You'll need to run two terminals (or bring in a process manager like concurrently/pm2-dev if you like): - -Start the Remix development asset server +Start the Remix development asset server and the Express server by running: ```sh npm run dev @@ -34,7 +32,7 @@ Now you'll need to pick a host to deploy it to. If you're familiar with deploying express applications you should be right at home just make sure to deploy the output of `remix build` -- `server/build/` +- `build/` - `public/build/` ### Using a Template diff --git a/templates/express/package.json b/templates/express/package.json index a6bb8c23bff..94be5507d70 100644 --- a/templates/express/package.json +++ b/templates/express/package.json @@ -17,7 +17,7 @@ "@remix-run/react": "*", "compression": "^1.7.4", "cross-env": "^7.0.3", - "express": "^4.17.1", + "express": "^4.18.1", "morgan": "^1.10.0", "react": "^17.0.2", "react-dom": "^17.0.2" @@ -25,12 +25,12 @@ "devDependencies": { "@remix-run/dev": "*", "@remix-run/eslint-config": "*", - "@types/react": "^17.0.24", - "@types/react-dom": "^17.0.9", - "eslint": "^8.11.0", - "nodemon": "^2.0.15", + "@types/react": "^17.0.45", + "@types/react-dom": "^17.0.17", + "eslint": "^8.15.0", + "nodemon": "^2.0.16", "npm-run-all": "^4.1.5", - "typescript": "^4.5.5" + "typescript": "^4.6.4" }, "engines": { "node": ">=14" diff --git a/templates/express/tsconfig.json b/templates/express/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/templates/express/tsconfig.json +++ b/templates/express/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/templates/fly/package.json b/templates/fly/package.json index fdf3318f029..1762275681a 100644 --- a/templates/fly/package.json +++ b/templates/fly/package.json @@ -20,10 +20,10 @@ "devDependencies": { "@remix-run/dev": "*", "@remix-run/eslint-config": "*", - "@types/react": "^17.0.24", - "@types/react-dom": "^17.0.9", - "eslint": "^8.11.0", - "typescript": "^4.5.5" + "@types/react": "^17.0.45", + "@types/react-dom": "^17.0.17", + "eslint": "^8.15.0", + "typescript": "^4.6.4" }, "engines": { "node": ">=14" diff --git a/templates/fly/tsconfig.json b/templates/fly/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/templates/fly/tsconfig.json +++ b/templates/fly/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/templates/netlify/package.json b/templates/netlify/package.json index 7c397a2bc71..57ca550dc46 100644 --- a/templates/netlify/package.json +++ b/templates/netlify/package.json @@ -21,10 +21,10 @@ "devDependencies": { "@remix-run/dev": "*", "@remix-run/eslint-config": "*", - "@types/react": "^17.0.24", - "@types/react-dom": "^17.0.9", - "eslint": "^8.11.0", - "typescript": "^4.5.5" + "@types/react": "^17.0.45", + "@types/react-dom": "^17.0.17", + "eslint": "^8.15.0", + "typescript": "^4.6.4" }, "engines": { "node": ">=14" diff --git a/templates/netlify/tsconfig.json b/templates/netlify/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/templates/netlify/tsconfig.json +++ b/templates/netlify/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/templates/remix/package.json b/templates/remix/package.json index eac950d897c..2c7bc997b78 100644 --- a/templates/remix/package.json +++ b/templates/remix/package.json @@ -19,10 +19,10 @@ "devDependencies": { "@remix-run/dev": "*", "@remix-run/eslint-config": "*", - "@types/react": "^17.0.24", - "@types/react-dom": "^17.0.9", - "eslint": "^8.11.0", - "typescript": "^4.5.5" + "@types/react": "^17.0.45", + "@types/react-dom": "^17.0.17", + "eslint": "^8.15.0", + "typescript": "^4.6.4" }, "engines": { "node": ">=14" diff --git a/templates/remix/tsconfig.json b/templates/remix/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/templates/remix/tsconfig.json +++ b/templates/remix/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"] diff --git a/templates/vercel/package.json b/templates/vercel/package.json index 7e73a9601a6..d3c07a2c9e9 100644 --- a/templates/vercel/package.json +++ b/templates/vercel/package.json @@ -12,7 +12,7 @@ "@remix-run/node": "*", "@remix-run/react": "*", "@remix-run/vercel": "*", - "@vercel/node": "^1.14.0", + "@vercel/node": "^1.15.2", "react": "^17.0.2", "react-dom": "^17.0.2" }, @@ -20,10 +20,10 @@ "@remix-run/dev": "*", "@remix-run/eslint-config": "*", "@remix-run/serve": "*", - "@types/react": "^17.0.24", - "@types/react-dom": "^17.0.9", - "eslint": "^8.11.0", - "typescript": "^4.5.5" + "@types/react": "^17.0.45", + "@types/react-dom": "^17.0.17", + "eslint": "^8.15.0", + "typescript": "^4.6.4" }, "engines": { "node": ">=14" diff --git a/templates/vercel/tsconfig.json b/templates/vercel/tsconfig.json index 749e4b959e8..20f8a386a6c 100644 --- a/templates/vercel/tsconfig.json +++ b/templates/vercel/tsconfig.json @@ -9,6 +9,8 @@ "resolveJsonModule": true, "target": "ES2019", "strict": true, + "allowJs": true, + "forceConsistentCasingInFileNames": true, "baseUrl": ".", "paths": { "~/*": ["./app/*"]