diff --git a/.changeset/async-load-context.md b/.changeset/async-load-context.md new file mode 100644 index 00000000000..334006fc0d9 --- /dev/null +++ b/.changeset/async-load-context.md @@ -0,0 +1,12 @@ +--- +"remix": patch +"@remix-run/architect": patch +"@remix-run/cloudflare": patch +"@remix-run/cloudflare-pages": patch +"@remix-run/cloudflare-workers": patch +"@remix-run/express": patch +"@remix-run/netlify": patch +"@remix-run/vercel": patch +--- + +feat: support async `getLoadContext` in all adapters diff --git a/.changeset/cjs-esm-warning.md b/.changeset/cjs-esm-warning.md new file mode 100644 index 00000000000..404c2f4b754 --- /dev/null +++ b/.changeset/cjs-esm-warning.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": patch +--- + +add warning for v2 "cjs"->"esm" serverModuleFormat default change diff --git a/.changeset/dev-server.md b/.changeset/dev-server.md new file mode 100644 index 00000000000..eba6d7aa674 --- /dev/null +++ b/.changeset/dev-server.md @@ -0,0 +1,144 @@ +--- +"@remix-run/dev": minor +"@remix-run/server-runtime": minor +--- + +Dev server improvements + +- Push-based app server syncing that doesn't rely on polling +- App server as a managed subprocess +- Gracefully handle new files and routes without crashing +- Statically serve static assets to avoid fetch errors during app server reboots + +# Guide + +Enable `unstable_dev` in `remix.config.js`: + +```js +{ + future: { + "unstable_dev": true + } +} +``` + +## Remix App Server + +Update `package.json` scripts + +```json +{ + "scripts": { + "dev": "remix dev" + } +} +``` + +That's it! + +```sh +npm run dev +``` + +## Other app servers + +Update `package.json` scripts, specifying the command to run you app server with the `-c`/`--command` flag: + +```json +{ + "scripts": { + "dev": "remix dev -c 'node ./server.js'" + } +} +``` + +Then, call `devReady` in your server when its up and running. + +For example, an Express server would call `devReady` at the end of `listen`: + +```js +// +import { devReady } from "@remix-run/node"; + +// Path to Remix's server build directory ('build/' by default) +let BUILD_DIR = path.join(process.cwd(), "build"); + +// + +app.listen(3000, () => { + let build = require(BUILD_DIR); + console.log("Ready: http://localhost:" + port); + + // in development, call `devReady` _after_ your server is up and running + if (process.env.NODE_ENV === "development") { + devReady(build); + } +}); +``` + +That's it! + +```sh +npm run dev +``` + +# Configuration + +Most users won't need to configure the dev server, but you might need to if: + +- You are setting up custom origins for SSL support or for Docker networking +- You want to handle server updates yourself (e.g. via require cache purging) + +```js +{ + future: { + unstable_dev: { + // Command to run your app server + command: "wrangler", // default: `remix-serve ./build` + // HTTP(S) scheme used when sending `devReady` messages to the dev server + httpScheme: "https", // default: `"http"` + // HTTP(S) host used when sending `devReady` messages to the dev server + httpHost: "mycustomhost", // default: `"localhost"` + // HTTP(S) port internally used by the dev server to statically serve built assets and to receive app server `devReady` messages + httpPort: 8001, // default: Remix chooses an open port in the range 3001-3099 + // Websocket port internally used by the dev server for sending updates to the browser (Live reload, HMR, HDR) + websocketPort: 8002, // default: Remix chooses an open port in the range 3001-3099 + // Whether the app server should be restarted when app is rebuilt + // See `Advanced > restart` for more + restart: false, // default: `true` + } + } +} +``` + +You can also configure via flags. For example: + +```sh +remix dev -c 'nodemon ./server.mjs' --http-port=3001 --websocket-port=3002 --no-restart +``` + +See `remix dev --help` for more details. + +### restart + +If you want to manage app server updates yourself, you can use the `--no-restart` flag so that the Remix dev server doesn't restart the app server subprocess when a rebuild succeeds. + +For example, if you rely on require cache purging to keep your app server running while server changes are pulled in, then you'll want to use `--no-restart`. + +🚨 It is then your responsibility to call `devReady` whenever server changes are incorporated in your app server. 🚨 + +So for require cache purging, you'd want to: + +1. Purge the require cache +2. `require` your server build +3. Call `devReady` within a `if (process.env.NODE_ENV === 'development')` + +([Looking at you, Kent](https://github.com/kentcdodds/kentcdodds.com/blob/main/server/index.ts#L298) 😆) + +--- + +The ultimate solution for `--no-restart` would be for you to implement _server-side_ HMR for your app server. +Note: server-side HMR is not to be confused with the client-side HMR provided by Remix. +Then your app server could continuously update itself with new build with 0 downtime and without losing in-memory data that wasn't affected by the server changes. + +This is left as an exercise to the reader. diff --git a/.changeset/empty-taxis-call.md b/.changeset/empty-taxis-call.md new file mode 100644 index 00000000000..65bfc9b0de8 --- /dev/null +++ b/.changeset/empty-taxis-call.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": patch +--- + +Use correct require context in bareImports plugin. diff --git a/.changeset/express-template-esm.md b/.changeset/express-template-esm.md new file mode 100644 index 00000000000..e07428f3d23 --- /dev/null +++ b/.changeset/express-template-esm.md @@ -0,0 +1,5 @@ +--- +"create-remix": patch +--- + +update express template to output ESM and use the new dev command diff --git a/.changeset/hdr-revalidate-only-when-needed.md b/.changeset/hdr-revalidate-only-when-needed.md new file mode 100644 index 00000000000..38690e4e31c --- /dev/null +++ b/.changeset/hdr-revalidate-only-when-needed.md @@ -0,0 +1,5 @@ +--- +"@remix-run/react": patch +--- + +Revalidate loaders only when a change to one is detected. diff --git a/.changeset/install-globals.md b/.changeset/install-globals.md new file mode 100644 index 00000000000..7741b31d2ba --- /dev/null +++ b/.changeset/install-globals.md @@ -0,0 +1,6 @@ +--- +"@remix-run/node": patch +"@remix-run/serve": patch +--- + +add `@remix-run/node/install` side-effect to allow `node --require @remix-run/node/install` diff --git a/.changeset/link-meta-short-circuit.md b/.changeset/link-meta-short-circuit.md new file mode 100644 index 00000000000..c7d925616d5 --- /dev/null +++ b/.changeset/link-meta-short-circuit.md @@ -0,0 +1,5 @@ +--- +"@remix-run/react": patch +--- + +short circuit links and meta for routes that are not rendered due to errors diff --git a/.changeset/pink-walls-hide.md b/.changeset/pink-walls-hide.md new file mode 100644 index 00000000000..4e69d0914df --- /dev/null +++ b/.changeset/pink-walls-hide.md @@ -0,0 +1,5 @@ +--- +"create-remix": patch +--- + +enable unstable_dev by default in the remix template diff --git a/.changeset/remove-use-sync-external-store.md b/.changeset/remove-use-sync-external-store.md new file mode 100644 index 00000000000..0871ea2b5d7 --- /dev/null +++ b/.changeset/remove-use-sync-external-store.md @@ -0,0 +1,5 @@ +--- +"@remix-run/react": patch +--- + +Update Remix for React Router no longer relying on `useSyncExternalStore` diff --git a/.changeset/resource-route-boundary.md b/.changeset/resource-route-boundary.md new file mode 100644 index 00000000000..945cf193289 --- /dev/null +++ b/.changeset/resource-route-boundary.md @@ -0,0 +1,5 @@ +--- +"@remix-run/react": patch +--- + +Fix false-positive resource route identification if a route only exports a boundary diff --git a/.changeset/splat-index-matching.md b/.changeset/splat-index-matching.md new file mode 100644 index 00000000000..3cca3d0c23c --- /dev/null +++ b/.changeset/splat-index-matching.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": patch +--- + +Fix a bug in route matching that wass preventing a single splat (`$.jsx`) route from matching a root `/` path diff --git a/.changeset/twelve-cheetahs-drive.md b/.changeset/twelve-cheetahs-drive.md new file mode 100644 index 00000000000..9b8892eabe0 --- /dev/null +++ b/.changeset/twelve-cheetahs-drive.md @@ -0,0 +1,5 @@ +--- +"@remix-run/server-runtime": patch +--- + +pass `AppLoadContext` to `handleRequest` diff --git a/.changeset/two-pumpkins-roll.md b/.changeset/two-pumpkins-roll.md new file mode 100644 index 00000000000..26f0f2c7eb3 --- /dev/null +++ b/.changeset/two-pumpkins-roll.md @@ -0,0 +1,5 @@ +--- +"@remix-run/node": patch +--- + +add missing files to published package diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f51a918858d..cbc5ffff3eb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -24,14 +24,15 @@ jobs: node-version-file: ".nvmrc" cache: "yarn" - - name: 📥 Install deps - run: yarn --frozen-lockfile - - - name: Disable Eslint GitHub Actions Annotations + - name: Disable GitHub Actions Annotations run: | + echo "::remove-matcher owner=tsc::" echo "::remove-matcher owner=eslint-compact::" echo "::remove-matcher owner=eslint-stylish::" + - name: 📥 Install deps + run: yarn --frozen-lockfile + - name: 🔬 Lint run: yarn lint diff --git a/.github/workflows/reusable-test.yml b/.github/workflows/reusable-test.yml index 687a9053748..01f87c177c2 100644 --- a/.github/workflows/reusable-test.yml +++ b/.github/workflows/reusable-test.yml @@ -31,6 +31,12 @@ jobs: node-version-file: ".nvmrc" cache: "yarn" + - name: Disable GitHub Actions Annotations + run: | + echo "::remove-matcher owner=tsc::" + echo "::remove-matcher owner=eslint-compact::" + echo "::remove-matcher owner=eslint-stylish::" + - name: 📥 Install deps run: yarn --frozen-lockfile @@ -61,6 +67,12 @@ jobs: node-version: ${{ matrix.node }} cache: "yarn" + - name: Disable GitHub Actions Annotations + run: | + echo "::remove-matcher owner=tsc::" + echo "::remove-matcher owner=eslint-compact::" + echo "::remove-matcher owner=eslint-stylish::" + - name: 📥 Install deps run: yarn --frozen-lockfile @@ -93,6 +105,12 @@ jobs: node-version: ${{ matrix.node }} cache: "yarn" + - name: Disable GitHub Actions Annotations + run: | + echo "::remove-matcher owner=tsc::" + echo "::remove-matcher owner=eslint-compact::" + echo "::remove-matcher owner=eslint-stylish::" + - name: 📥 Install deps run: yarn --frozen-lockfile @@ -124,6 +142,12 @@ jobs: node-version: ${{ matrix.node }} cache: "yarn" + - name: Disable GitHub Actions Annotations + run: | + echo "::remove-matcher owner=tsc::" + echo "::remove-matcher owner=eslint-compact::" + echo "::remove-matcher owner=eslint-stylish::" + - name: 📥 Install deps run: yarn --frozen-lockfile @@ -155,6 +179,12 @@ jobs: node-version: ${{ matrix.node }} cache: "yarn" + - name: Disable GitHub Actions Annotations + run: | + echo "::remove-matcher owner=tsc::" + echo "::remove-matcher owner=eslint-compact::" + echo "::remove-matcher owner=eslint-stylish::" + - name: 📥 Install deps run: yarn --frozen-lockfile diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b49c73ec03..14905fe4d14 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,4 +22,4 @@ jobs: if: github.repository == 'remix-run/remix' uses: ./.github/workflows/reusable-test.yml with: - node_version: '["19"]' + node_version: '["latest"]' diff --git a/contributors.yml b/contributors.yml index 1fe3b0004ee..fc93ac794ba 100644 --- a/contributors.yml +++ b/contributors.yml @@ -142,6 +142,7 @@ - F3n67u - federicoestevez - fergusmeiklejohn +- fernandojbf - fgiuliani - fishel-feng - francisudeji diff --git a/docs/pages/v2.md b/docs/pages/v2.md index dd59b44ef77..5d1351426ae 100644 --- a/docs/pages/v2.md +++ b/docs/pages/v2.md @@ -689,6 +689,12 @@ module.exports = { }; ``` +## `serverModuleFormat` + +The default server module output format will be changing from `cjs` to `esm`. + +In your `remix.config.js`, you should specify either `serverModuleFormat: "cjs"` to retain existing behavior, or `serverModuleFormat: "esm"`, to opt into the future behavior. + ## Dev Server We are still stabilizing the new dev server that enables HMR, several CSS libraries (CSS Modules, Vanilla Extract, Tailwind, PostCSS) and simplifies integration with various servers. diff --git a/integration/error-data-request-test.ts b/integration/error-data-request-test.ts index c282820e47b..e9af0467b43 100644 --- a/integration/error-data-request-test.ts +++ b/integration/error-data-request-test.ts @@ -129,17 +129,13 @@ test.describe("ErrorBoundary", () => { }); test("returns a 405 x-remix-error on a data fetch with a bad method", async () => { - let response = await fixture.requestData( - `/loader-return-json`, - "routes/loader-return-json", - { + expect(() => + fixture.requestData("/loader-return-json", "routes/loader-return-json", { method: "TRACE", - } + }) + ).rejects.toThrowError( + `Failed to construct 'Request': 'TRACE' HTTP method is unsupported.` ); - expect(response.status).toBe(405); - expect(response.headers.get("X-Remix-Error")).toBe("yes"); - expect(await response.text()).toMatch("Unexpected Server Error"); - assertConsoleError('Error: Invalid request method "TRACE"'); }); test("returns a 403 x-remix-error on a data fetch GET to a bad path", async () => { diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts index 61858c787cc..bb632820868 100644 --- a/integration/hmr-test.ts +++ b/integration/hmr-test.ts @@ -7,15 +7,22 @@ import getPort, { makeRange } from "get-port"; import { createFixtureProject, css, js, json } from "./helpers/create-fixture"; -let fixture = (options: { port: number; appServerPort: number }) => ({ +test.setTimeout(120_000); + +let fixture = (options: { + appServerPort: number; + httpPort: number; + webSocketPort: number; +}) => ({ files: { "remix.config.js": js` module.exports = { + serverModuleFormat: "cjs", tailwind: true, future: { unstable_dev: { - port: ${options.port}, - appServerPort: ${options.appServerPort}, + httpPort: ${options.httpPort}, + webSocketPort: ${options.webSocketPort}, }, v2_routeConvention: true, v2_errorBoundary: true, @@ -28,8 +35,7 @@ let fixture = (options: { port: number; appServerPort: number }) => ({ private: true, sideEffects: false, scripts: { - "dev:remix": `cross-env NODE_ENV=development node ./node_modules/@remix-run/dev/dist/cli.js dev`, - "dev:app": `cross-env NODE_ENV=development nodemon --watch build/ ./server.js`, + dev: `node ./node_modules/@remix-run/dev/dist/cli.js dev -c "node ./server.js"`, }, dependencies: { "@remix-run/css-bundle": "0.0.0-local-version", @@ -38,7 +44,6 @@ let fixture = (options: { port: number; appServerPort: number }) => ({ "cross-env": "0.0.0-local-version", express: "0.0.0-local-version", isbot: "0.0.0-local-version", - nodemon: "0.0.0-local-version", react: "0.0.0-local-version", "react-dom": "0.0.0-local-version", tailwindcss: "0.0.0-local-version", @@ -58,6 +63,7 @@ let fixture = (options: { port: number; appServerPort: number }) => ({ let path = require("path"); let express = require("express"); let { createRequestHandler } = require("@remix-run/express"); + let { devReady } = require("@remix-run/node"); const app = express(); app.use(express.static("public", { immutable: true, maxAge: "1y" })); @@ -75,8 +81,11 @@ let fixture = (options: { port: number; appServerPort: number }) => ({ let port = ${options.appServerPort}; app.listen(port, () => { - require(BUILD_DIR); + let build = require(BUILD_DIR); console.log('✅ app ready: http://localhost:' + port); + if (process.env.NODE_ENV === 'development') { + devReady(build); + } }); `, @@ -146,6 +155,9 @@ let fixture = (options: { port: number; appServerPort: number }) => ({ "app/routes/_index.tsx": js` import { useLoaderData } from "@remix-run/react"; + export function shouldRevalidate(args) { + return args.defaultShouldRevalidate; + } export default function Index() { const t = useLoaderData(); return ( @@ -204,44 +216,41 @@ let bufferize = (stream: Readable): (() => string) => { return () => buffer; }; +let HMR_TIMEOUT_MS = 10_000; + test("HMR", async ({ page }) => { // uncomment for debugging // page.on("console", (msg) => console.log(msg.text())); page.on("pageerror", (err) => console.log(err.message)); - - let appServerPort = await getPort({ port: makeRange(3080, 3089) }); - let port = await getPort({ port: makeRange(3090, 3099) }); - let projectDir = await createFixtureProject(fixture({ port, appServerPort })); + let dataRequests = 0; + page.on("request", (request) => { + let url = new URL(request.url()); + if (url.searchParams.has("_data")) { + dataRequests++; + } + }); + + let portRange = makeRange(3080, 3099); + let appServerPort = await getPort({ port: portRange }); + let httpPort = await getPort({ port: portRange }); + let webSocketPort = await getPort({ port: portRange }); + let projectDir = await createFixtureProject( + fixture({ appServerPort, httpPort, webSocketPort }) + ); // spin up dev server - let dev = execa("npm", ["run", "dev:remix"], { cwd: projectDir }); + let dev = execa("npm", ["run", "dev"], { cwd: projectDir }); let devStdout = bufferize(dev.stdout!); let devStderr = bufferize(dev.stderr!); - await wait( - () => { - let stderr = devStderr(); - if (stderr.length > 0) throw Error(stderr); - return /💿 Built in /.test(devStdout()); - }, - { timeoutMs: 10_000 } - ); - - // spin up app server - let app = execa("npm", ["run", "dev:app"], { cwd: projectDir }); - let appStdout = bufferize(app.stdout!); - let appStderr = bufferize(app.stderr!); - await wait( - () => { - let stderr = appStderr(); - if (stderr.length > 0) throw Error(stderr); - return /✅ app ready: /.test(appStdout()); - }, - { - timeoutMs: 10_000, - } - ); - try { + await wait( + () => { + if (dev.exitCode) throw Error("Dev server exited early"); + return /✅ app ready: /.test(devStdout()); + }, + { timeoutMs: 10_000 } + ); + await page.goto(`http://localhost:${appServerPort}`, { waitUntil: "networkidle", }); @@ -276,6 +285,9 @@ test("HMR", async ({ page }) => { let newIndex = ` import { useLoaderData } from "@remix-run/react"; import styles from "~/styles.module.css"; + export function shouldRevalidate(args) { + return args.defaultShouldRevalidate; + } export default function Index() { const t = useLoaderData(); return ( @@ -289,8 +301,9 @@ test("HMR", async ({ page }) => { // detect HMR'd content and style changes await page.waitForLoadState("networkidle"); + let h1 = page.getByText("Changed"); - await h1.waitFor({ timeout: 2000 }); + await h1.waitFor({ timeout: HMR_TIMEOUT_MS }); expect(h1).toHaveCSS("color", "rgb(255, 255, 255)"); expect(h1).toHaveCSS("background-color", "rgb(0, 0, 0)"); @@ -301,17 +314,23 @@ test("HMR", async ({ page }) => { // undo change fs.writeFileSync(indexPath, originalIndex); fs.writeFileSync(cssModulePath, originalCssModule); - await page.getByText("Index Title").waitFor({ timeout: 2000 }); + await page.getByText("Index Title").waitFor({ timeout: HMR_TIMEOUT_MS }); expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); await page.waitForSelector(`#root-counter:has-text("inc 1")`); + // We should not have done any revalidation yet as only UI has changed + expect(dataRequests).toBe(0); + // add loader let withLoader1 = ` import { json } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; - export let loader = () => json({ hello: "world" }) + export let loader = () => json({ hello: "world" }); + export function shouldRevalidate(args) { + return args.defaultShouldRevalidate; + } export default function Index() { let { hello } = useLoaderData(); return ( @@ -322,10 +341,14 @@ test("HMR", async ({ page }) => { } `; fs.writeFileSync(indexPath, withLoader1); - await page.getByText("Hello, world").waitFor({ timeout: 2000 }); + await page.waitForLoadState("networkidle"); + + await page.getByText("Hello, world").waitFor({ timeout: HMR_TIMEOUT_MS }); expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); await page.waitForSelector(`#root-counter:has-text("inc 1")`); + expect(dataRequests).toBe(1); + let withLoader2 = ` import { json } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; @@ -334,6 +357,9 @@ test("HMR", async ({ page }) => { return json({ hello: "planet" }) } + export function shouldRevalidate(args) { + return args.defaultShouldRevalidate; + } export default function Index() { let { hello } = useLoaderData(); return ( @@ -344,10 +370,14 @@ test("HMR", async ({ page }) => { } `; fs.writeFileSync(indexPath, withLoader2); - await page.getByText("Hello, planet").waitFor({ timeout: 2000 }); + await page.waitForLoadState("networkidle"); + + await page.getByText("Hello, planet").waitFor({ timeout: HMR_TIMEOUT_MS }); expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); await page.waitForSelector(`#root-counter:has-text("inc 1")`); + expect(dataRequests).toBe(2); + // change shared component let updatedCounter = ` import * as React from "react"; @@ -388,10 +418,20 @@ test("HMR", async ({ page }) => { aboutCounter = await page.waitForSelector( `#about-counter:has-text("inc 0")` ); + + // This should not have triggered any revalidation but our detection is + // failing for x-module changes for route module imports + // expect(dataRequests).toBe(2); + } catch (e) { + console.log("stdout begin -----------------------"); + console.log(devStdout()); + console.log("stdout end -------------------------"); + + console.log("stderr begin -----------------------"); + console.log(devStderr()); + console.log("stderr end -------------------------"); + throw e; } finally { dev.kill(); - app.kill(); - console.log(devStderr()); - console.log(appStderr()); } }); diff --git a/integration/link-test.ts b/integration/link-test.ts index 48b69655145..3a9ecc4946c 100644 --- a/integration/link-test.ts +++ b/integration/link-test.ts @@ -220,6 +220,9 @@ test.describe("route module link export", () => {
  • Resource routes
  • +
  • + Errored child route +
  • @@ -471,6 +474,42 @@ test.describe("route module link export", () => { } `, + + "app/routes/parent.jsx": js` + import { Outlet } from "@remix-run/react"; + + export function links() { + return [ + { "data-test-id": "red" }, + ]; + } + + export default function Component() { + return
    ; + } + + export function ErrorBoundary() { + return

    Error Boundary

    ; + } + `, + + "app/routes/parent.child.jsx": js` + import { Outlet } from "@remix-run/react"; + + export function loader() { + throw new Response(null, { status: 404 }); + } + + export function links() { + return [ + { "data-test-id": "blue" }, + ]; + } + + export default function Component() { + return
    ; + } + `, }, }); appFixture = await createAppFixture(fixture); @@ -511,6 +550,17 @@ test.describe("route module link export", () => { expect(stylesheetResponses.length).toEqual(1); }); + test("does not render errored child route links", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/", true); + await page.click('a[href="/parent/child"]'); + await page.waitForSelector('[data-test-id="/parent:error-boundary"]'); + await page.waitForSelector('[data-test-id="red"]', { state: "attached" }); + await page.waitForSelector('[data-test-id="blue"]', { + state: "detached", + }); + }); + test.describe("no js", () => { test.use({ javaScriptEnabled: false }); @@ -534,6 +584,16 @@ test.describe("route module link export", () => { let locator = page.locator("link[rel=preload][as=image]"); expect(await locator.getAttribute("imagesizes")).toBe("100vw"); }); + + test("does not render errored child route links", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/parent/child"); + await page.waitForSelector('[data-test-id="/parent:error-boundary"]'); + await page.waitForSelector('[data-test-id="red"]', { state: "attached" }); + await page.waitForSelector('[data-test-id="blue"]', { + state: "detached", + }); + }); }); test.describe("script imports", () => { diff --git a/integration/resource-routes-test.ts b/integration/resource-routes-test.ts index 7a662245550..5307d471113 100644 --- a/integration/resource-routes-test.ts +++ b/integration/resource-routes-test.ts @@ -191,3 +191,68 @@ test.describe("loader in an app", async () => { ); }); }); + +test.describe("Development server", async () => { + let appFixture: AppFixture; + let fixture: Fixture; + let _consoleError: typeof console.error; + + test.beforeAll(async () => { + _consoleError = console.error; + console.error = () => {}; + + fixture = await createFixture( + { + future: { + v2_routeConvention: true, + v2_errorBoundary: true, + }, + files: { + "app/routes/_index.jsx": js` + import { Link } from "@remix-run/react"; + export default () => Child; + `, + "app/routes/_main.jsx": js` + import { useRouteError } from "@remix-run/react"; + export function ErrorBoundary() { + return
    {useRouteError().message}
    ; + } + `, + "app/routes/_main.child.jsx": js` + export default function Component() { + throw new Error('Error from render') + } + `, + }, + }, + ServerMode.Development + ); + appFixture = await createAppFixture(fixture, ServerMode.Development); + }); + + test.afterAll(() => { + appFixture.close(); + console.error = _consoleError; + }); + + test.describe("with JavaScript", () => { + runTests(); + }); + + test.describe("without JavaScript", () => { + test.use({ javaScriptEnabled: false }); + runTests(); + }); + + function runTests() { + test("should not treat an ErrorBoundary-only route as a resource route", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/child"); + let html = await app.getHtml(); + expect(html).not.toMatch("has no component"); + expect(html).toMatch("Error from render"); + }); + } +}); diff --git a/integration/revalidate-test.ts b/integration/revalidate-test.ts index 00ed60ca619..b5f1721d0fc 100644 --- a/integration/revalidate-test.ts +++ b/integration/revalidate-test.ts @@ -44,6 +44,11 @@ test.describe("Revalidation", () => { } `, + "app/routes/_index.jsx": js` + export default function Component() { + return

    Index

    ; + } + `, "app/routes/parent.jsx": js` import { json } from "@remix-run/node"; import { Outlet, useLoaderData } from "@remix-run/react"; diff --git a/integration/splat-routes-test.ts b/integration/splat-routes-test.ts index 0a15ae5e68b..ed86715458a 100644 --- a/integration/splat-routes-test.ts +++ b/integration/splat-routes-test.ts @@ -3,9 +3,9 @@ import { test, expect } from "@playwright/test"; import { createFixture, js } from "./helpers/create-fixture"; import type { Fixture } from "./helpers/create-fixture"; -test.describe("rendering", () => { - let fixture: Fixture; +let fixture: Fixture; +test.describe("rendering", () => { let ROOT_$ = "FLAT"; let ROOT_INDEX = "ROOT_INDEX"; let FLAT_$ = "FLAT"; @@ -128,3 +128,105 @@ test.describe("rendering", () => { expect(await res.text()).toMatch(PARENTLESS_$); }); }); + +test.describe("root splat route without index", () => { + test("matches routes correctly (v1)", async ({ page }) => { + fixture = await createFixture({ + future: { v2_routeConvention: false }, + files: { + "app/routes/$.jsx": js` + export default function Component() { + return

    Hello Splat

    + } + `, + }, + }); + + let res = await fixture.requestDocument("/"); + expect(await res.text()).toMatch("Hello Splat"); + + res = await fixture.requestDocument("/splat"); + expect(await res.text()).toMatch("Hello Splat"); + + res = await fixture.requestDocument("/splat/deep/path"); + expect(await res.text()).toMatch("Hello Splat"); + }); + + test("matches routes correctly (v2)", async ({ page }) => { + fixture = await createFixture({ + future: { v2_routeConvention: true }, + files: { + "app/routes/$.jsx": js` + export default function Component() { + return

    Hello Splat

    + } + `, + }, + }); + + let res = await fixture.requestDocument("/"); + expect(await res.text()).toMatch("Hello Splat"); + + res = await fixture.requestDocument("/splat"); + expect(await res.text()).toMatch("Hello Splat"); + + res = await fixture.requestDocument("/splat/deep/path"); + expect(await res.text()).toMatch("Hello Splat"); + }); +}); + +test.describe("root splat route with index", () => { + test("matches routes correctly (v1)", async ({ page }) => { + fixture = await createFixture({ + future: { v2_routeConvention: false }, + files: { + "app/routes/index.jsx": js` + export default function Component() { + return

    Hello Index

    + } + `, + "app/routes/$.jsx": js` + export default function Component() { + return

    Hello Splat

    + } + `, + }, + }); + + let res = await fixture.requestDocument("/"); + expect(await res.text()).toMatch("Hello Index"); + + res = await fixture.requestDocument("/splat"); + expect(await res.text()).toMatch("Hello Splat"); + + res = await fixture.requestDocument("/splat/deep/path"); + expect(await res.text()).toMatch("Hello Splat"); + }); + + test("matches routes correctly (v2)", async ({ page }) => { + fixture = await createFixture({ + future: { v2_routeConvention: true }, + files: { + "app/routes/_index.jsx": js` + export default function Component() { + return

    Hello Index

    + } + `, + "app/routes/$.jsx": js` + export default function Component() { + return

    Hello Splat

    + } + `, + }, + }); + + let res = await fixture.requestDocument("/"); + expect(await res.text()).toMatch("Hello Index"); + + res = await fixture.requestDocument("/splat"); + expect(await res.text()).toMatch("Hello Splat"); + + res = await fixture.requestDocument("/splat/deep/path"); + expect(await res.text()).toMatch("Hello Splat"); + }); +}); diff --git a/package.json b/package.json index b23c8490133..b8936b79ffd 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "@types/retry": "^0.12.0", "@types/semver": "^7.3.4", "@types/ssri": "^7.1.0", - "@types/use-sync-external-store": "^0.0.3", "@vanilla-extract/css": "^1.1.0", "abort-controller": "^3.0.0", "abortcontroller-polyfill": "^1.7.3", @@ -108,7 +107,6 @@ "jest-watch-typeahead": "^0.6.5", "jsonfile": "^6.0.1", "lodash": "^4.17.21", - "nodemon": "^2.0.20", "npm-run-all": "^4.1.5", "patch-package": "^6.5.0", "prettier": "2.7.1", diff --git a/packages/remix-architect/__tests__/server-test.ts b/packages/remix-architect/__tests__/server-test.ts index 2d02c513697..e0e8643b3df 100644 --- a/packages/remix-architect/__tests__/server-test.ts +++ b/packages/remix-architect/__tests__/server-test.ts @@ -3,10 +3,6 @@ import path from "path"; import lambdaTester from "lambda-tester"; import type { APIGatewayProxyEventV2 } from "aws-lambda"; import { - // This has been added as a global in node 15+, but we expose it here while we - // support Node 14 - // eslint-disable-next-line @typescript-eslint/no-unused-vars - AbortController, createRequestHandler as createRemixRequestHandler, Response as NodeResponse, } from "@remix-run/node"; @@ -204,145 +200,50 @@ describe("architect createRequestHandler", () => { describe("architect createRemixHeaders", () => { describe("creates fetch headers from architect headers", () => { it("handles empty headers", () => { - expect(createRemixHeaders({}, undefined)).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({}); + expect(headers.raw()).toMatchInlineSnapshot(`Object {}`); }); it("handles simple headers", () => { - expect(createRemixHeaders({ "x-foo": "bar" }, undefined)) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ "x-foo": "bar" }); + expect(headers.get("x-foo")).toBe("bar"); }); it("handles multiple headers", () => { - expect(createRemixHeaders({ "x-foo": "bar", "x-bar": "baz" }, undefined)) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar", - "x-bar", - "baz", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ "x-foo": "bar", "x-bar": "baz" }); + expect(headers.get("x-foo")).toBe("bar"); + expect(headers.get("x-bar")).toBe("baz"); }); it("handles headers with multiple values", () => { - expect(createRemixHeaders({ "x-foo": "bar, baz" }, undefined)) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar, baz", - ], - Symbol(context): null, - } - `); - }); - - it("handles headers with multiple values and multiple headers", () => { - expect( - createRemixHeaders({ "x-foo": "bar, baz", "x-bar": "baz" }, undefined) - ).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar, baz", - "x-bar", - "baz", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ + "x-foo": "bar, baz", + "x-bar": "baz", + }); + expect(headers.getAll("x-foo")).toEqual(["bar, baz"]); + expect(headers.get("x-bar")).toBe("baz"); }); - it("handles cookies", () => { - expect( - createRemixHeaders({ "x-something-else": "true" }, [ - "__session=some_value", - "__other=some_other_value", - ]) - ).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-something-else", - "true", - "cookie", - "__session=some_value; __other=some_other_value", - ], - Symbol(context): null, - } - `); + it("handles multiple request cookies", () => { + let headers = createRemixHeaders({}, [ + "__session=some_value", + "__other=some_other_value", + ]); + expect(headers.getAll("cookie")).toEqual([ + "__session=some_value; __other=some_other_value", + ]); }); }); }); describe("architect createRemixRequest", () => { it("creates a request with the correct headers", () => { - expect( - createRemixRequest( - createMockEvent({ - cookies: ["__session=value"], - }) - ) - ).toMatchInlineSnapshot(` - NodeRequest { - "agent": undefined, - "compress": true, - "counter": 0, - "follow": 20, - "highWaterMark": 16384, - "insecureHTTPParser": false, - "size": 0, - Symbol(Body internals): Object { - "body": null, - "boundary": null, - "disturbed": false, - "error": null, - "size": 0, - "type": null, - }, - Symbol(Request internals): Object { - "credentials": "same-origin", - "headers": Headers { - Symbol(query): Array [ - "accept", - "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "accept-encoding", - "gzip, deflate", - "accept-language", - "en-US,en;q=0.9", - "cookie", - "__session=value", - "host", - "localhost:3333", - "upgrade-insecure-requests", - "1", - "user-agent", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15", - ], - Symbol(context): null, - }, - "method": "GET", - "parsedURL": "https://localhost:3333/", - "redirect": "follow", - "signal": AbortSignal {}, - }, - } - `); + let remixRequest = createRemixRequest( + createMockEvent({ cookies: ["__session=value"] }) + ); + + expect(remixRequest.method).toBe("GET"); + expect(remixRequest.headers.get("cookie")).toBe("__session=value"); }); }); diff --git a/packages/remix-architect/server.ts b/packages/remix-architect/server.ts index cbbfa9f1bd6..15ec86c157c 100644 --- a/packages/remix-architect/server.ts +++ b/packages/remix-architect/server.ts @@ -29,7 +29,7 @@ import { isBinaryType } from "./binaryTypes"; */ export type GetLoadContextFunction = ( event: APIGatewayProxyEventV2 -) => AppLoadContext; +) => Promise | AppLoadContext; export type RequestHandler = APIGatewayProxyHandlerV2; @@ -50,7 +50,7 @@ export function createRequestHandler({ return async (event) => { let request = createRemixRequest(event); - let loadContext = getLoadContext?.(event); + let loadContext = await getLoadContext?.(event); let response = (await handleRequest(request, loadContext)) as NodeResponse; diff --git a/packages/remix-cloudflare-pages/worker.ts b/packages/remix-cloudflare-pages/worker.ts index 5f1c70e15a2..5b4034a803f 100644 --- a/packages/remix-cloudflare-pages/worker.ts +++ b/packages/remix-cloudflare-pages/worker.ts @@ -10,7 +10,7 @@ import { createRequestHandler as createRemixRequestHandler } from "@remix-run/cl */ export type GetLoadContextFunction = ( context: Parameters>[0] -) => AppLoadContext; +) => Promise | AppLoadContext; export type RequestHandler = PagesFunction; @@ -27,8 +27,8 @@ export function createRequestHandler({ }: createPagesFunctionHandlerParams): RequestHandler { let handleRequest = createRemixRequestHandler(build, mode); - return (context) => { - let loadContext = getLoadContext?.(context); + return async (context) => { + let loadContext = await getLoadContext?.(context); return handleRequest(context.request, loadContext); }; diff --git a/packages/remix-cloudflare-workers/worker.ts b/packages/remix-cloudflare-workers/worker.ts index c783121b2ff..db54b63bd9d 100644 --- a/packages/remix-cloudflare-workers/worker.ts +++ b/packages/remix-cloudflare-workers/worker.ts @@ -17,7 +17,9 @@ import { createRequestHandler as createRemixRequestHandler } from "@remix-run/cl * You can think of this as an escape hatch that allows you to pass * environment/platform-specific values through to your loader/action. */ -export type GetLoadContextFunction = (event: FetchEvent) => AppLoadContext; +export type GetLoadContextFunction = ( + event: FetchEvent +) => Promise | AppLoadContext; export type RequestHandler = (event: FetchEvent) => Promise; @@ -36,8 +38,8 @@ export function createRequestHandler({ }): RequestHandler { let handleRequest = createRemixRequestHandler(build, mode); - return (event: FetchEvent) => { - let loadContext = getLoadContext?.(event); + return async (event: FetchEvent) => { + let loadContext = await getLoadContext?.(event); return handleRequest(event.request, loadContext); }; diff --git a/packages/remix-cloudflare/index.ts b/packages/remix-cloudflare/index.ts index e0e81f9597e..1f6f1ffa052 100644 --- a/packages/remix-cloudflare/index.ts +++ b/packages/remix-cloudflare/index.ts @@ -28,6 +28,7 @@ export { createRequestHandler, createSession, defer, + devReady, isCookie, isSession, json, diff --git a/packages/remix-deno/index.ts b/packages/remix-deno/index.ts index 77ef4d86c83..946342f8cc2 100644 --- a/packages/remix-deno/index.ts +++ b/packages/remix-deno/index.ts @@ -16,6 +16,7 @@ export { export { createSession, defer, + devReady, isCookie, isSession, json, diff --git a/packages/remix-dev/__tests__/cli-test.ts b/packages/remix-dev/__tests__/cli-test.ts index 9b0c8963a17..ba57c100664 100644 --- a/packages/remix-dev/__tests__/cli-test.ts +++ b/packages/remix-dev/__tests__/cli-test.ts @@ -115,6 +115,14 @@ describe("remix CLI", () => { \`dev\` Options: --debug Attach Node.js inspector --port, -p Choose the port from which to run your app + + [unstable_dev] + --command, -c Command used to run your app server + --http-scheme HTTP(S) scheme for the dev server. Default: http + --http-host HTTP(S) host for the dev server. Default: localhost + --http-port HTTP(S) port for the dev server. Default: any open port + --no-restart Do not restart the app server when rebuilds occur. + --websocket-port Websocket port for the dev server. Default: any open port \`init\` Options: --no-delete Skip deleting the \`remix.init\` script \`routes\` Options: diff --git a/packages/remix-dev/__tests__/create-test.ts b/packages/remix-dev/__tests__/create-test.ts index 9640bcaff1a..3891972938f 100644 --- a/packages/remix-dev/__tests__/create-test.ts +++ b/packages/remix-dev/__tests__/create-test.ts @@ -13,6 +13,7 @@ import { flatRoutesWarning, formMethodWarning, metaWarning, + serverModuleFormatWarning, } from "../config"; beforeAll(() => server.listen({ onUnhandledRequest: "error" })); @@ -359,6 +360,8 @@ describe("the create command", () => { "\n" + metaWarning + "\n" + + serverModuleFormatWarning + + "\n" + flatRoutesWarning + "\n\n" + getOptOutOfInstallMessage() + diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts index e1f795537c7..8298b81e5e8 100644 --- a/packages/remix-dev/__tests__/readConfig-test.ts +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -68,7 +68,6 @@ describe("readConfig", () => { "root": Object { "file": "root.tsx", "id": "root", - "path": "", }, }, "serverBuildPath": Any, diff --git a/packages/remix-dev/channel.ts b/packages/remix-dev/channel.ts index a631a4809a2..eb2ecdc128f 100644 --- a/packages/remix-dev/channel.ts +++ b/packages/remix-dev/channel.ts @@ -1,8 +1,8 @@ +import type { Result } from "./result"; + type Resolve = (value: V | PromiseLike) => void; type Reject = (reason?: any) => void; -type Result = { ok: true; value: V } | { ok: false; error: E }; - export type Type = { ok: (value: V) => void; err: (reason?: any) => void; diff --git a/packages/remix-dev/cli/commands.ts b/packages/remix-dev/cli/commands.ts index 1cd7c43af48..0b16b9828b0 100644 --- a/packages/remix-dev/cli/commands.ts +++ b/packages/remix-dev/cli/commands.ts @@ -1,6 +1,8 @@ import * as path from "path"; import { execSync } from "child_process"; +import inspector from "inspector"; import * as fse from "fs-extra"; +import getPort, { makeRange } from "get-port"; import ora from "ora"; import prettyMs from "pretty-ms"; import * as esbuild from "esbuild"; @@ -15,13 +17,15 @@ import type { RemixConfig } from "../config"; import { readConfig } from "../config"; import { formatRoutes, RoutesFormat, isRoutesFormat } from "../config/format"; import { createApp } from "./create"; -import { getPreferredPackageManager } from "./getPreferredPackageManager"; +import { detectPackageManager } from "./detectPackageManager"; import { setupRemix, isSetupPlatform, SetupPlatform } from "./setup"; import runCodemod from "../codemod"; import { CodemodError } from "../codemod/utils/error"; import { TaskError } from "../codemod/utils/task"; import { transpile as convertFileToJS } from "./useJavascript"; import { warnOnce } from "../warnOnce"; +import type { Options } from "../compiler/options"; +import { getAppDependencies } from "../dependencies"; export async function create({ appTemplate, @@ -80,7 +84,7 @@ export async function init( let initPackageJson = path.resolve(initScriptDir, "package.json"); let isTypeScript = fse.existsSync(path.join(projectDir, "tsconfig.json")); - let packageManager = getPreferredPackageManager(); + let packageManager = detectPackageManager() ?? "npm"; if (await fse.pathExists(initPackageJson)) { execSync(`${packageManager} install`, { @@ -167,24 +171,31 @@ export async function build( let start = Date.now(); let config = await readConfig(remixRoot); + let options: Options = { + mode, + sourcemap, + onWarning: warnOnce, + }; + if (mode === "development" && config.future.unstable_dev) { + let dev = await resolveDevBuild(config); + options.devHttpOrigin = { + scheme: dev.httpScheme, + host: dev.httpHost, + port: dev.httpPort, + }; + options.devWebsocketPort = dev.websocketPort; + } + fse.emptyDirSync(config.assetsBuildDirectory); - await compiler - .build({ - config, - options: { - mode, - sourcemap, - onWarning: warnOnce, - }, - }) - .catch((thrown) => { - compiler.logThrown(thrown); - process.exit(1); - }); + await compiler.build({ config, options }).catch((thrown) => { + compiler.logThrown(thrown); + process.exit(1); + }); - console.log(`built in ${prettyMs(Date.now() - start)}`); + console.log(`Built in ${prettyMs(Date.now() - start)}`); } +// TODO: replace watch in v2 export async function watch( remixRootOrConfig: string | RemixConfig, modeArg?: string @@ -197,26 +208,41 @@ export async function watch( ? remixRootOrConfig : await readConfig(remixRootOrConfig); - devServer.liveReload(config, { - onInitialBuild: (durationMs) => - console.log(`💿 Built in ${prettyMs(durationMs)}`), - }); + devServer.liveReload(config); return await new Promise(() => {}); } export async function dev( remixRoot: string, - flags: { port?: number; appServerPort?: number } = {} + flags: { + debug?: boolean; + port?: number; // TODO: remove for v2 + + // unstable_dev + command?: string; + httpScheme?: string; + httpHost?: string; + httpPort?: number; + restart?: boolean; + websocketPort?: number; + } = {} ) { + if (process.env.NODE_ENV && process.env.NODE_ENV !== "development") { + console.warn( + `Forcing NODE_ENV to be 'development'. Was: ${process.env.NODE_ENV}` + ); + } + process.env.NODE_ENV = "development"; + if (flags.debug) inspector.open(); + let config = await readConfig(remixRoot); - if (config.future.unstable_dev !== false) { - await devServer_unstable.serve(config, flags); + if (config.future.unstable_dev === false) { + await devServer.serve(config, flags.port); return await new Promise(() => {}); } - await devServer.serve(config, flags.port); - return await new Promise(() => {}); + await devServer_unstable.serve(config, await resolveDevServe(config, flags)); } export async function codemod( @@ -442,3 +468,99 @@ let parseMode = ( console.error(`Unrecognized mode: ${mode}`); process.exit(1); }; + +let findPort = async () => getPort({ port: makeRange(3001, 3100) }); + +type DevBuildFlags = { + httpScheme: string; + httpHost: string; + httpPort: number; + websocketPort: number; +}; +let resolveDevBuild = async ( + config: RemixConfig, + flags: Partial = {} +): Promise => { + let dev = config.future.unstable_dev; + if (dev === false) throw Error("This should never happen"); + + // prettier-ignore + let httpScheme = + flags.httpScheme ?? + (dev === true ? undefined : dev.httpScheme) ?? + "http"; + // prettier-ignore + let httpHost = + flags.httpHost ?? + (dev === true ? undefined : dev.httpHost) ?? + "localhost"; + // prettier-ignore + let httpPort = + flags.httpPort ?? + (dev === true ? undefined : dev.httpPort) ?? + (await findPort()); + // prettier-ignore + let websocketPort = + flags.websocketPort ?? + (dev === true ? undefined : dev.websocketPort) ?? + (await findPort()); + + return { + httpScheme, + httpHost, + httpPort, + websocketPort, + }; +}; + +type DevServeFlags = DevBuildFlags & { + command: string; + restart: boolean; +}; +let resolveDevServe = async ( + config: RemixConfig, + flags: Partial = {} +): Promise => { + let dev = config.future.unstable_dev; + if (dev === false) throw Error("Cannot resolve dev options"); + + let { httpScheme, httpHost, httpPort, websocketPort } = await resolveDevBuild( + config, + flags + ); + + // prettier-ignore + let command = + flags.command ?? + (dev === true ? undefined : dev.command) + if (!command) { + command = `remix-serve ${path.relative( + process.cwd(), + config.serverBuildPath + )}`; + + let usingRemixAppServer = + getAppDependencies(config)["@remix-run/serve"] !== undefined; + if (!usingRemixAppServer) { + console.error( + [ + `Remix dev server command defaulted to '${command}', but @remix-run/serve is not installed.`, + "If you are using another server, specify how to run it with `-c` or `--command` flag.", + "For example, `remix dev -c 'node ./server.js'`", + ].join("\n") + ); + process.exit(1); + } + } + let restart = + flags.restart ?? (dev === true ? undefined : dev.restart) ?? true; + + return { + command, + httpScheme, + httpHost, + httpPort, + websocketPort, + restart, + }; +}; diff --git a/packages/remix-dev/cli/create.ts b/packages/remix-dev/cli/create.ts index 4d58d935f90..c26fe90be04 100644 --- a/packages/remix-dev/cli/create.ts +++ b/packages/remix-dev/cli/create.ts @@ -15,7 +15,7 @@ import tar from "tar-fs"; import * as colors from "../colors"; import invariant from "../invariant"; import packageJson from "../package.json"; -import { getPreferredPackageManager } from "./getPreferredPackageManager"; +import { detectPackageManager } from "./detectPackageManager"; import * as useJavascript from "./useJavascript"; const remixDevPackageVersion = packageJson.version; @@ -208,7 +208,7 @@ export async function createApp({ } if (installDeps) { - let packageManager = getPreferredPackageManager(); + let packageManager = detectPackageManager() ?? "npm"; let npmConfig = execSync( `${packageManager} config get @remix-run:registry`, diff --git a/packages/remix-dev/cli/detectPackageManager.ts b/packages/remix-dev/cli/detectPackageManager.ts new file mode 100644 index 00000000000..f77f5c7bb5d --- /dev/null +++ b/packages/remix-dev/cli/detectPackageManager.ts @@ -0,0 +1,22 @@ +type PackageManager = "npm" | "pnpm" | "yarn"; + +/** + * Determine which package manager the user prefers. + * + * npm, pnpm and Yarn set the user agent environment variable + * that can be used to determine which package manager ran + * the command. + */ +export const detectPackageManager = (): PackageManager | undefined => { + let { npm_config_user_agent } = process.env; + if (!npm_config_user_agent) return undefined; + try { + let pkgManager = npm_config_user_agent.split("/")[0]; + if (pkgManager === "npm") return "npm"; + if (pkgManager === "pnpm") return "pnpm"; + if (pkgManager === "yarn") return "yarn"; + return undefined; + } catch { + return undefined; + } +}; diff --git a/packages/remix-dev/cli/getPreferredPackageManager.ts b/packages/remix-dev/cli/getPreferredPackageManager.ts deleted file mode 100644 index d4dd9dd4264..00000000000 --- a/packages/remix-dev/cli/getPreferredPackageManager.ts +++ /dev/null @@ -1,12 +0,0 @@ -type PackageManager = "npm" | "pnpm" | "yarn"; - -/** - * Determine which package manager the user prefers. - * - * npm, pnpm and Yarn set the user agent environment variable - * that can be used to determine which package manager ran - * the command. - */ -export const getPreferredPackageManager = () => - ((process.env.npm_config_user_agent ?? "").split("/")[0] || - "npm") as PackageManager; diff --git a/packages/remix-dev/cli/run.ts b/packages/remix-dev/cli/run.ts index a62c53eb58b..c4448f122ca 100644 --- a/packages/remix-dev/cli/run.ts +++ b/packages/remix-dev/cli/run.ts @@ -1,7 +1,6 @@ import * as path from "path"; import os from "os"; import arg from "arg"; -import inspector from "inspector"; import inquirer from "inquirer"; import semver from "semver"; import fse from "fs-extra"; @@ -9,7 +8,7 @@ import fse from "fs-extra"; import * as colors from "../colors"; import * as commands from "./commands"; import { validateNewProjectPath, validateTemplate } from "./create"; -import { getPreferredPackageManager } from "./getPreferredPackageManager"; +import { detectPackageManager } from "./detectPackageManager"; const helpText = ` ${colors.logoBlue("R")} ${colors.logoGreen("E")} ${colors.logoYellow( @@ -42,6 +41,14 @@ ${colors.logoBlue("R")} ${colors.logoGreen("E")} ${colors.logoYellow( \`dev\` Options: --debug Attach Node.js inspector --port, -p Choose the port from which to run your app + + [unstable_dev] + --command, -c Command used to run your app server + --http-scheme HTTP(S) scheme for the dev server. Default: http + --http-host HTTP(S) host for the dev server. Default: localhost + --http-port HTTP(S) port for the dev server. Default: any open port + --no-restart Do not restart the app server when rebuilds occur. + --websocket-port Websocket port for the dev server. Default: any open port \`init\` Options: --no-delete Skip deleting the \`remix.init\` script \`routes\` Options: @@ -137,20 +144,6 @@ const npxInterop = { pnpm: "pnpm exec", }; -async function dev( - projectDir: string, - flags: { debug?: boolean; port?: number; appServerPort?: number } -) { - if (process.env.NODE_ENV && process.env.NODE_ENV !== "development") { - console.warn( - `NODE_ENV=${process.env.NODE_ENV} overwritten to 'development'` - ); - } - - if (flags.debug) inspector.open(); - await commands.dev(projectDir, flags); -} - /** * Programmatic interface for running the Remix CLI with the given command line * arguments. @@ -166,7 +159,6 @@ export async function run(argv: string[] = process.argv.slice(2)) { let args = arg( { - "--app-server-port": Number, "--debug": Boolean, "--no-delete": Boolean, "--dry": Boolean, @@ -188,6 +180,15 @@ export async function run(argv: string[] = process.argv.slice(2)) { "--no-typescript": Boolean, "--version": Boolean, "-v": "--version", + + // dev server + "--command": String, + "-c": "--command", + "--http-scheme": String, + "--http-host": String, + "--http-port": Number, + "--no-restart": Boolean, + "--websocket-port": Number, }, { argv, @@ -212,6 +213,23 @@ export async function run(argv: string[] = process.argv.slice(2)) { return; } + if (flags["http-scheme"]) { + flags.httpScheme = flags["http-scheme"]; + delete flags["http-scheme"]; + } + if (flags["http-host"]) { + flags.httpHost = flags["http-host"]; + delete flags["http-host"]; + } + if (flags["http-port"]) { + flags.httpPort = flags["http-port"]; + delete flags["http-port"]; + } + if (flags["websocket-port"]) { + flags.websocketPort = flags["websocket-port"]; + delete flags["websocket-port"]; + } + if (args["--no-delete"]) { flags.delete = false; } @@ -222,6 +240,10 @@ export async function run(argv: string[] = process.argv.slice(2)) { flags.interactive = false; } flags.interactive = flags.interactive ?? require.main === module; + if (args["--no-restart"]) { + flags.restart = false; + delete flags["no-restart"]; + } if (args["--no-typescript"]) { flags.typescript = false; } @@ -310,7 +332,7 @@ export async function run(argv: string[] = process.argv.slice(2)) { return; } - let packageManager = getPreferredPackageManager(); + let packageManager = detectPackageManager() ?? "npm"; let answers = await inquirer .prompt<{ appType: "template" | "stack"; @@ -498,10 +520,10 @@ export async function run(argv: string[] = process.argv.slice(2)) { break; } case "dev": - await dev(input[1], flags); + await commands.dev(input[1], flags); break; default: // `remix ./my-project` is shorthand for `remix dev ./my-project` - await dev(input[0], flags); + await commands.dev(input[0], flags); } } diff --git a/packages/remix-dev/codemod/replace-remix-magic-imports/index.ts b/packages/remix-dev/codemod/replace-remix-magic-imports/index.ts index 24b46f6342c..525becb93e8 100644 --- a/packages/remix-dev/codemod/replace-remix-magic-imports/index.ts +++ b/packages/remix-dev/codemod/replace-remix-magic-imports/index.ts @@ -6,7 +6,7 @@ import semver from "semver"; import { blue, cyan, gray } from "../../colors"; import { readConfig } from "../../config"; -import { getPreferredPackageManager } from "../../cli/getPreferredPackageManager"; +import { detectPackageManager } from "../../cli/detectPackageManager"; import type { Codemod } from "../codemod"; import { CodemodError } from "../utils/error"; import * as log from "../utils/log"; @@ -177,7 +177,7 @@ const codemod: Codemod = async (projectDir, options) => { ); if (!options.dry) { - let packageManager = getPreferredPackageManager(); + let packageManager = detectPackageManager() ?? "npm"; log.info( `👉 To update your lockfile, run ${code(`${packageManager} install`)}` ); diff --git a/packages/remix-dev/compiler/compiler.ts b/packages/remix-dev/compiler/compiler.ts index bb1b1b0538a..4a1f6016960 100644 --- a/packages/remix-dev/compiler/compiler.ts +++ b/packages/remix-dev/compiler/compiler.ts @@ -7,6 +7,7 @@ import * as Server from "./server"; import * as Channel from "../channel"; import type { Manifest } from "../manifest"; import { create as createManifest, write as writeManifest } from "./manifest"; +import { err, ok } from "../result"; type Compiler = { compile: () => Promise; @@ -29,29 +30,23 @@ export let create = async (ctx: Context): Promise => { js: await JS.createCompiler(ctx, channels), server: await Server.createCompiler(ctx, channels), }; + let cancel = async () => { + // resolve channels with error so that downstream tasks don't hang waiting for results from upstream tasks + channels.cssBundleHref.err(); + channels.manifest.err(); + + // optimization: cancel tasks + await Promise.all([ + subcompiler.css.cancel(), + subcompiler.js.cancel(), + subcompiler.server.cancel(), + ]); + }; let compile = async () => { - let hasThrown = false; - let cancelAndThrow = async (error: unknown) => { - // An earlier error from a failed task has already been thrown; ignore this error. - // Safe to cast as `never` here as subsequent errors are only thrown from canceled tasks. - if (hasThrown) return undefined as never; - - // resolve channels with error so that downstream tasks don't hang waiting for results from upstream tasks - channels.cssBundleHref.err(); - channels.manifest.err(); - - // optimization: cancel tasks - subcompiler.css.cancel(); - subcompiler.js.cancel(); - subcompiler.server.cancel(); - - // Only throw the first error encountered during compilation - // otherwise subsequent errors will be unhandled and will crash the compiler. - // `try`/`catch` won't handle subsequent errors either, so that isn't a viable alternative. - // `Promise.all` _could_ be used, but the resulting promise chaining is complex and hard to follow. - hasThrown = true; - throw error; + let errCancel = (error: unknown) => { + cancel(); + return err(error); }; // reset channels @@ -60,9 +55,9 @@ export let create = async (ctx: Context): Promise => { // kickoff compilations in parallel let tasks = { - css: subcompiler.css.compile().catch(cancelAndThrow), - js: subcompiler.js.compile().catch(cancelAndThrow), - server: subcompiler.server.compile().catch(cancelAndThrow), + css: subcompiler.css.compile().then(ok, errCancel), + js: subcompiler.js.compile().then(ok, errCancel), + server: subcompiler.server.compile().then(ok, errCancel), }; // keep track of manually written artifacts @@ -74,23 +69,26 @@ export let create = async (ctx: Context): Promise => { // css compilation let css = await tasks.css; + if (!css.ok) throw css.error; // css bundle let cssBundleHref = - css.bundle && + css.value.bundle && ctx.config.publicPath + path.relative( ctx.config.assetsBuildDirectory, - path.resolve(css.bundle.path) + path.resolve(css.value.bundle.path) ); channels.cssBundleHref.ok(cssBundleHref); - if (css.bundle) { - writes.cssBundle = CSS.writeBundle(ctx, css.outputFiles); + if (css.value.bundle) { + writes.cssBundle = CSS.writeBundle(ctx, css.value.outputFiles); } // js compilation (implicitly writes artifacts/js) // TODO: js task should not return metafile, but rather js assets - let { metafile, hmr } = await tasks.js; + let js = await tasks.js; + if (!js.ok) throw js.error; + let { metafile, hmr } = js.value; // artifacts/manifest let manifest = await createManifest({ @@ -103,18 +101,17 @@ export let create = async (ctx: Context): Promise => { writes.manifest = writeManifest(ctx.config, manifest); // server compilation - let serverFiles = await tasks.server; + let server = await tasks.server; + if (!server.ok) throw server.error; // artifacts/server - writes.server = Server.write(ctx.config, serverFiles); + writes.server = Server.write(ctx.config, server.value); await Promise.all(Object.values(writes)); return manifest; }; return { compile, - cancel: async () => { - await Promise.all(Object.values(subcompiler).map((sub) => sub.cancel())); - }, + cancel, dispose: async () => { await Promise.all(Object.values(subcompiler).map((sub) => sub.dispose())); }, diff --git a/packages/remix-dev/compiler/js/compiler.ts b/packages/remix-dev/compiler/js/compiler.ts index 3f527c004bb..bb77e352222 100644 --- a/packages/remix-dev/compiler/js/compiler.ts +++ b/packages/remix-dev/compiler/js/compiler.ts @@ -22,7 +22,7 @@ import { vanillaExtractPlugin } from "../plugins/vanillaExtract"; import invariant from "../../invariant"; import { hmrPlugin } from "./plugins/hmr"; import { createMatchPath } from "../utils/tsconfig"; -import { getPreferredPackageManager } from "../../cli/getPreferredPackageManager"; +import { detectPackageManager } from "../../cli/detectPackageManager"; import type * as Channel from "../../channel"; import type { Context } from "../context"; @@ -145,7 +145,7 @@ const createEsbuildConfig = ( } let packageName = getNpmPackageName(args.path); - let pkgManager = getPreferredPackageManager(); + let pkgManager = detectPackageManager() ?? "npm"; if ( ctx.options.onWarning && !isNodeBuiltIn(packageName) && diff --git a/packages/remix-dev/compiler/options.ts b/packages/remix-dev/compiler/options.ts index 21cb13d489c..d2f9dc979ec 100644 --- a/packages/remix-dev/compiler/options.ts +++ b/packages/remix-dev/compiler/options.ts @@ -2,7 +2,14 @@ type Mode = "development" | "production" | "test"; export type Options = { mode: Mode; - liveReloadPort?: number; sourcemap: boolean; onWarning?: (message: string, key: string) => void; + + // TODO: required in v2 + devHttpOrigin?: { + scheme: string; + host: string; + port: number; + }; + devWebsocketPort?: number; }; diff --git a/packages/remix-dev/compiler/server/compiler.ts b/packages/remix-dev/compiler/server/compiler.ts index 60c8f69d71f..3dd99e80ffe 100644 --- a/packages/remix-dev/compiler/server/compiler.ts +++ b/packages/remix-dev/compiler/server/compiler.ts @@ -98,9 +98,13 @@ const createEsbuildConfig = ( publicPath: ctx.config.publicPath, define: { "process.env.NODE_ENV": JSON.stringify(ctx.options.mode), + // TODO: remove REMIX_DEV_SERVER_WS_PORT in v2 "process.env.REMIX_DEV_SERVER_WS_PORT": JSON.stringify( ctx.config.devServerPort ), + "process.env.REMIX_DEV_HTTP_ORIGIN": JSON.stringify( + ctx.options.devHttpOrigin ?? "" // TODO: remove nullish check in v2 + ), }, jsx: "automatic", jsxDev: ctx.options.mode !== "production", diff --git a/packages/remix-dev/compiler/server/plugins/bareImports.ts b/packages/remix-dev/compiler/server/plugins/bareImports.ts index bcac6a884a3..075361fa6bf 100644 --- a/packages/remix-dev/compiler/server/plugins/bareImports.ts +++ b/packages/remix-dev/compiler/server/plugins/bareImports.ts @@ -10,7 +10,7 @@ import { } from "../virtualModules"; import { isCssSideEffectImportPath } from "../../plugins/cssSideEffectImports"; import { createMatchPath } from "../../utils/tsconfig"; -import { getPreferredPackageManager } from "../../../cli/getPreferredPackageManager"; +import { detectPackageManager } from "../../../cli/detectPackageManager"; import type { Context } from "../../context"; /** @@ -72,7 +72,7 @@ export function serverBareModulesPlugin({ config, options }: Context): Plugin { } let packageName = getNpmPackageName(path); - let pkgManager = getPreferredPackageManager(); + let pkgManager = detectPackageManager() ?? "npm"; // Warn if we can't find an import for a package. if ( @@ -86,7 +86,7 @@ export function serverBareModulesPlugin({ config, options }: Context): Plugin { (pkgManager === "yarn" && process.versions.pnp == null)) ) { try { - require.resolve(path); + require.resolve(path, { paths: [importer] }); } catch (error: unknown) { options.onWarning( `The path "${path}" is imported in ` + @@ -117,7 +117,12 @@ export function serverBareModulesPlugin({ config, options }: Context): Plugin { kind !== "dynamic-import" && config.serverPlatform === "node" ) { - warnOnceIfEsmOnlyPackage(packageName, path, options.onWarning); + warnOnceIfEsmOnlyPackage( + packageName, + path, + importer, + options.onWarning + ); } // Externalize everything else if we've gotten here. @@ -148,10 +153,15 @@ function isBareModuleId(id: string): boolean { function warnOnceIfEsmOnlyPackage( packageName: string, fullImportPath: string, + importer: string, onWarning: (msg: string, key: string) => void ) { try { - let packageDir = resolveModuleBasePath(packageName, fullImportPath); + let packageDir = resolveModuleBasePath( + packageName, + fullImportPath, + importer + ); let packageJsonFile = path.join(packageDir, "package.json"); if (!fs.existsSync(packageJsonFile)) { @@ -193,8 +203,14 @@ function warnOnceIfEsmOnlyPackage( // https://github.com/nodejs/node/issues/33460#issuecomment-919184789 // adapted to use the fullImportPath to resolve sub packages like @heroicons/react/solid -function resolveModuleBasePath(packageName: string, fullImportPath: string) { - let moduleMainFilePath = require.resolve(fullImportPath); +function resolveModuleBasePath( + packageName: string, + fullImportPath: string, + importer: string +) { + let moduleMainFilePath = require.resolve(fullImportPath, { + paths: [importer], + }); let packageNameParts = packageName.split("/"); diff --git a/packages/remix-dev/compiler/server/plugins/entry.ts b/packages/remix-dev/compiler/server/plugins/entry.ts index ecf4922cfee..76b16114d5e 100644 --- a/packages/remix-dev/compiler/server/plugins/entry.ts +++ b/packages/remix-dev/compiler/server/plugins/entry.ts @@ -51,9 +51,9 @@ ${Object.keys(config.routes) export const publicPath = ${JSON.stringify(config.publicPath)}; export const entry = { module: entryServer }; ${ - options.liveReloadPort + options.devWebsocketPort ? `export const dev = ${JSON.stringify({ - liveReloadPort: options.liveReloadPort, + websocketPort: options.devWebsocketPort, })}` : "" } diff --git a/packages/remix-dev/compiler/watch.ts b/packages/remix-dev/compiler/watch.ts index 4e0b2452f3d..2b0461850ce 100644 --- a/packages/remix-dev/compiler/watch.ts +++ b/packages/remix-dev/compiler/watch.ts @@ -21,28 +21,26 @@ function isEntryPoint(config: RemixConfig, file: string): boolean { export type WatchOptions = { reloadConfig?(root: string): Promise; - onRebuildStart?(): void; - onRebuildFinish?(durationMs: number, manifest?: Manifest): void; + onBuildStart?(ctx: Context): void; + onBuildFinish?(ctx: Context, durationMs: number, manifest?: Manifest): void; onFileCreated?(file: string): void; onFileChanged?(file: string): void; onFileDeleted?(file: string): void; - onInitialBuild?(durationMs: number, manifest?: Manifest): void; }; export async function watch( - { config, options }: Context, + ctx: Context, { reloadConfig = readConfig, - onRebuildStart, - onRebuildFinish, + onBuildStart, + onBuildFinish, onFileCreated, onFileChanged, onFileDeleted, - onInitialBuild, }: WatchOptions = {} ): Promise<() => Promise> { let start = Date.now(); - let compiler = await Compiler.create({ config, options }); + let compiler = await Compiler.create(ctx); let compile = () => compiler.compile().catch((thrown) => { logThrown(thrown); @@ -50,39 +48,40 @@ export async function watch( }); // initial build + onBuildStart?.(ctx); let manifest = await compile(); - onInitialBuild?.(Date.now() - start, manifest); + onBuildFinish?.(ctx, Date.now() - start, manifest); let restart = debounce(async () => { - onRebuildStart?.(); + onBuildStart?.(ctx); let start = Date.now(); compiler.dispose(); try { - config = await reloadConfig(config.rootDirectory); + ctx.config = await reloadConfig(ctx.config.rootDirectory); } catch (thrown: unknown) { logThrown(thrown); return; } - compiler = await Compiler.create({ config, options }); + compiler = await Compiler.create(ctx); let manifest = await compile(); - onRebuildFinish?.(Date.now() - start, manifest); + onBuildFinish?.(ctx, Date.now() - start, manifest); }, 500); let rebuild = debounce(async () => { - onRebuildStart?.(); + onBuildStart?.(ctx); let start = Date.now(); let manifest = await compile(); - onRebuildFinish?.(Date.now() - start, manifest); + onBuildFinish?.(ctx, Date.now() - start, manifest); }, 100); - let toWatch = [config.appDirectory]; - if (config.serverEntryPoint) { - toWatch.push(config.serverEntryPoint); + let toWatch = [ctx.config.appDirectory]; + if (ctx.config.serverEntryPoint) { + toWatch.push(ctx.config.serverEntryPoint); } - config.watchPaths?.forEach((watchPath) => { + ctx.config.watchPaths?.forEach((watchPath) => { toWatch.push(watchPath); }); @@ -104,17 +103,17 @@ export async function watch( onFileCreated?.(file); try { - config = await reloadConfig(config.rootDirectory); + ctx.config = await reloadConfig(ctx.config.rootDirectory); } catch (thrown: unknown) { logThrown(thrown); return; } - await (isEntryPoint(config, file) ? restart : rebuild)(); + await (isEntryPoint(ctx.config, file) ? restart : rebuild)(); }) .on("unlink", async (file) => { onFileDeleted?.(file); - await (isEntryPoint(config, file) ? restart : rebuild)(); + await (isEntryPoint(ctx.config, file) ? restart : rebuild)(); }); return async () => { diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 8b4a0a0fec6..55aabcdf111 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -8,12 +8,11 @@ import { coerce } from "semver"; import type { RouteManifest, DefineRoutesFunction } from "./config/routes"; import { defineRoutes } from "./config/routes"; -import { defineConventionalRoutes } from "./config/routesConvention"; import { ServerMode, isValidServerMode } from "./config/serverModes"; import { writeConfigDefaults } from "./config/write-tsconfig-defaults"; import { serverBuildVirtualModule } from "./compiler/server/virtualModules"; import { flatRoutes } from "./config/flat-routes"; -import { getPreferredPackageManager } from "./cli/getPreferredPackageManager"; +import { detectPackageManager } from "./cli/detectPackageManager"; import { warnOnce } from "./warnOnce"; export interface RemixMdxConfig { @@ -38,10 +37,14 @@ export type ServerModuleFormat = "esm" | "cjs"; export type ServerPlatform = "node" | "neutral"; type Dev = { - port?: number; - appServerPort?: number; - remixRequestHandlerPath?: string; - rebuildPollIntervalMs?: number; + port?: number; // TODO: remove in v2 + + command?: string; + httpScheme?: string; + httpHost?: string; + httpPort?: number; + websocketPort?: number; + restart?: boolean; }; interface FutureConfig { @@ -422,22 +425,6 @@ export async function readConfig( } } - if (appConfig.serverBuildTarget) { - warnOnce(serverBuildTargetWarning, "v2_serverBuildTarget"); - } - - if (!appConfig.future?.v2_errorBoundary) { - warnOnce(errorBoundaryWarning, "v2_errorBoundary"); - } - - if (!appConfig.future?.v2_normalizeFormMethod) { - warnOnce(formMethodWarning, "v2_normalizeFormMethod"); - } - - if (!appConfig.future?.v2_meta) { - warnOnce(metaWarning, "v2_meta"); - } - let isCloudflareRuntime = ["cloudflare-pages", "cloudflare-workers"].includes( appConfig.serverBuildTarget ?? "" ); @@ -453,6 +440,7 @@ export async function readConfig( let serverEntryPoint = appConfig.server; let serverMainFields = appConfig.serverMainFields; let serverMinify = appConfig.serverMinify; + let serverModuleFormat = appConfig.serverModuleFormat || "cjs"; let serverPlatform = appConfig.serverPlatform || "node"; if (isCloudflareRuntime) { @@ -474,34 +462,43 @@ export async function readConfig( serverModuleFormat === "esm" ? ["module", "main"] : ["main", "module"]; serverMinify ??= false; - if (appConfig.future) { - if ("unstable_cssModules" in appConfig.future) { - warnOnce( - 'The "future.unstable_cssModules" config option has been removed as this feature is now enabled automatically.' - ); - } - - if ("unstable_cssSideEffectImports" in appConfig.future) { - warnOnce( - 'The "future.unstable_cssSideEffectImports" config option has been removed as this feature is now enabled automatically.' - ); - } - - if ("unstable_vanillaExtract" in appConfig.future) { - warnOnce( - 'The "future.unstable_vanillaExtract" config option has been removed as this feature is now enabled automatically.' - ); - } + let future: FutureConfig = { + unstable_dev: appConfig.future?.unstable_dev ?? false, + unstable_postcss: appConfig.future?.unstable_postcss === true, + unstable_tailwind: appConfig.future?.unstable_tailwind === true, + v2_errorBoundary: appConfig.future?.v2_errorBoundary === true, + v2_meta: appConfig.future?.v2_meta === true, + v2_normalizeFormMethod: appConfig.future?.v2_normalizeFormMethod === true, + v2_routeConvention: appConfig.future?.v2_routeConvention === true, + }; - if (appConfig.future.unstable_postcss !== undefined) { - warnOnce( - 'The "future.unstable_postcss" config option has been deprecated as this feature is now considered stable. Use the "postcss" config option instead.' - ); - } + if (appConfig.future) { + let deprecatedFutureFlags = [ + "unstable_cssModules", + "unstable_cssSideEffectImports", + "unstable_dev", + "unstable_postcss", + "unstable_tailwind", + "unstable_vanillaExtract", + "v2_errorBoundary", + "v2_meta", + "v2_normalizeFormMethod", + "v2_routeConvention", + "v2_serverBuildTarget", + ] as const; + + let usedFutureFlags = deprecatedFutureFlags.filter( + (f) => f in appConfig.future! + ); - if (appConfig.future.unstable_tailwind !== undefined) { + if (usedFutureFlags.length > 0) { warnOnce( - 'The "future.unstable_tailwind" config option has been deprecated as this feature is now considered stable. Use the "tailwind" config option instead.' + `⚠️ REMIX FUTURE CHANGE: the following Remix future flags can be removed from your remix.config + ${usedFutureFlags.map((f) => `- ${f}`).join("\n")} + ` + .split("\n") + .map((line) => line.trim()) + .join("\n") ); } } @@ -592,7 +589,7 @@ export async function readConfig( await pkgJson.save(); - let packageManager = getPreferredPackageManager(); + let packageManager = detectPackageManager() ?? "npm"; execSync(`${packageManager} install`, { cwd: remixRoot, @@ -639,10 +636,6 @@ export async function readConfig( ? path.resolve(appDirectory, userEntryServerFile) : path.resolve(defaultsDirectory, entryServerFile); - if (appConfig.browserBuildDirectory) { - warnOnce(browserBuildDirectoryWarning, "browserBuildDirectory"); - } - let assetsBuildDirectory = appConfig.assetsBuildDirectory || appConfig.browserBuildDirectory || @@ -670,20 +663,11 @@ export async function readConfig( } let routes: RouteManifest = { - root: { path: "", id: "root", file: rootRouteFile }, + root: { id: "root", file: rootRouteFile }, }; - let routesConvention: typeof flatRoutes; - - if (appConfig.future?.v2_routeConvention) { - routesConvention = flatRoutes; - } else { - warnOnce(flatRoutesWarning, "v2_routeConvention"); - routesConvention = defineConventionalRoutes; - } - if (fse.existsSync(path.resolve(appDirectory, "routes"))) { - let conventionalRoutes = routesConvention( + let conventionalRoutes = flatRoutes( appDirectory, appConfig.ignoredRouteFiles ); @@ -728,16 +712,6 @@ export async function readConfig( writeConfigDefaults(tsconfigPath); } - let future: FutureConfig = { - unstable_dev: appConfig.future?.unstable_dev ?? false, - unstable_postcss: appConfig.future?.unstable_postcss === true, - unstable_tailwind: appConfig.future?.unstable_tailwind === true, - v2_errorBoundary: appConfig.future?.v2_errorBoundary === true, - v2_meta: appConfig.future?.v2_meta === true, - v2_normalizeFormMethod: appConfig.future?.v2_normalizeFormMethod === true, - v2_routeConvention: appConfig.future?.v2_routeConvention === true, - }; - return { appDirectory, cacheDirectory, @@ -824,13 +798,6 @@ const resolveServerBuildPath = ( break; } - // retain deprecated behavior for now - if (appConfig.serverBuildDirectory) { - warnOnce(serverBuildDirectoryWarning, "serverBuildDirectory"); - - serverBuildPath = path.join(appConfig.serverBuildDirectory, "index.js"); - } - if (appConfig.serverBuildPath) { serverBuildPath = appConfig.serverBuildPath; } @@ -874,46 +841,3 @@ let disjunctionListFormat = new Intl.ListFormat("en", { style: "long", type: "disjunction", }); - -export let browserBuildDirectoryWarning = - "⚠️ REMIX FUTURE CHANGE: The `browserBuildDirectory` config option will be removed in v2. " + - "Use `assetsBuildDirectory` instead. " + - "For instructions on making this change see " + - "https://remix.run/docs/en/v1.15.0/pages/v2#browserbuilddirectory"; - -export let serverBuildDirectoryWarning = - "⚠️ REMIX FUTURE CHANGE: The `serverBuildDirectory` config option will be removed in v2. " + - "Use `serverBuildPath` instead. " + - "For instructions on making this change see " + - "https://remix.run/docs/en/v1.15.0/pages/v2#serverbuilddirectory"; - -export let serverBuildTargetWarning = - "⚠️ REMIX FUTURE CHANGE: The `serverBuildTarget` config option will be removed in v2. " + - "Use a combination of server module config values to achieve the same build output. " + - "For instructions on making this change see " + - "https://remix.run/docs/en/v1.15.0/pages/v2#serverbuildtarget"; - -export let flatRoutesWarning = - "⚠️ REMIX FUTURE CHANGE: The route file convention is changing in v2. " + - "You can prepare for this change at your convenience with the `v2_routeConvention` future flag. " + - "For instructions on making this change see " + - "https://remix.run/docs/en/v1.15.0/pages/v2#file-system-route-convention"; - -export const errorBoundaryWarning = - "⚠️ REMIX FUTURE CHANGE: The behaviors of `CatchBoundary` and `ErrorBoundary` are changing in v2. " + - "You can prepare for this change at your convenience with the `v2_errorBoundary` future flag. " + - "For instructions on making this change see " + - "https://remix.run/docs/en/v1.15.0/pages/v2#catchboundary-and-errorboundary"; - -export const formMethodWarning = - "⚠️ REMIX FUTURE CHANGE: APIs that provide `formMethod` will be changing in v2. " + - "All values will be uppercase (GET, POST, etc.) instead of lowercase (get, post, etc.) " + - "You can prepare for this change at your convenience with the `v2_normalizeFormMethod` future flag. " + - "For instructions on making this change see " + - "https://remix.run/docs/en/v1.15.0/pages/v2#formMethod"; - -export const metaWarning = - "⚠️ REMIX FUTURE CHANGE: The route `meta` export signature is changing in v2. " + - "You can prepare for this change at your convenience with the `v2_meta` future flag. " + - "For instructions on making this change see " + - "https://remix.run/docs/en/v1.15.0/pages/v2#meta"; diff --git a/packages/remix-dev/devServer/liveReload.ts b/packages/remix-dev/devServer/liveReload.ts index 8fa86178369..c09f48887a4 100644 --- a/packages/remix-dev/devServer/liveReload.ts +++ b/packages/remix-dev/devServer/liveReload.ts @@ -4,7 +4,6 @@ import path from "path"; import prettyMs from "pretty-ms"; import WebSocket from "ws"; -import type { WatchOptions } from "../compiler"; import { watch } from "../compiler"; import type { RemixConfig } from "../config"; import { warnOnce } from "../warnOnce"; @@ -19,10 +18,7 @@ let clean = (config: RemixConfig) => { } }; -export async function liveReload( - config: RemixConfig, - { onInitialBuild }: WatchOptions = {} -) { +export async function liveReload(config: RemixConfig) { clean(config); let wss = new WebSocket.Server({ port: config.devServerPort }); function broadcast(event: { type: string } & Record) { @@ -41,6 +37,7 @@ export async function liveReload( broadcast({ type: "LOG", message: _message }); } + let hasBuilt = false; let dispose = await watch( { config, @@ -51,13 +48,14 @@ export async function liveReload( }, }, { - onInitialBuild, - onRebuildStart() { + onBuildStart() { clean(config); - log("Rebuilding..."); + log((hasBuilt ? "Rebuilding" : "Building") + "..."); }, - onRebuildFinish(durationMs: number) { - log(`Rebuilt in ${prettyMs(durationMs)}`); + onBuildFinish(_, durationMs: number, manifest) { + if (manifest === undefined) return; + hasBuilt = true; + log((hasBuilt ? "Rebuilt" : "Built") + ` in ${prettyMs(durationMs)}`); broadcast({ type: "RELOAD" }); }, onFileCreated(file) { diff --git a/packages/remix-dev/devServer_unstable/env.ts b/packages/remix-dev/devServer_unstable/env.ts index 5999d6d3b40..9f9df9c4ff0 100644 --- a/packages/remix-dev/devServer_unstable/env.ts +++ b/packages/remix-dev/devServer_unstable/env.ts @@ -4,15 +4,9 @@ import * as path from "path"; // Import environment variables from: .env, failing gracefully if it doesn't exist export async function loadEnv(rootDirectory: string): Promise { let envPath = path.join(rootDirectory, ".env"); - try { - await fse.readFile(envPath); - } catch { - return; - } + if (!fse.existsSync(envPath)) return; console.log(`Loading environment variables from .env`); let result = require("dotenv").config({ path: envPath }); - if (result.error) { - throw result.error; - } + if (result.error) throw result.error; } diff --git a/packages/remix-dev/devServer_unstable/index.ts b/packages/remix-dev/devServer_unstable/index.ts index d62d2a652c9..df19f1da0e7 100644 --- a/packages/remix-dev/devServer_unstable/index.ts +++ b/packages/remix-dev/devServer_unstable/index.ts @@ -1,173 +1,210 @@ -import exitHook from "exit-hook"; import fs from "fs-extra"; -import getPort, { makeRange } from "get-port"; -import os from "os"; import path from "node:path"; import prettyMs from "pretty-ms"; -import fetch from "node-fetch"; +import execa from "execa"; +import express from "express"; +import * as Channel from "../channel"; import { type Manifest } from "../manifest"; import * as Compiler from "../compiler"; -import { type RemixConfig } from "../config"; +import { readConfig, type RemixConfig } from "../config"; import { loadEnv } from "./env"; -import * as LiveReload from "./liveReload"; +import * as Socket from "./socket"; import * as HMR from "./hmr"; import { warnOnce } from "../warnOnce"; +import { detectPackageManager } from "../cli/detectPackageManager"; -let info = (message: string) => console.info(`💿 ${message}`); - -let relativePath = (file: string) => path.relative(process.cwd(), file); - -let sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -let clean = (config: RemixConfig) => { - try { - fs.emptyDirSync(config.relativeAssetsBuildDirectory); - } catch { - // ignore failed clean up attempts - } +type Origin = { + scheme: string; + host: string; + port: number; }; -let getHost = () => - process.env.HOST ?? - Object.values(os.networkInterfaces()) - .flat() - .find((ip) => String(ip?.family).includes("4") && !ip?.internal)?.address; - -let findPort = async (portPreference?: number) => - getPort({ - port: - // prettier-ignore - portPreference ? Number(portPreference) : - process.env.PORT ? Number(process.env.PORT) : - makeRange(3001, 3100), - }); - -let fetchAssetsManifest = async ( - origin: string, - remixRequestHandlerPath: string -): Promise => { - try { - let url = origin + remixRequestHandlerPath + "/__REMIX_ASSETS_MANIFEST"; - let res = await fetch(url); - let assetsManifest = (await res.json()) as Manifest; - return assetsManifest; - } catch (error) { - return undefined; - } -}; - -let resolveDev = ( - dev: RemixConfig["future"]["unstable_dev"], - flags: { port?: number; appServerPort?: number } -) => { - if (dev === false) - throw Error("The new dev server requires 'unstable_dev' to be set"); - - let port = flags.port ?? (dev === true ? undefined : dev.port); - - let appServerPort = - flags.appServerPort ?? (dev === true || dev.appServerPort == undefined) - ? 3000 - : dev.appServerPort; - let remixRequestHandlerPath = - dev === true || dev.remixRequestHandlerPath === undefined - ? "" - : dev.remixRequestHandlerPath; - let rebuildPollIntervalMs = - dev === true || dev.rebuildPollIntervalMs === undefined - ? 50 - : dev.rebuildPollIntervalMs; +let stringifyOrigin = (o: Origin) => `${o.scheme}://${o.host}:${o.port}`; +let patchPublicPath = ( + config: RemixConfig, + devHttpOrigin: Origin +): RemixConfig => { + // set public path to point to dev server + // so that browser asks the dev server for assets return { - port, - appServerPort, - remixRequestHandlerPath, - rebuildPollIntervalMs, + ...config, + // dev server has its own origin, to `/build/` path will not cause conflicts with app server routes + publicPath: stringifyOrigin(devHttpOrigin) + "/build/", }; }; +let detectBin = async (): Promise => { + let pkgManager = detectPackageManager() ?? "npm"; + if (pkgManager === "npm") { + // npm v9 removed the `bin` command, so have to use `prefix` + let { stdout } = await execa(pkgManager, ["prefix"]); + return stdout.trim() + "/node_modules/.bin"; + } + let { stdout } = await execa(pkgManager, ["bin"]); + return stdout.trim(); +}; + export let serve = async ( - config: RemixConfig, - flags: { port?: number; appServerPort?: number } = {} + initialConfig: RemixConfig, + options: { + command: string; + httpScheme: string; + httpHost: string; + httpPort: number; + websocketPort: number; + restart: boolean; + } ) => { - clean(config); - await loadEnv(config.rootDirectory); - - let dev = resolveDev(config.future.unstable_dev, flags); - - let host = getHost(); - let appServerOrigin = `http://${host ?? "localhost"}:${dev.appServerPort}`; - - let waitForAppServer = async (buildHash: string) => { - while (true) { - // TODO AbortController signal to cancel responses? - let assetsManifest = await fetchAssetsManifest( - appServerOrigin, - dev.remixRequestHandlerPath - ); - if (assetsManifest?.version === buildHash) return; + await loadEnv(initialConfig.rootDirectory); + let websocket = Socket.serve({ port: options.websocketPort }); + let httpOrigin: Origin = { + scheme: options.httpScheme, + host: options.httpHost, + port: options.httpPort, + }; - await sleep(dev.rebuildPollIntervalMs); - } + let state: { + latestBuildHash?: string; + buildHashChannel?: Channel.Type; + appServer?: execa.ExecaChildProcess; + prevManifest?: Manifest; + } = {}; + + let bin = await detectBin(); + let startAppServer = (command: string) => { + console.log(`> ${command}`); + return execa.command(command, { + stdio: "inherit", + env: { + NODE_ENV: "development", + PATH: `${bin}:${process.env.PATH}`, + REMIX_DEV_HTTP_ORIGIN: stringifyOrigin(httpOrigin), + }, + }); }; - // watch and live reload on rebuilds - let port = await findPort(dev.port); - let socket = LiveReload.serve({ port }); - let prevManifest: Manifest | undefined = undefined; let dispose = await Compiler.watch( { - config, + config: patchPublicPath(initialConfig, httpOrigin), options: { mode: "development", - liveReloadPort: port, sourcemap: true, onWarning: warnOnce, + devHttpOrigin: httpOrigin, + devWebsocketPort: options.websocketPort, }, }, { - onInitialBuild: (durationMs, manifest) => { - info(`Built in ${prettyMs(durationMs)}`); - prevManifest = manifest; + reloadConfig: async (root) => { + let config = await readConfig(root); + return patchPublicPath(config, httpOrigin); }, - onRebuildStart: () => { - clean(config); - socket.log("Rebuilding..."); + onBuildStart: (ctx) => { + state.buildHashChannel?.err(); + clean(ctx.config); + websocket.log(state.prevManifest ? "Rebuilding..." : "Building..."); }, - onRebuildFinish: async (durationMs, manifest) => { + onBuildFinish: async (ctx, durationMs, manifest) => { if (!manifest) return; - socket.log(`Rebuilt in ${prettyMs(durationMs)}`); - info(`Waiting for ${appServerOrigin}...`); + websocket.log( + (state.prevManifest ? "Rebuilt" : "Built") + + ` in ${prettyMs(durationMs)}` + ); + let prevManifest = state.prevManifest; + state.prevManifest = manifest; + state.latestBuildHash = manifest.version; + state.buildHashChannel = Channel.create(); + let start = Date.now(); - await waitForAppServer(manifest.version); - info(`${appServerOrigin} ready in ${prettyMs(Date.now() - start)}`); - await new Promise((resolve) => { - setTimeout(resolve, -1); - }); + console.log(`Waiting for app server (${state.latestBuildHash})`); + if ( + options.command && + (state.appServer === undefined || options.restart) + ) { + await kill(state.appServer); + state.appServer = startAppServer(options.command); + } + let { ok } = await state.buildHashChannel.result; + // result not ok -> new build started before this one finished. do not process outdated manifest + if (!ok) return; + console.log(`App server took ${prettyMs(Date.now() - start)}`); if (manifest.hmr && prevManifest) { - let updates = HMR.updates(config, manifest, prevManifest); - socket.hmr(manifest, updates); - } else { - socket.reload(); + let updates = HMR.updates(ctx.config, manifest, prevManifest); + websocket.hmr(manifest, updates); + + let hdr = updates.some((u) => u.revalidate); + console.log("> HMR" + (hdr ? " + HDR" : "")); + } else if (prevManifest !== undefined) { + websocket.reload(); + console.log("> Live reload"); } - prevManifest = manifest; }, onFileCreated: (file) => - socket.log(`File created: ${relativePath(file)}`), + websocket.log(`File created: ${relativePath(file)}`), onFileChanged: (file) => - socket.log(`File changed: ${relativePath(file)}`), + websocket.log(`File changed: ${relativePath(file)}`), onFileDeleted: (file) => - socket.log(`File deleted: ${relativePath(file)}`), + websocket.log(`File deleted: ${relativePath(file)}`), } ); - // clean up build directories when dev server exits - exitHook(() => clean(config)); - return async () => { + let httpServer = express() + // statically serve built assets + .use((_, res, next) => { + res.header("Access-Control-Allow-Origin", "*"); + next(); + }) + .use( + "/build", + express.static(initialConfig.assetsBuildDirectory, { + immutable: true, + maxAge: "1y", + }) + ) + + // handle `devReady` messages + .use(express.json()) + .post("/ping", (req, res) => { + let { buildHash } = req.body; + if (typeof buildHash !== "string") { + console.warn(`Unrecognized payload: ${req.body}`); + res.sendStatus(400); + } + if (buildHash === state.latestBuildHash) { + state.buildHashChannel?.ok(); + } + res.sendStatus(200); + }) + .listen(httpOrigin.port, () => { + console.log("Remix dev server ready"); + }); + + return new Promise(() => {}).finally(async () => { + await kill(state.appServer); + websocket.close(); + httpServer.close(); await dispose(); - socket.close(); - }; + }); +}; + +let clean = (config: RemixConfig) => { + try { + fs.emptyDirSync(config.relativeAssetsBuildDirectory); + } catch {} +}; + +let relativePath = (file: string) => path.relative(process.cwd(), file); + +let kill = async (p?: execa.ExecaChildProcess) => { + if (p === undefined) return; + // `execa`'s `kill` is not reliable on windows + if (process.platform === "win32") { + await execa("taskkill", ["/pid", String(p.pid), "/f", "/t"]); + return; + } + p.kill(); }; diff --git a/packages/remix-dev/devServer_unstable/liveReload.ts b/packages/remix-dev/devServer_unstable/socket.ts similarity index 95% rename from packages/remix-dev/devServer_unstable/liveReload.ts rename to packages/remix-dev/devServer_unstable/socket.ts index 919d7ca2aa8..83ea95fb5ca 100644 --- a/packages/remix-dev/devServer_unstable/liveReload.ts +++ b/packages/remix-dev/devServer_unstable/socket.ts @@ -25,17 +25,17 @@ export let serve = (options: { port: number }) => { }); }; - let reload = () => broadcast({ type: "RELOAD" }); - let log = (messageText: string) => { let _message = `💿 ${messageText}`; console.log(_message); broadcast({ type: "LOG", message: _message }); }; + let reload = () => broadcast({ type: "RELOAD" }); + let hmr = (assetsManifest: Manifest, updates: HMR.Update[]) => { broadcast({ type: "HMR", assetsManifest, updates }); }; - return { reload, hmr, log, close: wss.close }; + return { log, reload, hmr, close: wss.close }; }; diff --git a/packages/remix-dev/result.ts b/packages/remix-dev/result.ts new file mode 100644 index 00000000000..eaa82557b6b --- /dev/null +++ b/packages/remix-dev/result.ts @@ -0,0 +1,7 @@ +type Ok = { ok: true; value: V }; +type Err = { ok: false; error: E }; + +export type Result = Ok | Err; + +export let ok = (value: V): Ok => ({ ok: true, value }); +export let err = (error: E): Err => ({ ok: false, error }); diff --git a/packages/remix-express/__tests__/server-test.ts b/packages/remix-express/__tests__/server-test.ts index 1e50480ce64..964c89a251b 100644 --- a/packages/remix-express/__tests__/server-test.ts +++ b/packages/remix-express/__tests__/server-test.ts @@ -107,9 +107,7 @@ describe("express createRequestHandler", () => { }); let request = supertest(createApp()); - // note: vercel's createServerWithHelpers requires a x-now-bridge-request-id - let res = await request.get("/").set({ "x-now-bridge-request-id": "2" }); - + let res = await request.get("/"); expect(res.status).toBe(200); expect(res.text).toBe("hello world"); }); @@ -159,88 +157,41 @@ describe("express createRequestHandler", () => { describe("express createRemixHeaders", () => { describe("creates fetch headers from express headers", () => { it("handles empty headers", () => { - expect(createRemixHeaders({})).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({}); + expect(headers.raw()).toMatchInlineSnapshot(`Object {}`); }); it("handles simple headers", () => { - expect(createRemixHeaders({ "x-foo": "bar" })).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ "x-foo": "bar" }); + expect(headers.get("x-foo")).toBe("bar"); }); it("handles multiple headers", () => { - expect(createRemixHeaders({ "x-foo": "bar", "x-bar": "baz" })) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar", - "x-bar", - "baz", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ "x-foo": "bar", "x-bar": "baz" }); + expect(headers.get("x-foo")).toBe("bar"); + expect(headers.get("x-bar")).toBe("baz"); }); it("handles headers with multiple values", () => { - expect(createRemixHeaders({ "x-foo": "bar, baz" })) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar, baz", - ], - Symbol(context): null, - } - `); - }); - - it("handles headers with multiple values and multiple headers", () => { - expect(createRemixHeaders({ "x-foo": "bar, baz", "x-bar": "baz" })) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar, baz", - "x-bar", - "baz", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ + "x-foo": ["bar", "baz"], + "x-bar": "baz", + }); + expect(headers.getAll("x-foo")).toEqual(["bar", "baz"]); + expect(headers.get("x-bar")).toBe("baz"); }); it("handles multiple set-cookie headers", () => { - expect( - createRemixHeaders({ - "set-cookie": [ - "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", - "__other=some_other_value; Path=/; Secure; HttpOnly; MaxAge=3600; SameSite=Lax", - ], - }) - ).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "set-cookie", - "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", - "set-cookie", - "__other=some_other_value; Path=/; Secure; HttpOnly; MaxAge=3600; SameSite=Lax", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ + "set-cookie": [ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", + ], + }); + expect(headers.getAll("set-cookie")).toEqual([ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", + ]); }); }); }); @@ -259,41 +210,12 @@ describe("express createRemixRequest", () => { }); let expressResponse = createResponse(); - expect(createRemixRequest(expressRequest, expressResponse)) - .toMatchInlineSnapshot(` - NodeRequest { - "agent": undefined, - "compress": true, - "counter": 0, - "follow": 20, - "highWaterMark": 16384, - "insecureHTTPParser": false, - "size": 0, - Symbol(Body internals): Object { - "body": null, - "boundary": null, - "disturbed": false, - "error": null, - "size": 0, - "type": null, - }, - Symbol(Request internals): Object { - "credentials": "same-origin", - "headers": Headers { - Symbol(query): Array [ - "cache-control", - "max-age=300, s-maxage=3600", - "host", - "localhost:3000", - ], - Symbol(context): null, - }, - "method": "GET", - "parsedURL": "http://localhost:3000/foo/bar", - "redirect": "follow", - "signal": AbortSignal {}, - }, - } - `); + let remixRequest = createRemixRequest(expressRequest, expressResponse); + + expect(remixRequest.method).toBe("GET"); + expect(remixRequest.headers.get("cache-control")).toBe( + "max-age=300, s-maxage=3600" + ); + expect(remixRequest.headers.get("host")).toBe("localhost:3000"); }); }); diff --git a/packages/remix-express/server.ts b/packages/remix-express/server.ts index 009652fd36c..76abf32c5ee 100644 --- a/packages/remix-express/server.ts +++ b/packages/remix-express/server.ts @@ -24,7 +24,7 @@ import { export type GetLoadContextFunction = ( req: express.Request, res: express.Response -) => AppLoadContext; +) => Promise | AppLoadContext; export type RequestHandler = ( req: express.Request, @@ -53,7 +53,7 @@ export function createRequestHandler({ ) => { try { let request = createRemixRequest(req, res); - let loadContext = getLoadContext?.(req, res); + let loadContext = await getLoadContext?.(req, res); let response = (await handleRequest( request, diff --git a/packages/remix-netlify/__tests__/server-test.ts b/packages/remix-netlify/__tests__/server-test.ts index 794c088a85b..80fc451de8b 100644 --- a/packages/remix-netlify/__tests__/server-test.ts +++ b/packages/remix-netlify/__tests__/server-test.ts @@ -221,145 +221,53 @@ describe("netlify createRequestHandler", () => { describe("netlify createRemixHeaders", () => { describe("creates fetch headers from netlify headers", () => { it("handles empty headers", () => { - expect(createRemixHeaders({})).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({}); + expect(headers.raw()).toMatchInlineSnapshot(`Object {}`); }); it("handles simple headers", () => { - expect(createRemixHeaders({ "x-foo": ["bar"] })).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ "x-foo": ["bar"] }); + expect(headers.get("x-foo")).toBe("bar"); }); it("handles multiple headers", () => { - expect(createRemixHeaders({ "x-foo": ["bar"], "x-bar": ["baz"] })) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar", - "x-bar", - "baz", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ "x-foo": ["bar"], "x-bar": ["baz"] }); + expect(headers.get("x-foo")).toBe("bar"); + expect(headers.get("x-bar")).toBe("baz"); }); it("handles headers with multiple values", () => { - expect(createRemixHeaders({ "x-foo": ["bar", "baz"] })) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar", - "x-foo", - "baz", - ], - Symbol(context): null, - } - `); - }); - - it("handles headers with multiple values and multiple headers", () => { - expect(createRemixHeaders({ "x-foo": ["bar", "baz"], "x-bar": ["baz"] })) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar", - "x-foo", - "baz", - "x-bar", - "baz", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ + "x-foo": ["bar", "baz"], + "x-bar": ["baz"], + }); + expect(headers.getAll("x-foo")).toEqual(["bar", "baz"]); + expect(headers.get("x-bar")).toBe("baz"); }); - it("handles cookies", () => { - expect( - createRemixHeaders({ - Cookie: [ - "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", - "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", - ], - - "x-something-else": ["true"], - }) - ).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "cookie", - "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", - "cookie", - "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", - "x-something-else", - "true", - ], - Symbol(context): null, - } - `); + it("handles multiple set-cookie headers", () => { + let headers = createRemixHeaders({ + "set-cookie": [ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", + ], + }); + expect(headers.getAll("set-cookie")).toEqual([ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", + ]); }); }); }); describe("netlify createRemixRequest", () => { it("creates a request with the correct headers", () => { - expect( - createRemixRequest( - createMockEvent({ - multiValueHeaders: { - Cookie: ["__session=value", "__other=value"], - }, - }) - ) - ).toMatchInlineSnapshot(` - NodeRequest { - "agent": undefined, - "compress": true, - "counter": 0, - "follow": 20, - "highWaterMark": 16384, - "insecureHTTPParser": false, - "size": 0, - Symbol(Body internals): Object { - "body": null, - "boundary": null, - "disturbed": false, - "error": null, - "size": 0, - "type": null, - }, - Symbol(Request internals): Object { - "credentials": "same-origin", - "headers": Headers { - Symbol(query): Array [ - "cookie", - "__session=value", - "cookie", - "__other=value", - ], - Symbol(context): null, - }, - "method": "GET", - "parsedURL": "http://localhost:3000/", - "redirect": "follow", - "signal": AbortSignal {}, - }, - } - `); + let remixRequest = createRemixRequest( + createMockEvent({ multiValueHeaders: { Cookie: ["__session=value"] } }) + ); + + expect(remixRequest.method).toBe("GET"); + expect(remixRequest.headers.get("cookie")).toBe("__session=value"); }); }); diff --git a/packages/remix-netlify/server.ts b/packages/remix-netlify/server.ts index 5932d3f041d..4fe8e139cc1 100644 --- a/packages/remix-netlify/server.ts +++ b/packages/remix-netlify/server.ts @@ -30,7 +30,7 @@ import { isBinaryType } from "./binaryTypes"; export type GetLoadContextFunction = ( event: HandlerEvent, context: HandlerContext -) => AppLoadContext; +) => Promise | AppLoadContext; export type RequestHandler = Handler; @@ -47,7 +47,7 @@ export function createRequestHandler({ return async (event, context) => { let request = createRemixRequest(event); - let loadContext = getLoadContext?.(event, context); + let loadContext = await getLoadContext?.(event, context); let response = (await handleRequest(request, loadContext)) as NodeResponse; diff --git a/packages/remix-node/index.ts b/packages/remix-node/index.ts index e384ffa23b3..ec5466e6138 100644 --- a/packages/remix-node/index.ts +++ b/packages/remix-node/index.ts @@ -39,6 +39,7 @@ export { createRequestHandler, createSession, defer, + devReady, isCookie, isSession, json, diff --git a/packages/remix-node/install.d.ts b/packages/remix-node/install.d.ts new file mode 100644 index 00000000000..cb0ff5c3b54 --- /dev/null +++ b/packages/remix-node/install.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/remix-node/install.js b/packages/remix-node/install.js new file mode 100644 index 00000000000..10966624c91 --- /dev/null +++ b/packages/remix-node/install.js @@ -0,0 +1,8 @@ +/* eslint-disable */ +"use strict"; + +var globals = require("./dist/globals.js"); + +Object.defineProperty(exports, "__esModule", { value: true }); + +globals.installGlobals(); diff --git a/packages/remix-node/package.json b/packages/remix-node/package.json index 27d0ac211c2..74ef83f59d4 100644 --- a/packages/remix-node/package.json +++ b/packages/remix-node/package.json @@ -13,10 +13,12 @@ "license": "MIT", "main": "dist/index.js", "typings": "dist/index.d.ts", - "sideEffects": false, + "sideEffects": [ + "./install.js" + ], "dependencies": { "@remix-run/server-runtime": "1.15.0", - "@remix-run/web-fetch": "^4.3.2", + "@remix-run/web-fetch": "^4.3.4", "@remix-run/web-file": "^3.0.2", "@remix-run/web-stream": "^1.0.3", "@web3-storage/multipart-parser": "^1.0.0", @@ -35,6 +37,8 @@ "files": [ "dist/", "globals.d.ts", + "install.d.ts", + "install.js", "CHANGELOG.md", "LICENSE.md", "README.md" diff --git a/packages/remix-node/rollup.config.js b/packages/remix-node/rollup.config.js index bc2cef39ebb..a9ca3dd4645 100644 --- a/packages/remix-node/rollup.config.js +++ b/packages/remix-node/rollup.config.js @@ -43,6 +43,11 @@ module.exports = function rollup() { { src: "LICENSE.md", dest: [outputDir, sourceDir] }, { src: `${sourceDir}/package.json`, dest: outputDir }, { src: `${sourceDir}/README.md`, dest: outputDir }, + // This needs to end up in the root of the pkg but also needs to + // reference other compiled files. Just copying these are easier + // than dealing with output configuration for sharing chunks x-builds. + { src: `${sourceDir}/install.js`, dest: outputDir }, + { src: `${sourceDir}/install.d.ts`, dest: outputDir }, ], }), magicExportsPlugin({ packageName, version }), diff --git a/packages/remix-react/__tests__/components-test.tsx b/packages/remix-react/__tests__/components-test.tsx index d9ef31e4308..b088805a445 100644 --- a/packages/remix-react/__tests__/components-test.tsx +++ b/packages/remix-react/__tests__/components-test.tsx @@ -47,14 +47,14 @@ describe("", () => { LiveReload = require("../components").LiveReload; let { container } = render(); expect(container.querySelector("script")).toHaveTextContent( - "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 8002;" + "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.websocketPort) || 8002;" ); }); it("can set the port explicitly", () => { let { container } = render(); expect(container.querySelector("script")).toHaveTextContent( - "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 4321;" + "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.websocketPort) || 4321;" ); }); @@ -62,7 +62,7 @@ describe("", () => { process.env.REMIX_DEV_SERVER_WS_PORT = "1234"; let { container } = render(); expect(container.querySelector("script")).toHaveTextContent( - "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || 1234;" + "let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.websocketPort) || 1234;" ); }); diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 5bd38fe7d8e..1ad587f8cc0 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -1,9 +1,7 @@ import type { HydrationState, Router } from "@remix-run/router"; import type { ReactElement } from "react"; import * as React from "react"; -import type { Location } from "react-router-dom"; import { createBrowserRouter, RouterProvider } from "react-router-dom"; -import { useSyncExternalStore } from "use-sync-external-store/shim"; import { RemixContext } from "./components"; import type { EntryContext, FutureConfig } from "./entry"; @@ -13,7 +11,10 @@ import { } from "./errorBoundaries"; import { deserializeErrors } from "./errors"; import type { RouteModules } from "./routeModules"; -import { createClientRoutes } from "./routes"; +import { + createClientRoutes, + createClientRoutesWithHMRRevalidationOptOut, +} from "./routes"; /* eslint-disable prefer-let/prefer-let */ declare global { @@ -23,7 +24,7 @@ declare global { // The number of active deferred keys rendered on the server a?: number; dev?: { - liveReloadPort?: number; + websocketPort?: number; hmrRuntime?: string; }; }; @@ -44,12 +45,18 @@ declare global { } let router: Router; -let hmrAbortController: AbortController; +let hmrAbortController: AbortController | undefined; if (import.meta && import.meta.hot) { import.meta.hot.accept( "remix:manifest", - async (newManifest: EntryContext["manifest"]) => { + async ({ + assetsManifest, + needsRevalidation, + }: { + assetsManifest: EntryContext["manifest"]; + needsRevalidation: boolean; + }) => { let routeIds = [ ...new Set( router.state.matches @@ -58,6 +65,12 @@ if (import.meta && import.meta.hot) { ), ]; + if (hmrAbortController) { + hmrAbortController.abort(); + } + hmrAbortController = new AbortController(); + let signal = hmrAbortController.signal; + // Load new route modules that we've seen. let newRouteModules = Object.assign( {}, @@ -66,12 +79,12 @@ if (import.meta && import.meta.hot) { ( await Promise.all( routeIds.map(async (id) => { - if (!newManifest.routes[id]) { + if (!assetsManifest.routes[id]) { return null; } let imported = await import( - newManifest.routes[id].module + - `?t=${newManifest.hmr?.timestamp}` + assetsManifest.routes[id].module + + `?t=${assetsManifest.hmr?.timestamp}` ); return [ id, @@ -101,8 +114,9 @@ if (import.meta && import.meta.hot) { Object.assign(window.__remixRouteModules, newRouteModules); // Create new routes - let routes = createClientRoutes( - newManifest.routes, + let routes = createClientRoutesWithHMRRevalidationOptOut( + needsRevalidation, + assetsManifest.routes, window.__remixRouteModules, window.__remixContext.future ); @@ -110,20 +124,19 @@ if (import.meta && import.meta.hot) { // This is temporary API and will be more granular before release router._internalSetRoutes(routes); - if (hmrAbortController) { - hmrAbortController.abort(); - } - hmrAbortController = new AbortController(); - let signal = hmrAbortController.signal; // Wait for router to be idle before updating the manifest and route modules // and triggering a react-refresh let unsub = router.subscribe((state) => { - if (state.revalidation === "idle" && !signal.aborted) { + if (state.revalidation === "idle") { unsub(); - // TODO: Handle race conditions here. Should abort if a new update - // comes in while we're waiting for the router to be idle. - Object.assign(window.__remixManifest, newManifest); - window.$RefreshRuntime$.performReactRefresh(); + // Abort if a new update comes in while we're waiting for the + // router to be idle. + if (signal.aborted) return; + // Ensure RouterProvider setState has flushed before re-rendering + setTimeout(() => { + Object.assign(window.__remixManifest, assetsManifest); + window.$RefreshRuntime$.performReactRefresh(); + }, 0); } }); router.revalidate(); @@ -166,16 +179,20 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { }); } + let [location, setLocation] = React.useState(router.state.location); + + React.useLayoutEffect(() => { + return router.subscribe((newState) => { + if (newState.location !== location) { + setLocation(newState.location); + } + }); + }, [location]); + // We need to include a wrapper RemixErrorBoundary here in case the root error // boundary also throws and we need to bubble up outside of the router entirely. // Then we need a stateful location here so the user can back-button navigate // out of there - let location: Location = useSyncExternalStore( - router.subscribe, - () => router.state.location, - () => router.state.location - ); - return ( errors![m.route.id]) + 1 + ) + : routerMatches; let links = React.useMemo( () => getLinksForMatches(matches, routeModules, manifest), @@ -569,9 +585,20 @@ function PrefetchPageLinksImpl({ */ function V1Meta() { let { routeModules } = useRemixContext(); - let { matches, loaderData } = useDataRouterStateContext(); + let { + errors, + matches: routerMatches, + loaderData, + } = useDataRouterStateContext(); let location = useLocation(); + let matches = errors + ? routerMatches.slice( + 0, + routerMatches.findIndex((m) => errors![m.route.id]) + 1 + ) + : routerMatches; + let meta: V1_HtmlMetaDescriptor = {}; let parentsData: { [routeId: string]: AppData } = {}; @@ -667,9 +694,20 @@ function V1Meta() { function V2Meta() { let { routeModules } = useRemixContext(); - let { matches: _matches, loaderData } = useDataRouterStateContext(); + let { + errors, + matches: routerMatches, + loaderData, + } = useDataRouterStateContext(); let location = useLocation(); + let _matches = errors + ? routerMatches.slice( + 0, + routerMatches.findIndex((m) => errors![m.route.id]) + 1 + ) + : routerMatches; + let meta: V2_MetaDescriptor[] = []; let leafMeta: V2_MetaDescriptor[] | null = null; let matches: V2_MetaMatches = []; @@ -1680,6 +1718,7 @@ export const LiveReload = process.env.NODE_ENV !== "development" ? () => null : function LiveReload({ + // TODO: remove REMIX_DEV_SERVER_WS_PORT in v2 port = Number(process.env.REMIX_DEV_SERVER_WS_PORT || 8002), timeoutMs = 1000, nonce = undefined, @@ -1698,7 +1737,7 @@ export const LiveReload = function remixLiveReloadConnect(config) { let protocol = location.protocol === "https:" ? "wss:" : "ws:"; let host = location.hostname; - let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.liveReloadPort) || ${String( + let port = (window.__remixContext && window.__remixContext.dev && window.__remixContext.dev.websocketPort) || ${String( port )}; let socketPath = protocol + "//" + host + ":" + port + "/socket"; @@ -1720,9 +1759,11 @@ export const LiveReload = } if (!event.updates || !event.updates.length) return; let updateAccepted = false; + let needsRevalidation = false; for (let update of event.updates) { console.log("[HMR] " + update.reason + " [" + update.id +"]") if (update.revalidate) { + needsRevalidation = true; console.log("[HMR] Revalidating [" + update.id + "]"); } let imported = await import(update.url + '?t=' + event.assetsManifest.hmr.timestamp); @@ -1738,7 +1779,7 @@ export const LiveReload = } if (event.assetsManifest && window.__hmr__.contexts["remix:manifest"]) { let accepted = window.__hmr__.contexts["remix:manifest"].emit( - event.assetsManifest + { needsRevalidation, assetsManifest: event.assetsManifest } ); if (accepted) { console.log("[HMR] Updated accepted by", "remix:manifest"); diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts index 4df10fc98db..fde03eca196 100644 --- a/packages/remix-react/entry.ts +++ b/packages/remix-react/entry.ts @@ -10,7 +10,7 @@ export interface RemixContextObject { serverHandoffString?: string; future: FutureConfig; abortDelay?: number; - dev?: { liveReloadPort: number }; + dev?: { websocketPort: number }; } // Additional React-Router information needed at runtime, but not hydrated diff --git a/packages/remix-react/package.json b/packages/remix-react/package.json index 58287148036..c569afa7049 100644 --- a/packages/remix-react/package.json +++ b/packages/remix-react/package.json @@ -16,9 +16,8 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "1.5.0", - "react-router-dom": "6.10.0", - "use-sync-external-store": "1.2.0" + "@remix-run/router": "1.6.0-pre.0", + "react-router-dom": "6.11.0-pre.1" }, "devDependencies": { "@remix-run/server-runtime": "1.15.0", diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index f82216f8ad8..cb6cdfd8b4b 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -104,6 +104,22 @@ export function createServerRoutes( }); } +export function createClientRoutesWithHMRRevalidationOptOut( + needsRevalidation: boolean, + manifest: RouteManifest, + routeModulesCache: RouteModules, + future: FutureConfig +) { + return createClientRoutes( + manifest, + routeModulesCache, + future, + "", + groupRoutesByParentId(manifest), + needsRevalidation + ); +} + export function createClientRoutes( manifest: RouteManifest, routeModulesCache: RouteModules, @@ -112,7 +128,8 @@ export function createClientRoutes( routesByParentId: Record< string, Omit[] - > = groupRoutesByParentId(manifest) + > = groupRoutesByParentId(manifest), + needsRevalidation: boolean | undefined = undefined ): DataRouteObject[] { return (routesByParentId[parentId] || []).map((route) => { let hasErrorBoundary = @@ -136,7 +153,11 @@ export function createClientRoutes( handle: undefined, loader: createDataFunction(route, routeModulesCache, false), action: createDataFunction(route, routeModulesCache, true), - shouldRevalidate: createShouldRevalidate(route, routeModulesCache), + shouldRevalidate: createShouldRevalidate( + route, + routeModulesCache, + needsRevalidation + ), }; let children = createClientRoutes( manifest, @@ -151,14 +172,30 @@ export function createClientRoutes( function createShouldRevalidate( route: EntryRoute, - routeModules: RouteModules + routeModules: RouteModules, + needsRevalidation: boolean | undefined ): ShouldRevalidateFunction { + let handledRevalidation = false; return function (arg) { let module = routeModules[route.id]; invariant(module, `Expected route module to be loaded for ${route.id}`); + if (module.shouldRevalidate) { + if (typeof needsRevalidation === "boolean" && !handledRevalidation) { + handledRevalidation = true; + return module.shouldRevalidate({ + ...arg, + defaultShouldRevalidate: needsRevalidation, + }); + } return module.shouldRevalidate(arg); } + + if (typeof needsRevalidation === "boolean" && !handledRevalidation) { + handledRevalidation = true; + return needsRevalidation; + } + return arg.defaultShouldRevalidate; }; } diff --git a/packages/remix-serve/cli.ts b/packages/remix-serve/cli.ts index b32e1c056b3..a1c286189aa 100644 --- a/packages/remix-serve/cli.ts +++ b/packages/remix-serve/cli.ts @@ -1,6 +1,7 @@ import "./env"; import path from "path"; import os from "os"; +import { devReady } from "@remix-run/node"; import { createApp } from "./index"; @@ -16,6 +17,7 @@ if (!buildPathArg) { } let buildPath = path.resolve(process.cwd(), buildPathArg); +let build = require(buildPath); let onListen = () => { let address = @@ -31,10 +33,14 @@ let onListen = () => { `Remix App Server started at http://localhost:${port} (http://${address}:${port})` ); } + if ( + build.future?.unstable_dev !== false && + process.env.NODE_ENV === "development" + ) { + devReady(build); + } }; -let build = require(buildPath); - let app = createApp( buildPath, process.env.NODE_ENV, diff --git a/packages/remix-serve/env.ts b/packages/remix-serve/env.ts index a212b1fa83e..5c1c352db25 100644 --- a/packages/remix-serve/env.ts +++ b/packages/remix-serve/env.ts @@ -1 +1,3 @@ +import "@remix-run/node/install"; + process.env.NODE_ENV = process.env.NODE_ENV || "production"; diff --git a/packages/remix-serve/package.json b/packages/remix-serve/package.json index 2b116261d58..2a9bbd9479b 100644 --- a/packages/remix-serve/package.json +++ b/packages/remix-serve/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@remix-run/express": "1.15.0", + "@remix-run/node": "1.15.0", "compression": "^1.7.4", "express": "^4.17.1", "morgan": "^1.10.0" diff --git a/packages/remix-server-runtime/__tests__/server-test.ts b/packages/remix-server-runtime/__tests__/server-test.ts index 8c1d33708a3..c0bf55166cb 100644 --- a/packages/remix-server-runtime/__tests__/server-test.ts +++ b/packages/remix-server-runtime/__tests__/server-test.ts @@ -1748,4 +1748,45 @@ describe("shared server runtime", () => { expect(spy.console.mock.calls.length).toBe(1 * DATA_CALL_MULTIPIER); }); }); + test("provides load context to server entrypoint", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/index": { + parentId: "root", + default: {}, + loader: indexLoader, + }, + }); + + build.entry.module.default = jest.fn( + async ( + request, + responseStatusCode, + responseHeaders, + entryContext, + loadContext + ) => + new Response(JSON.stringify(loadContext), { + status: responseStatusCode, + headers: responseHeaders, + }) + ); + + let handler = createRequestHandler(build, ServerMode.Development); + let request = new Request(`${baseUrl}/`, { method: "get" }); + let loadContext = { "load-context": "load-value" }; + + let result = await handler(request, loadContext); + expect(await result.text()).toBe(JSON.stringify(loadContext)); + }); }); diff --git a/packages/remix-server-runtime/__tests__/utils.ts b/packages/remix-server-runtime/__tests__/utils.ts index 541409dce64..b8168627b74 100644 --- a/packages/remix-server-runtime/__tests__/utils.ts +++ b/packages/remix-server-runtime/__tests__/utils.ts @@ -48,7 +48,13 @@ export function mockServerBuild( entry: { module: { default: jest.fn( - async (request, responseStatusCode, responseHeaders, entryContext) => + async ( + request, + responseStatusCode, + responseHeaders, + entryContext, + loadContext + ) => new Response(null, { status: responseStatusCode, headers: responseHeaders, diff --git a/packages/remix-server-runtime/build.ts b/packages/remix-server-runtime/build.ts index 0c87b7c07ff..4e1b831b1f2 100644 --- a/packages/remix-server-runtime/build.ts +++ b/packages/remix-server-runtime/build.ts @@ -1,6 +1,7 @@ import type { DataFunctionArgs } from "./routeModules"; import type { AssetsManifest, EntryContext, FutureConfig } from "./entry"; import type { ServerRouteManifest } from "./routes"; +import type { AppLoadContext } from "./data"; /** * The output of the compiler for the server build. @@ -14,7 +15,7 @@ export interface ServerBuild { publicPath: string; assetsBuildDirectory: string; future: FutureConfig; - dev?: { liveReloadPort: number }; + dev?: { websocketPort: number }; } export interface HandleDocumentRequestFunction { @@ -22,7 +23,8 @@ export interface HandleDocumentRequestFunction { request: Request, responseStatusCode: number, responseHeaders: Headers, - context: EntryContext + context: EntryContext, + loadContext: AppLoadContext ): Promise | Response; } diff --git a/packages/remix-server-runtime/dev.ts b/packages/remix-server-runtime/dev.ts new file mode 100644 index 00000000000..8ce5210cf5e --- /dev/null +++ b/packages/remix-server-runtime/dev.ts @@ -0,0 +1,17 @@ +import type { ServerBuild } from "./build"; + +export let devReady = (build: ServerBuild, origin?: string) => { + origin ??= process.env.REMIX_DEV_HTTP_ORIGIN; + if (!origin) throw Error("Dev server origin not set"); + + try { + fetch(`${origin}/ping`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ buildHash: build.assets.version }), + }); + } catch (error) { + console.error(`Could not reach Remix dev server at ${origin}`); + throw error; + } +}; diff --git a/packages/remix-server-runtime/index.ts b/packages/remix-server-runtime/index.ts index d6fb090fbc4..14112112827 100644 --- a/packages/remix-server-runtime/index.ts +++ b/packages/remix-server-runtime/index.ts @@ -15,6 +15,7 @@ export { createCookieSessionStorageFactory } from "./sessions/cookieStorage"; export { createMemorySessionStorageFactory } from "./sessions/memoryStorage"; export { createMemoryUploadHandler as unstable_createMemoryUploadHandler } from "./upload/memoryUploadHandler"; export { MaxPartSizeExceededError } from "./upload/errors"; +export { devReady } from "./dev"; // Types for the Remix server runtime interface export type { diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index 4d265c4c549..5e79806727c 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -16,7 +16,7 @@ "typings": "dist/index.d.ts", "module": "dist/esm/index.js", "dependencies": { - "@remix-run/router": "1.5.0", + "@remix-run/router": "1.6.0-pre.0", "@web3-storage/multipart-parser": "^1.0.0", "cookie": "^0.4.1", "set-cookie-parser": "^2.4.8", diff --git a/packages/remix-server-runtime/server.ts b/packages/remix-server-runtime/server.ts index 23cf0c442a2..97fa3a420e7 100644 --- a/packages/remix-server-runtime/server.ts +++ b/packages/remix-server-runtime/server.ts @@ -51,26 +51,6 @@ export const createRequestHandler: CreateRequestHandlerFunction = ( return async function requestHandler(request, loadContext = {}) { let url = new URL(request.url); - // special __REMIX_ASSETS_MANIFEST endpoint for checking if app server serving up-to-date routes and assets - let { unstable_dev } = build.future; - if ( - mode === "development" && - unstable_dev !== false && - url.pathname === - (unstable_dev === true - ? "/__REMIX_ASSETS_MANIFEST" - : (unstable_dev.remixRequestHandlerPath ?? "") + - "/__REMIX_ASSETS_MANIFEST") - ) { - if (request.method !== "GET") { - return new Response("Method not allowed", { status: 405 }); - } - return new Response(JSON.stringify(build.assets), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - let matches = matchServerRoutes(routes, url.pathname); let response: Response; @@ -301,7 +281,8 @@ async function handleDocumentRequestRR( request, context.statusCode, headers, - entryContext + entryContext, + loadContext ); } catch (error: unknown) { // Get a new StaticHandlerContext that contains the error at the right boundary @@ -340,7 +321,8 @@ async function handleDocumentRequestRR( request, context.statusCode, headers, - entryContext + entryContext, + loadContext ); } catch (error: any) { logServerErrorIfNotAborted(error, request, serverMode); diff --git a/packages/remix-server-runtime/serverHandoff.ts b/packages/remix-server-runtime/serverHandoff.ts index a6165841c08..edc4d4b3055 100644 --- a/packages/remix-server-runtime/serverHandoff.ts +++ b/packages/remix-server-runtime/serverHandoff.ts @@ -20,7 +20,7 @@ export function createServerHandoffString(serverHandoff: { // we'd end up including duplicate info state: ValidateShape; future: FutureConfig; - dev?: { liveReloadPort: number }; + dev?: { websocketPort: number }; }): string { // Uses faster alternative of jsesc to escape data returned from the loaders. // This string is inserted directly into the HTML in the `` element. diff --git a/packages/remix-testing/package.json b/packages/remix-testing/package.json index f99039cbe80..f345fea9e43 100644 --- a/packages/remix-testing/package.json +++ b/packages/remix-testing/package.json @@ -18,8 +18,8 @@ "dependencies": { "@remix-run/node": "1.15.0", "@remix-run/react": "1.15.0", - "@remix-run/router": "1.5.0", - "react-router-dom": "6.10.0" + "@remix-run/router": "1.6.0-pre.0", + "react-router-dom": "6.11.0-pre.1" }, "devDependencies": { "@types/node": "^18.11.9", diff --git a/packages/remix-vercel/__tests__/server-test.ts b/packages/remix-vercel/__tests__/server-test.ts index 59f248e906f..d948cfaffe3 100644 --- a/packages/remix-vercel/__tests__/server-test.ts +++ b/packages/remix-vercel/__tests__/server-test.ts @@ -170,88 +170,41 @@ describe("vercel createRequestHandler", () => { describe("vercel createRemixHeaders", () => { describe("creates fetch headers from vercel headers", () => { it("handles empty headers", () => { - expect(createRemixHeaders({})).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({}); + expect(headers.raw()).toMatchInlineSnapshot(`Object {}`); }); it("handles simple headers", () => { - expect(createRemixHeaders({ "x-foo": "bar" })).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ "x-foo": "bar" }); + expect(headers.get("x-foo")).toBe("bar"); }); it("handles multiple headers", () => { - expect(createRemixHeaders({ "x-foo": "bar", "x-bar": "baz" })) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar", - "x-bar", - "baz", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ "x-foo": "bar", "x-bar": "baz" }); + expect(headers.get("x-foo")).toBe("bar"); + expect(headers.get("x-bar")).toBe("baz"); }); it("handles headers with multiple values", () => { - expect(createRemixHeaders({ "x-foo": "bar, baz" })) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar, baz", - ], - Symbol(context): null, - } - `); - }); - - it("handles headers with multiple values and multiple headers", () => { - expect(createRemixHeaders({ "x-foo": "bar, baz", "x-bar": "baz" })) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar, baz", - "x-bar", - "baz", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ + "x-foo": ["bar", "baz"], + "x-bar": "baz", + }); + expect(headers.getAll("x-foo")).toEqual(["bar", "baz"]); + expect(headers.getAll("x-bar")).toEqual(["baz"]); }); it("handles multiple set-cookie headers", () => { - expect( - createRemixHeaders({ - "set-cookie": [ - "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", - "__other=some_other_value; Path=/; Secure; HttpOnly; MaxAge=3600; SameSite=Lax", - ], - }) - ).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "set-cookie", - "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", - "set-cookie", - "__other=some_other_value; Path=/; Secure; HttpOnly; MaxAge=3600; SameSite=Lax", - ], - Symbol(context): null, - } - `); + let headers = createRemixHeaders({ + "set-cookie": [ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", + ], + }); + expect(headers.getAll("set-cookie")).toEqual([ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", + ]); }); }); }); @@ -269,42 +222,13 @@ describe("vercel createRemixRequest", () => { }) as VercelRequest; let response = createResponse() as unknown as VercelResponse; - expect(createRemixRequest(request, response)).toMatchInlineSnapshot(` - NodeRequest { - "agent": undefined, - "compress": true, - "counter": 0, - "follow": 20, - "highWaterMark": 16384, - "insecureHTTPParser": false, - "size": 0, - Symbol(Body internals): Object { - "body": null, - "boundary": null, - "disturbed": false, - "error": null, - "size": 0, - "type": null, - }, - Symbol(Request internals): Object { - "credentials": "same-origin", - "headers": Headers { - Symbol(query): Array [ - "cache-control", - "max-age=300, s-maxage=3600", - "x-forwarded-host", - "localhost:3000", - "x-forwarded-proto", - "http", - ], - Symbol(context): null, - }, - "method": "GET", - "parsedURL": "http://localhost:3000/foo/bar", - "redirect": "follow", - "signal": AbortSignal {}, - }, - } - `); + let remixRequest = createRemixRequest(request, response); + + expect(remixRequest.method).toBe("GET"); + expect(remixRequest.headers.get("cache-control")).toBe( + "max-age=300, s-maxage=3600" + ); + expect(remixRequest.headers.get("x-forwarded-host")).toBe("localhost:3000"); + expect(remixRequest.headers.get("x-forwarded-proto")).toBe("http"); }); }); diff --git a/packages/remix-vercel/server.ts b/packages/remix-vercel/server.ts index 6707693cbe5..ba32640e756 100644 --- a/packages/remix-vercel/server.ts +++ b/packages/remix-vercel/server.ts @@ -23,7 +23,7 @@ import { export type GetLoadContextFunction = ( req: VercelRequest, res: VercelResponse -) => AppLoadContext; +) => Promise | AppLoadContext; export type RequestHandler = ( req: VercelRequest, @@ -47,7 +47,7 @@ export function createRequestHandler({ return async (req, res) => { let request = createRemixRequest(req, res); - let loadContext = getLoadContext?.(req, res); + let loadContext = await getLoadContext?.(req, res); let response = (await handleRequest(request, loadContext)) as NodeResponse; diff --git a/scripts/bump-router-versions.sh b/scripts/bump-router-versions.sh new file mode 100755 index 00000000000..fae157781fe --- /dev/null +++ b/scripts/bump-router-versions.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +VERSION="${1}" + +if [ "${VERSION}" == "" ]; then + VERSION="latest" +fi + +echo "Updating all router versions to ${VERSION}" + + +if [ ! -d "packages/remix-server-runtime" ]; then + echo "Must be run from the remix repository" + exit 1 +fi + +set -x + +cd packages/remix-server-runtime +yarn add -E @remix-run/router@${VERSION} +cd ../.. + +# cd packages/remix-express +# yarn add -E @remix-run/router@${VERSION} +# cd ../.. + +cd packages/remix-react +yarn add -E @remix-run/router@${VERSION} react-router-dom@${VERSION} +cd ../.. + +cd packages/remix-testing +yarn add -E @remix-run/router@${VERSION} react-router-dom@${VERSION} +cd ../.. + +set +x \ No newline at end of file diff --git a/templates/arc/app/root.tsx b/templates/arc/app/root.tsx index 1f082523e7b..a8b9c38907b 100644 --- a/templates/arc/app/root.tsx +++ b/templates/arc/app/root.tsx @@ -6,6 +6,12 @@ import { Scripts, ScrollRestoration, } from "@remix-run/react"; +import type { LinksFunction } from "@remix-run/node"; +import { cssBundleHref } from "@remix-run/css-bundle"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; export default function App() { return ( diff --git a/templates/arc/package.json b/templates/arc/package.json index 1cd76cdfade..888cfb5d697 100644 --- a/templates/arc/package.json +++ b/templates/arc/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@remix-run/architect": "*", + "@remix-run/css-bundle": "*", "@remix-run/node": "*", "@remix-run/react": "*", "cross-env": "^7.0.3", diff --git a/templates/cloudflare-pages/app/root.tsx b/templates/cloudflare-pages/app/root.tsx index 1f082523e7b..a8b9c38907b 100644 --- a/templates/cloudflare-pages/app/root.tsx +++ b/templates/cloudflare-pages/app/root.tsx @@ -6,6 +6,12 @@ import { Scripts, ScrollRestoration, } from "@remix-run/react"; +import type { LinksFunction } from "@remix-run/node"; +import { cssBundleHref } from "@remix-run/css-bundle"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; export default function App() { return ( diff --git a/templates/cloudflare-pages/package.json b/templates/cloudflare-pages/package.json index a90db543fb1..63942d47109 100644 --- a/templates/cloudflare-pages/package.json +++ b/templates/cloudflare-pages/package.json @@ -13,6 +13,7 @@ "dependencies": { "@remix-run/cloudflare": "*", "@remix-run/cloudflare-pages": "*", + "@remix-run/css-bundle": "*", "@remix-run/react": "*", "cross-env": "^7.0.3", "isbot": "^3.6.5", diff --git a/templates/cloudflare-workers/app/root.tsx b/templates/cloudflare-workers/app/root.tsx index 1f082523e7b..a8b9c38907b 100644 --- a/templates/cloudflare-workers/app/root.tsx +++ b/templates/cloudflare-workers/app/root.tsx @@ -6,6 +6,12 @@ import { Scripts, ScrollRestoration, } from "@remix-run/react"; +import type { LinksFunction } from "@remix-run/node"; +import { cssBundleHref } from "@remix-run/css-bundle"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; export default function App() { return ( diff --git a/templates/cloudflare-workers/package.json b/templates/cloudflare-workers/package.json index 85bd8a7ce97..5056cd7882a 100644 --- a/templates/cloudflare-workers/package.json +++ b/templates/cloudflare-workers/package.json @@ -13,6 +13,7 @@ "dependencies": { "@remix-run/cloudflare": "*", "@remix-run/cloudflare-workers": "*", + "@remix-run/css-bundle": "*", "@remix-run/react": "*", "cross-env": "^7.0.3", "isbot": "^3.6.5", diff --git a/templates/deno/app/root.tsx b/templates/deno/app/root.tsx index a6a08d93909..7bd9545748a 100644 --- a/templates/deno/app/root.tsx +++ b/templates/deno/app/root.tsx @@ -6,8 +6,14 @@ import { Scripts, ScrollRestoration, } from "@remix-run/react"; +import type { LinksFunction } from "@remix-run/deno"; +import { cssBundleHref } from "@remix-run/css-bundle"; import * as React from "react"; +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; + export default function App() { return ( diff --git a/templates/deno/package.json b/templates/deno/package.json index 9a576b9a081..227e5723b34 100644 --- a/templates/deno/package.json +++ b/templates/deno/package.json @@ -13,6 +13,7 @@ "typecheck": "deno check" }, "dependencies": { + "@remix-run/css-bundle": "*", "@remix-run/deno": "*", "@remix-run/react": "*", "isbot": "^3.6.5", diff --git a/templates/express/.eslintrc.js b/templates/express/.eslintrc.cjs similarity index 100% rename from templates/express/.eslintrc.js rename to templates/express/.eslintrc.cjs diff --git a/templates/express/app/root.tsx b/templates/express/app/root.tsx index 1f082523e7b..a8b9c38907b 100644 --- a/templates/express/app/root.tsx +++ b/templates/express/app/root.tsx @@ -6,6 +6,12 @@ import { Scripts, ScrollRestoration, } from "@remix-run/react"; +import type { LinksFunction } from "@remix-run/node"; +import { cssBundleHref } from "@remix-run/css-bundle"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; export default function App() { return ( diff --git a/templates/express/package.json b/templates/express/package.json index 6ce983974a9..eb7cc645e28 100644 --- a/templates/express/package.json +++ b/templates/express/package.json @@ -1,15 +1,15 @@ { "private": true, "sideEffects": false, + "type": "module", "scripts": { "build": "remix build", - "dev": "npm-run-all build --parallel \"dev:*\"", - "dev:node": "cross-env NODE_ENV=development nodemon --require dotenv/config ./server.js --watch ./server.js", - "dev:remix": "remix watch", + "dev": "cross-env NODE_ENV=development remix dev -c \"node ./server.js\"", "start": "cross-env NODE_ENV=production node ./server.js", "typecheck": "tsc" }, "dependencies": { + "@remix-run/css-bundle": "*", "@remix-run/express": "*", "@remix-run/node": "*", "@remix-run/react": "*", @@ -28,8 +28,6 @@ "@types/react-dom": "^18.0.8", "dotenv": "^16.0.3", "eslint": "^8.27.0", - "nodemon": "^2.0.20", - "npm-run-all": "^4.1.5", "typescript": "^5.0.4" }, "engines": { diff --git a/templates/express/remix.config.js b/templates/express/remix.config.js index a1a396661bf..059252933c5 100644 --- a/templates/express/remix.config.js +++ b/templates/express/remix.config.js @@ -1,11 +1,13 @@ /** @type {import('@remix-run/dev').AppConfig} */ -module.exports = { +export default { ignoredRouteFiles: ["**/.*"], // appDirectory: "app", // assetsBuildDirectory: "public/build", // serverBuildPath: "build/index.js", // publicPath: "/build/", + serverModuleFormat: "esm", future: { + unstable_dev: true, v2_errorBoundary: true, v2_meta: true, v2_normalizeFormMethod: true, diff --git a/templates/express/server.js b/templates/express/server.js index cac30b22a01..0ab35561ea7 100644 --- a/templates/express/server.js +++ b/templates/express/server.js @@ -1,10 +1,12 @@ -const path = require("path"); -const express = require("express"); -const compression = require("compression"); -const morgan = require("morgan"); -const { createRequestHandler } = require("@remix-run/express"); +import express from "express"; +import compression from "compression"; +import morgan from "morgan"; +import { createRequestHandler } from "@remix-run/express"; +import { installGlobals } from "@remix-run/node"; -const BUILD_DIR = path.join(process.cwd(), "build"); +import * as build from "./build/index.js"; + +installGlobals(); const app = express(); @@ -25,37 +27,15 @@ app.use(express.static("public", { maxAge: "1h" })); app.use(morgan("tiny")); -app.all( - "*", - process.env.NODE_ENV === "development" - ? (req, res, next) => { - purgeRequireCache(); - - return createRequestHandler({ - build: require(BUILD_DIR), - mode: process.env.NODE_ENV, - })(req, res, next); - } - : createRequestHandler({ - build: require(BUILD_DIR), - mode: process.env.NODE_ENV, - }) -); -const port = process.env.PORT || 3000; +const MODE = process.env.NODE_ENV; +app.all("*", createRequestHandler({ build, mode: MODE })); -app.listen(port, () => { - console.log(`Express server listening on port ${port}`); -}); +const port = process.env.PORT || 3000; +app.listen(port, async () => { + console.log(`✅ Express server listening on port ${port}`); -function purgeRequireCache() { - // purge require cache on requests for "server side HMR" this won't let - // you have in-memory objects between requests in development, - // alternatively you can set up nodemon/pm2-dev to restart the server on - // file changes, but then you'll have to reconnect to databases/etc on each - // change. We prefer the DX of this, so we've included it for you by default - for (const key in require.cache) { - if (key.startsWith(BUILD_DIR)) { - delete require.cache[key]; - } + if (process.env.NODE_ENV === "development") { + const { devReady } = await import("@remix-run/node"); + devReady(build); } -} +}); diff --git a/templates/fly/app/root.tsx b/templates/fly/app/root.tsx index 1f082523e7b..a8b9c38907b 100644 --- a/templates/fly/app/root.tsx +++ b/templates/fly/app/root.tsx @@ -6,6 +6,12 @@ import { Scripts, ScrollRestoration, } from "@remix-run/react"; +import type { LinksFunction } from "@remix-run/node"; +import { cssBundleHref } from "@remix-run/css-bundle"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; export default function App() { return ( diff --git a/templates/fly/package.json b/templates/fly/package.json index 31dca9f96b7..e091373746a 100644 --- a/templates/fly/package.json +++ b/templates/fly/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc" }, "dependencies": { + "@remix-run/css-bundle": "*", "@remix-run/node": "*", "@remix-run/react": "*", "@remix-run/serve": "*", diff --git a/templates/netlify/app/root.tsx b/templates/netlify/app/root.tsx index 1f082523e7b..a8b9c38907b 100644 --- a/templates/netlify/app/root.tsx +++ b/templates/netlify/app/root.tsx @@ -6,6 +6,12 @@ import { Scripts, ScrollRestoration, } from "@remix-run/react"; +import type { LinksFunction } from "@remix-run/node"; +import { cssBundleHref } from "@remix-run/css-bundle"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; export default function App() { return ( diff --git a/templates/netlify/package.json b/templates/netlify/package.json index 1befe56d054..e8e96928922 100644 --- a/templates/netlify/package.json +++ b/templates/netlify/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@netlify/functions": "^1.3.0", + "@remix-run/css-bundle": "*", "@remix-run/netlify": "*", "@remix-run/node": "*", "@remix-run/react": "*", diff --git a/templates/remix/app/root.tsx b/templates/remix/app/root.tsx index 1f082523e7b..a8b9c38907b 100644 --- a/templates/remix/app/root.tsx +++ b/templates/remix/app/root.tsx @@ -6,6 +6,12 @@ import { Scripts, ScrollRestoration, } from "@remix-run/react"; +import type { LinksFunction } from "@remix-run/node"; +import { cssBundleHref } from "@remix-run/css-bundle"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; export default function App() { return ( diff --git a/templates/remix/package.json b/templates/remix/package.json index a3c4d4bd571..63b22fd1f20 100644 --- a/templates/remix/package.json +++ b/templates/remix/package.json @@ -8,6 +8,7 @@ "typecheck": "tsc" }, "dependencies": { + "@remix-run/css-bundle": "*", "@remix-run/node": "*", "@remix-run/react": "*", "@remix-run/serve": "*", diff --git a/templates/remix/remix.config.js b/templates/remix/remix.config.js index a1a396661bf..61abab3ac3d 100644 --- a/templates/remix/remix.config.js +++ b/templates/remix/remix.config.js @@ -6,6 +6,7 @@ module.exports = { // serverBuildPath: "build/index.js", // publicPath: "/build/", future: { + unstable_dev: true, v2_errorBoundary: true, v2_meta: true, v2_normalizeFormMethod: true, diff --git a/templates/vercel/app/root.tsx b/templates/vercel/app/root.tsx index 1f082523e7b..a8b9c38907b 100644 --- a/templates/vercel/app/root.tsx +++ b/templates/vercel/app/root.tsx @@ -6,6 +6,12 @@ import { Scripts, ScrollRestoration, } from "@remix-run/react"; +import type { LinksFunction } from "@remix-run/node"; +import { cssBundleHref } from "@remix-run/css-bundle"; + +export const links: LinksFunction = () => [ + ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), +]; export default function App() { return ( diff --git a/templates/vercel/package.json b/templates/vercel/package.json index a3456b297ef..6b602969d9a 100644 --- a/templates/vercel/package.json +++ b/templates/vercel/package.json @@ -7,6 +7,7 @@ "typecheck": "tsc" }, "dependencies": { + "@remix-run/css-bundle": "*", "@remix-run/node": "*", "@remix-run/react": "*", "@remix-run/vercel": "*", diff --git a/yarn.lock b/yarn.lock index ab66d9deb49..7c62409f7cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2467,10 +2467,10 @@ "@changesets/types" "^5.0.0" dotenv "^8.1.0" -"@remix-run/router@1.5.0": - version "1.5.0" - resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.5.0.tgz#57618e57942a5f0131374a9fdb0167e25a117fdc" - integrity sha512-bkUDCp8o1MvFO+qxkODcbhSqRa6P2GXgrGZVpt0dCXNW2HCSCqYI0ZoAqEOSAjRWmmlKcYgFvN4B4S+zo/f8kg== +"@remix-run/router@1.6.0-pre.0": + version "1.6.0-pre.0" + resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.6.0-pre.0.tgz#feaf7da8fc2f2ed1c764b82925867c154291f1a4" + integrity sha512-5NxLiXhnnAItn69LXumQvonUMMp58DaqF+IOuMLGm9gwkqi5M6Lejn1kUcR1MZeyhbBAqYAWws/ZAkyz89cfjg== "@remix-run/web-blob@^3.0.3", "@remix-run/web-blob@^3.0.4": version "3.0.4" @@ -2480,10 +2480,10 @@ "@remix-run/web-stream" "^1.0.0" web-encoding "1.1.5" -"@remix-run/web-fetch@^4.3.2": - version "4.3.2" - resolved "https://registry.npmjs.org/@remix-run/web-fetch/-/web-fetch-4.3.2.tgz" - integrity sha512-aRNaaa0Fhyegv/GkJ/qsxMhXvyWGjPNgCKrStCvAvV1XXphntZI0nQO/Fl02LIQg3cGL8lDiOXOS1gzqDOlG5w== +"@remix-run/web-fetch@^4.3.4": + version "4.3.4" + resolved "https://registry.npmjs.org/@remix-run/web-fetch/-/web-fetch-4.3.4.tgz#6149582fa2199b8e2a35d4e653ba05772bd0e675" + integrity sha512-AUM1XBa4hcgeNt2CD86OlB5aDLlqdMl0uJ+89R8dPGx07I5BwMXnbopCaPAkvSBIoHeT/IoLWIuZrLi7RvXS+Q== dependencies: "@remix-run/web-blob" "^3.0.4" "@remix-run/web-form-data" "^3.0.3" @@ -3332,11 +3332,6 @@ resolved "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== -"@types/use-sync-external-store@^0.0.3": - version "0.0.3" - resolved "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz" - integrity sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA== - "@types/ws@^7.4.1": version "7.4.7" resolved "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz" @@ -3602,11 +3597,6 @@ abab@^2.0.3, abab@^2.0.5: resolved "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz" integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== -abbrev@1: - version "1.1.1" - resolved "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== - abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" @@ -4741,7 +4731,7 @@ choices-separator@^2.0.0: debug "^2.6.6" strip-color "^0.1.0" -chokidar@^3.4.2, chokidar@^3.5.1, chokidar@^3.5.2, chokidar@^3.5.3: +chokidar@^3.4.2, chokidar@^3.5.1, chokidar@^3.5.3: version "3.5.3" resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== @@ -7521,11 +7511,6 @@ ieee754@^1.1.13: resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== -ignore-by-default@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz#48ca6d72f6c6a3af00a9ad4ae6876be3889e2b09" - integrity sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA== - ignore@^5.1.1, ignore@^5.2.0: version "5.2.0" resolved "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz" @@ -10182,29 +10167,6 @@ node-webtokens@^1.0.4: resolved "https://registry.npmjs.org/node-webtokens/-/node-webtokens-1.0.4.tgz" integrity sha512-Sla56CeSLWvPbwud2kogqf5edQtKNXZBtXDDpmOzAgNZjwETbK/Am6PXfs54iZPLBm8K8amZ9XWaCQwGqZmKyQ== -nodemon@^2.0.20: - version "2.0.20" - resolved "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz#e3537de768a492e8d74da5c5813cb0c7486fc701" - integrity sha512-Km2mWHKKY5GzRg6i1j5OxOHQtuvVsgskLfigG25yTtbyfRGn/GNvIbRyOf1PSCKJ2aT/58TiuUsuOU5UToVViw== - dependencies: - chokidar "^3.5.2" - debug "^3.2.7" - ignore-by-default "^1.0.1" - minimatch "^3.1.2" - pstree.remy "^1.1.8" - semver "^5.7.1" - simple-update-notifier "^1.0.7" - supports-color "^5.5.0" - touch "^3.1.0" - undefsafe "^2.0.5" - -nopt@~1.0.10: - version "1.0.10" - resolved "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz#6ddd21bd2a31417b92727dd585f8a6f37608ebee" - integrity sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg== - dependencies: - abbrev "1" - normalize-package-data@^2.3.2, normalize-package-data@^2.5.0: version "2.5.0" resolved "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz" @@ -11109,11 +11071,6 @@ psl@^1.1.28, psl@^1.1.33: resolved "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz" integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== -pstree.remy@^1.1.8: - version "1.1.8" - resolved "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" - integrity sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w== - pump@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz" @@ -11231,20 +11188,20 @@ react-refresh@^0.14.0: resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@6.10.0: - version "6.10.0" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.10.0.tgz#090ddc5c84dc41b583ce08468c4007c84245f61f" - integrity sha512-E5dfxRPuXKJqzwSe/qGcqdwa18QiWC6f3H3cWXM24qj4N0/beCIf/CWTipop2xm7mR0RCS99NnaqPNjHtrAzCg== +react-router-dom@6.11.0-pre.1: + version "6.11.0-pre.1" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.11.0-pre.1.tgz#66e076c2777108b2bac1729e33bde9161a8e83f5" + integrity sha512-EEdmUk06pGPrjU87mFxKlQQMc5a/RIMA2gEwnmdAyIHdo/FAWGb917yOEDIh4pnv/wH0eSX0Wgsnuua9/k6X+g== dependencies: - "@remix-run/router" "1.5.0" - react-router "6.10.0" + "@remix-run/router" "1.6.0-pre.0" + react-router "6.11.0-pre.1" -react-router@6.10.0: - version "6.10.0" - resolved "https://registry.npmjs.org/react-router/-/react-router-6.10.0.tgz#230f824fde9dd0270781b5cb497912de32c0a971" - integrity sha512-Nrg0BWpQqrC3ZFFkyewrflCud9dio9ME3ojHCF/WLsprJVzkq3q3UeEhMCAW1dobjeGbWgjNn/PVF6m46ANxXQ== +react-router@6.11.0-pre.1: + version "6.11.0-pre.1" + resolved "https://registry.npmjs.org/react-router/-/react-router-6.11.0-pre.1.tgz#cbeade3bec425aab45e0a49060c7735ba98a8bbb" + integrity sha512-CBOpqUysQxG598u9I0ngzoXXxMXYDEkBW6sSL9O1xyFJg3dYa0ytaVlKap9QAKOYsYTiMlkMiAGFKbyp81ErHA== dependencies: - "@remix-run/router" "1.5.0" + "@remix-run/router" "1.6.0-pre.0" react@^18.2.0: version "18.2.0" @@ -11762,12 +11719,12 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" -"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.6.0, semver@^5.7.1: +"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.6.0: version "5.7.1" resolved "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== -semver@7.0.0, semver@~7.0.0: +semver@7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== @@ -11919,13 +11876,6 @@ simple-git@^3.16.0: "@kwsites/promise-deferred" "^1.1.1" debug "^4.3.4" -simple-update-notifier@^1.0.7: - version "1.1.0" - resolved "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz#67694c121de354af592b347cdba798463ed49c82" - integrity sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg== - dependencies: - semver "~7.0.0" - sisteransi@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz" @@ -12402,7 +12352,7 @@ supertest@^6.1.5: methods "^1.1.2" superagent "^6.1.0" -supports-color@^5.3.0, supports-color@^5.5.0: +supports-color@^5.3.0: version "5.5.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz" integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== @@ -12678,13 +12628,6 @@ toml@^3.0.0: resolved "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz" integrity sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w== -touch@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz#fe365f5f75ec9ed4e56825e0bb76d24ab74af83b" - integrity sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA== - dependencies: - nopt "~1.0.10" - tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz" @@ -12962,11 +12905,6 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" -undefsafe@^2.0.5: - version "2.0.5" - resolved "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz#38733b9327bdcd226db889fb723a6efd162e6e2c" - integrity sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA== - unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz" @@ -13181,11 +13119,6 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -use-sync-external-store@1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz" - integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== - util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"