diff --git a/.changeset/optional-entries-react-17.md b/.changeset/optional-entries-react-17.md new file mode 100644 index 00000000000..9194b7e1939 --- /dev/null +++ b/.changeset/optional-entries-react-17.md @@ -0,0 +1,6 @@ +--- +"remix": patch +"@remix-run/dev": patch +--- + +add optional entry file support for React 17 diff --git a/docs/file-conventions/entry.client.md b/docs/file-conventions/entry.client.md index aee58581922..2eea45f64aa 100644 --- a/docs/file-conventions/entry.client.md +++ b/docs/file-conventions/entry.client.md @@ -5,19 +5,6 @@ toc: false # entry.client -Remix uses `app/entry.client.tsx` (or `.jsx`) as the entry point for the browser bundle. This module gives you full control over the "hydrate" step after JavaScript loads into the document. - -Typically this module uses `ReactDOM.hydrate` to re-hydrate the markup that was already generated on the server in your [server entry module][server-entry-module]. - -Here's a basic example: - -```tsx -import { hydrate } from "react-dom"; -import { RemixBrowser } from "@remix-run/react"; - -hydrate(, document); -``` - -This is the first piece of code that runs in the browser. As you can see, you have full control here. You can initialize client side libraries, setup things like `window.history.scrollRestoration`, etc. +By default, Remix will handle hydrating your app on the client for you. If you want to customize this behavior, you can run `npx remix reveal` to generate a `app/entry.client.tsx` (or `.jsx`) that will take precedence. This file is the entry point for the browser and is responsible for hydrating the markup generated by the server in your [server entry module][server-entry-module], however you can also initialize any other client-side code here. [server-entry-module]: ./entry.server diff --git a/docs/file-conventions/entry.server.md b/docs/file-conventions/entry.server.md index a043e82da72..cc25b6729f5 100644 --- a/docs/file-conventions/entry.server.md +++ b/docs/file-conventions/entry.server.md @@ -5,50 +5,10 @@ toc: false # entry.server -Remix uses `app/entry.server.tsx` (or `.jsx`) to generate the HTTP response when rendering on the server. The `default` export of this module is a function that lets you create the response, including HTTP status, headers, and HTML, giving you full control over the way the markup is generated and sent to the client. +By default, Remix will handle generating the HTTP Response for you. If you want to customize this behavior, you can run `npx remix reveal` to generate a `app/entry.server.tsx` (or `.jsx`) that will take precedence. The `default` export of this module is a function that lets you create the response, including HTTP status, headers, and HTML, giving you full control over the way the markup is generated and sent to the client. This module should render the markup for the current page using a `` element with the `context` and `url` for the current request. This markup will (optionally) be re-hydrated once JavaScript loads in the browser using the [browser entry module][browser-entry-module]. You can also export an optional `handleDataRequest` function that will allow you to modify the response of a data request. These are the requests that do not render HTML, but rather return the loader and action data to the browser once client-side hydration has occurred. -Here's a basic example: - -```tsx -import { renderToString } from "react-dom/server"; -import type { - EntryContext, - HandleDataRequestFunction, -} from "@remix-run/node"; // or cloudflare/deno -import { RemixServer } from "@remix-run/react"; - -export default function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext -) { - const markup = renderToString( - - ); - - responseHeaders.set("Content-Type", "text/html"); - - return new Response("" + markup, { - status: responseStatusCode, - headers: responseHeaders, - }); -} - -// this is an optional export -export const handleDataRequest: HandleDataRequestFunction = - ( - response: Response, - // same args that get passed to the action or loader that was called - { request, params, context } - ) => { - response.headers.set("x-custom", "yay!"); - return response; - }; -``` - [browser-entry-module]: ./entry.client diff --git a/integration/server-entry-test.ts b/integration/server-entry-test.ts index 32be4580a36..477880f5939 100644 --- a/integration/server-entry-test.ts +++ b/integration/server-entry-test.ts @@ -1,9 +1,10 @@ import { test, expect } from "@playwright/test"; -import { createFixture, js } from "./helpers/create-fixture"; +import { createFixture, js, json } from "./helpers/create-fixture"; import type { Fixture } from "./helpers/create-fixture"; +import { selectHtml } from "./helpers/playwright-fixture"; -test.describe("Server Entry", () => { +test.describe("Custom Server Entry", () => { let fixture: Fixture; let DATA_HEADER_NAME = "X-Macaroni-Salad"; @@ -41,3 +42,74 @@ test.describe("Server Entry", () => { expect(response.headers.get(DATA_HEADER_NAME)).toBe(DATA_HEADER_VALUE); }); }); + +test.describe("Default Server Entry", () => { + let fixture: Fixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/index.jsx": js` + export default function () { + return

Hello World

+ } + `, + }, + }); + }); + + test("renders", async () => { + let response = await fixture.requestDocument("/"); + expect(selectHtml(await response.text(), "p")).toBe("

Hello World

"); + }); +}); + +test.describe("Default Server Entry (React 17)", () => { + let fixture: Fixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "app/routes/index.jsx": js` + export default function () { + return

Hello World

+ } + `, + "package.json": json({ + name: "remix-template-remix", + private: true, + sideEffects: false, + scripts: { + build: + "node ../../../build/node_modules/@remix-run/dev/dist/cli.js build", + dev: "node ../../../build/node_modules/@remix-run/dev/dist/cli.js dev", + start: + "node ../../../build/node_modules/@remix-run/serve/dist/cli.js build", + }, + dependencies: { + "@remix-run/node": "0.0.0-local-version", + "@remix-run/react": "0.0.0-local-version", + "@remix-run/serve": "0.0.0-local-version", + isbot: "0.0.0-local-version", + react: "17.0.2", + "react-dom": "17.0.2", + }, + devDependencies: { + "@remix-run/dev": "0.0.0-local-version", + "@types/react": "0.0.0-local-version", + "@types/react-dom": "0.0.0-local-version", + typescript: "0.0.0-local-version", + }, + engines: { + node: ">=14", + }, + }), + }, + }); + }); + + test("renders", async () => { + let response = await fixture.requestDocument("/"); + expect(selectHtml(await response.text(), "p")).toBe("

Hello World

"); + }); +}); diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts index b284e6d1d52..5e006a11f8d 100644 --- a/packages/remix-dev/cli/commands.ts +++ b/packages/remix-dev/cli/commands.ts @@ -5,6 +5,7 @@ import ora from "ora"; import prettyMs from "pretty-ms"; import * as esbuild from "esbuild"; import NPMCliPackageJson from "@npmcli/package-json"; +import { coerce } from "semver"; import * as colors from "../colors"; import * as compiler from "../compiler"; @@ -257,11 +258,18 @@ let entries = ["entry.client", "entry.server"]; // @ts-expect-error available in node 12+ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat#browser_compatibility -let listFormat = new Intl.ListFormat("en", { +let conjunctionListFormat = new Intl.ListFormat("en", { style: "long", type: "conjunction", }); +// @ts-expect-error available in node 12+ +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat#browser_compatibility +let disjunctionListFormat = new Intl.ListFormat("en", { + style: "long", + type: "disjunction", +}); + export async function generateEntry( entry: string, remixRoot: string, @@ -278,7 +286,7 @@ export async function generateEntry( if (!entries.includes(entry)) { let entriesArray = Array.from(entries); - let list = listFormat.format(entriesArray); + let list = conjunctionListFormat.format(entriesArray); console.error( colors.error(`Invalid entry file. Valid entry files are ${list}`) @@ -289,6 +297,20 @@ export async function generateEntry( let pkgJson = await NPMCliPackageJson.load(config.rootDirectory); let deps = pkgJson.content.dependencies ?? {}; + let maybeReactVersion = coerce(deps.react); + if (!maybeReactVersion) { + let react = ["react", "react-dom"]; + let list = conjunctionListFormat.format(react); + throw new Error( + `Could not determine React version. Please install the following packages: ${list}` + ); + } + + let type = + maybeReactVersion.major >= 18 || maybeReactVersion.raw === "0.0.0" + ? ("stream" as const) + : ("string" as const); + let serverRuntime = deps["@remix-run/deno"] ? "deno" : deps["@remix-run/cloudflare"] @@ -303,7 +325,7 @@ export async function generateEntry( "@remix-run/cloudflare", "@remix-run/node", ]; - let formattedList = listFormat.format(serverRuntimes); + let formattedList = disjunctionListFormat.format(serverRuntimes); console.error( colors.error( `Could not determine server runtime. Please install one of the following: ${formattedList}` @@ -312,9 +334,9 @@ export async function generateEntry( return; } - let clientRuntime = deps["@remix-run/react"] ? "react" : undefined; + let clientRenderer = deps["@remix-run/react"] ? "react" : undefined; - if (!clientRuntime) { + if (!clientRenderer) { console.error( colors.error( `Could not determine runtime. Please install the following: @remix-run/react` @@ -326,11 +348,12 @@ export async function generateEntry( let defaultsDirectory = path.resolve(__dirname, "..", "config", "defaults"); let defaultEntryClient = path.resolve( defaultsDirectory, - `entry.client.${clientRuntime}.tsx` + `entry.client.${clientRenderer}-${type}.tsx` ); let defaultEntryServer = path.resolve( defaultsDirectory, - `entry.server.${serverRuntime}.tsx` + serverRuntime, + `entry.server.${clientRenderer}-${type}.tsx` ); let isServerEntry = entry === "entry.server"; diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index dcdab1dac61..df24ed46bdc 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -4,6 +4,7 @@ import { pathToFileURL } from "node:url"; import fse from "fs-extra"; import getPort from "get-port"; import NPMCliPackageJson from "@npmcli/package-json"; +import { coerce } from "semver"; import type { RouteManifest, DefineRoutesFunction } from "./config/routes"; import { defineRoutes } from "./config/routes"; @@ -471,7 +472,49 @@ export async function readConfig( if (userEntryServerFile) { entryServerFile = userEntryServerFile; } else { - if (!deps["isbot"]) { + let serverRuntime = deps["@remix-run/deno"] + ? "deno" + : deps["@remix-run/cloudflare"] + ? "cloudflare" + : deps["@remix-run/node"] + ? "node" + : undefined; + + if (!serverRuntime) { + let serverRuntimes = [ + "@remix-run/deno", + "@remix-run/cloudflare", + "@remix-run/node", + ]; + let formattedList = disjunctionListFormat.format(serverRuntimes); + throw new Error( + `Could not determine server runtime. Please install one of the following: ${formattedList}` + ); + } + + let clientRenderer = deps["@remix-run/react"] ? "react" : undefined; + + if (!clientRenderer) { + throw new Error( + `Could not determine renderer. Please install the following: @remix-run/react` + ); + } + + let maybeReactVersion = coerce(deps.react); + if (!maybeReactVersion) { + let react = ["react", "react-dom"]; + let list = conjunctionListFormat.format(react); + throw new Error( + `Could not determine React version. Please install the following packages: ${list}` + ); + } + + let type: "stream" | "string" = + maybeReactVersion.major >= 18 || maybeReactVersion.raw === "0.0.0" + ? "stream" + : "string"; + + if (!deps["isbot"] && type === "stream") { console.log( "adding `isbot` to your package.json, you should commit this change" ); @@ -493,41 +536,35 @@ export async function readConfig( }); } - let serverRuntime = deps["@remix-run/deno"] - ? "deno" - : deps["@remix-run/cloudflare"] - ? "cloudflare" - : deps["@remix-run/node"] - ? "node" - : undefined; - - if (!serverRuntime) { - let serverRuntimes = [ - "@remix-run/deno", - "@remix-run/cloudflare", - "@remix-run/node", - ]; - let formattedList = listFormat.format(serverRuntimes); - throw new Error( - `Could not determine server runtime. Please install one of the following: ${formattedList}` - ); - } - - entryServerFile = `entry.server.${serverRuntime}.tsx`; + entryServerFile = `${serverRuntime}/entry.server.${clientRenderer}-${type}.tsx`; } if (userEntryClientFile) { entryClientFile = userEntryClientFile; } else { - let clientRuntime = deps["@remix-run/react"] ? "react" : undefined; + let clientRenderer = deps["@remix-run/react"] ? "react" : undefined; - if (!clientRuntime) { + if (!clientRenderer) { throw new Error( `Could not determine runtime. Please install the following: @remix-run/react` ); } - entryClientFile = `entry.client.${clientRuntime}.tsx`; + let maybeReactVersion = coerce(deps.react); + if (!maybeReactVersion) { + let react = ["react", "react-dom"]; + let list = conjunctionListFormat.format(react); + throw new Error( + `Could not determine React version. Please install the following packages: ${list}` + ); + } + + let type: "stream" | "string" = + maybeReactVersion.major >= 18 || maybeReactVersion.raw === "0.0.0" + ? "stream" + : "string"; + + entryClientFile = `entry.client.${clientRenderer}-${type}.tsx`; } let entryClientFilePath = userEntryClientFile @@ -738,13 +775,43 @@ const resolveServerBuildPath = ( return path.resolve(rootDirectory, serverBuildPath); }; -// @ts-expect-error available in node 12+ -// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/ListFormat#browser_compatibility -let listFormat = new Intl.ListFormat("en", { +// adds types for `Intl.ListFormat` to the global namespace +// we could also update our `tsconfig.json` to include `lib: ["es2021"]` +declare namespace Intl { + type ListType = "conjunction" | "disjunction"; + + interface ListFormatOptions { + localeMatcher?: "lookup" | "best fit"; + type?: ListType; + style?: "long" | "short" | "narrow"; + } + + interface ListFormatPart { + type: "element" | "literal"; + value: string; + } + + class ListFormat { + constructor(locales?: string | string[], options?: ListFormatOptions); + format(values: any[]): string; + formatToParts(values: any[]): ListFormatPart[]; + supportedLocalesOf( + locales: string | string[], + options?: ListFormatOptions + ): string[]; + } +} + +let conjunctionListFormat = new Intl.ListFormat("en", { style: "long", type: "conjunction", }); +let disjunctionListFormat = new Intl.ListFormat("en", { + style: "long", + type: "disjunction", +}); + export let browserBuildDirectoryWarning = "⚠️ DEPRECATED: The `browserBuildDirectory` config option is deprecated. " + "Use `assetsBuildDirectory` instead."; diff --git a/packages/remix-dev/config/defaults/entry.server.cloudflare.tsx b/packages/remix-dev/config/defaults/cloudflare/entry.server.react-stream.tsx similarity index 100% rename from packages/remix-dev/config/defaults/entry.server.cloudflare.tsx rename to packages/remix-dev/config/defaults/cloudflare/entry.server.react-stream.tsx diff --git a/packages/remix-dev/config/defaults/cloudflare/entry.server.react-string.tsx b/packages/remix-dev/config/defaults/cloudflare/entry.server.react-string.tsx new file mode 100644 index 00000000000..081e0d69a8e --- /dev/null +++ b/packages/remix-dev/config/defaults/cloudflare/entry.server.react-string.tsx @@ -0,0 +1,21 @@ +import type { EntryContext } from "@remix-run/cloudflare"; +import { renderToString } from "react-dom/server"; +import { RemixServer } from "@remix-run/react"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + let markup = renderToString( + + ); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/packages/remix-dev/config/defaults/entry.server.deno.tsx b/packages/remix-dev/config/defaults/deno/entry.server.react-stream.tsx similarity index 100% rename from packages/remix-dev/config/defaults/entry.server.deno.tsx rename to packages/remix-dev/config/defaults/deno/entry.server.react-stream.tsx diff --git a/packages/remix-dev/config/defaults/deno/entry.server.react-string.tsx b/packages/remix-dev/config/defaults/deno/entry.server.react-string.tsx new file mode 100644 index 00000000000..98b57994bf3 --- /dev/null +++ b/packages/remix-dev/config/defaults/deno/entry.server.react-string.tsx @@ -0,0 +1,21 @@ +import type { EntryContext } from "@remix-run/deno"; +import { renderToString } from "react-dom/server"; +import { RemixServer } from "@remix-run/react"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + let markup = renderToString( + + ); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/packages/remix-dev/config/defaults/entry.client.react.tsx b/packages/remix-dev/config/defaults/entry.client.react-stream.tsx similarity index 100% rename from packages/remix-dev/config/defaults/entry.client.react.tsx rename to packages/remix-dev/config/defaults/entry.client.react-stream.tsx diff --git a/packages/remix-dev/config/defaults/entry.client.react-string.tsx b/packages/remix-dev/config/defaults/entry.client.react-string.tsx new file mode 100644 index 00000000000..3eec1fd0a02 --- /dev/null +++ b/packages/remix-dev/config/defaults/entry.client.react-string.tsx @@ -0,0 +1,4 @@ +import { RemixBrowser } from "@remix-run/react"; +import { hydrate } from "react-dom"; + +hydrate(, document); diff --git a/packages/remix-dev/config/defaults/entry.server.node.tsx b/packages/remix-dev/config/defaults/node/entry.server.react-stream.tsx similarity index 100% rename from packages/remix-dev/config/defaults/entry.server.node.tsx rename to packages/remix-dev/config/defaults/node/entry.server.react-stream.tsx diff --git a/packages/remix-dev/config/defaults/node/entry.server.react-string.tsx b/packages/remix-dev/config/defaults/node/entry.server.react-string.tsx new file mode 100644 index 00000000000..30f77ec2f33 --- /dev/null +++ b/packages/remix-dev/config/defaults/node/entry.server.react-string.tsx @@ -0,0 +1,21 @@ +import type { EntryContext } from "@remix-run/node"; +import { renderToString } from "react-dom/server"; +import { RemixServer } from "@remix-run/react"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + let markup = renderToString( + + ); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +}