Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: optional entry.{client,server} for react 17 #5681

Merged
merged 12 commits into from
Mar 21, 2023
6 changes: 6 additions & 0 deletions .changeset/optional-entries-react-17.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"remix": patch
"@remix-run/dev": patch
---

add optional entry file support for React 17
15 changes: 1 addition & 14 deletions docs/file-conventions/entry.client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<RemixBrowser />, 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
42 changes: 1 addition & 41 deletions docs/file-conventions/entry.server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<RemixServer>` 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(
<RemixServer context={remixContext} url={request.url} />
);

responseHeaders.set("Content-Type", "text/html");

return new Response("<!DOCTYPE html>" + 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
76 changes: 74 additions & 2 deletions integration/server-entry-test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 <p>Hello World</p>
}
`,
},
});
});

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

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 <p>Hello World</p>
}
`,
"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("<p>Hello World</p>");
});
});
37 changes: 30 additions & 7 deletions packages/remix-dev/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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}`)
Expand All @@ -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"]
Expand All @@ -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}`
Expand All @@ -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`
Expand All @@ -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";
Expand Down
Loading