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 () { + returnHello 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( +