Skip to content

Commit

Permalink
Merge branch 'dev' into dev
Browse files Browse the repository at this point in the history
  • Loading branch information
pcattori authored Dec 4, 2023
2 parents 37e8492 + 4222e71 commit 404de6a
Show file tree
Hide file tree
Showing 17 changed files with 835 additions and 884 deletions.
5 changes: 5 additions & 0 deletions .changeset/angry-dingos-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/dev": patch
---

Upgrade Vite peer dependency range to v5
11 changes: 11 additions & 0 deletions .changeset/grumpy-cats-roll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@remix-run/dev": minor
---

Vite: Strict route exports

With Vite, Remix gets stricter about which exports are allowed from your route modules.
Previously, the Remix compiler would allow any export from routes.
While this was convenient, it was also a common source of bugs that were hard to track down because they only surfaced at runtime.

For more, see https://remix.run/docs/en/main/future/vite#strict-route-exports
15 changes: 15 additions & 0 deletions .changeset/rude-keys-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@remix-run/dev": patch
---

Vite: Preserve names for exports from .client imports

Unlike `.server` modules, the main idea is not to prevent code from leaking into the server build
since the client build is already public. Rather, the goal is to isolate the SSR render from client-only code.
Routes need to import code from `.client` modules without compilation failing and then rely on runtime checks
to determine if the code is running on the server or client.

Replacing `.client` modules with empty modules would cause the build to fail as ESM named imports are statically analyzed.
So instead, we preserve the named export but replace each exported value with an empty object.
That way, the import is valid at build time and the standard runtime checks can be used to determine if then
code is running on the server or client.
5 changes: 5 additions & 0 deletions .changeset/shiny-timers-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@remix-run/dev": patch
---

Add `@remix-run/node` to Vite's `optimizeDeps.include` array
10 changes: 10 additions & 0 deletions .changeset/wise-pumas-thank.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@remix-run/dev": patch
---

Vite: Errors at build-time when client imports .server default export

Remix already stripped .server file code before ensuring that server code never makes it into the client.
That results in errors when client code tries to import server code, which is exactly what we want!
But those errors were happening at runtime for default imports.
A better experience is to have those errors happen at build-time so that you guarantee that your users won't hit them.
109 changes: 109 additions & 0 deletions docs/future/vite.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,25 @@ This means that, for any additional bundling features you'd like to use, you sho
Vite has many [features][vite-features] and [plugins][vite-plugins] that are not built into the existing Remix compiler.
The use of any such features will render the existing Remix compiler unable to compile your app, so only use them if you intend to use Vite exclusively from here on out.

#### `.server` directories

In addition to `.server` files, the Remix's Vite plugin also supports `.server` directories.
Any code in a `.server` directory will be excluded from the client bundle.

```txt
app
├── .server 👈 everything in this directory is excluded from the client bundle
│ ├── auth.ts
│ └── db.ts
├── cms.server.ts 👈 everything in this file is excluded from the client bundle
├── root.tsx
└── routes
└── _index.tsx
```

`.server` files and directories can be _anywhere_ within your Remix app directory (typically `app/`).
If you need more control, you can always write your own Vite plugins to exclude other files or directories from any other locations.

## Migrating

#### Setup Vite
Expand Down Expand Up @@ -615,6 +634,96 @@ const posts = import.meta.glob("./posts/*.mdx", {
});
```

#### Strict route exports

With Vite, Remix gets stricter about which exports are allowed from your route modules.

Previously, Remix allowed user-defined exports from routes.
The Remix compiler would then rely on treeshaking to remove any code only intended for use on the server from the client bundle.

```ts filename=app/routes/super-cool.tsx
// `loader`: always server-only, remove from client bundle 👍
export const loader = () => {};

// `default`: always client-safe, keep `default` in client bundle 👍
export default function SuperCool() {}

// User-defined export
export const mySuperCoolThing = () => {
/*
Client-safe or server-only? Depends on what code is in here... 🤷
Rely on treeshaking to remove from client bundle if it depends on server-only code.
*/
};
```

In contrast, Vite processes each module in isolation during development, so relying on cross-module treeshaking is not an option.
For most modules, you should already be using `.server` files or directories to isolate server-only code.
But routes are a special case since they intentionally blend client and server code.
Remix knows that exports like `loader`, `action`, `headers`, etc. are server-only, so it can safely remove them from the client bundle.
But there's no way to know when looking at a single route module in isolation whether user-defined exports are server-only.
That's why Remix's Vite plugin is stricter about which exports are allowed from your route modules.

```ts filename=app/routes/super-cool.tsx
export const loader = () => {}; // server-only 👍
export default function SuperCool() {} // client-safe 👍

// Need to decide whether this is client-safe or server-only without any other information 😬
export const mySuperCoolThing = () => {};
```

In fact, we'd rather not rely on treeshaking for correctness at all.
If tomorrow you or your coworker accidentally imports something you _thought_ was client-safe,
treeshaking will no longer exclude that from your client bundle and you might end up with server code in your app!
Treeshaking is designed as a pure optimization, so relying on it for correctness is brittle.

So instead of treeshaking, its better to be explicit about what code is client-safe and what code is server-only.
For route modules, that means only exporting Remix route exports.
For anything else, put it in a separate module and use a `.server` file or directory when needed.

Ultimately, Route exports are Remix API.
Think of a Remix route module like a function and the exports like named arguments to the function.

```ts
// Not real API, just a mental model
const route = createRoute({ loader, mySuperCoolThing });
// ^^^^^^^^^^^^^^^^
// Object literal may only specify known properties, and 'mySuperCoolThing' does not exist in type 'RemixRoute'
```

Just like how you shouldn't pass unexpected named arguments to a function, you shouldn't create unexpected exports from a route module.
The result is that Remix is simpler and more predictable.
In short, Vite made us eat our veggies, but turns out they were delicious all along!

👉 **Move any user-defined route exports to a separate module**

For example, here's a route with a user-defined export called `mySuperCoolThing`:

```ts filename=app/routes/super-cool.tsx
// ✅ This is a valid Remix route export, so it's fine
export const loader = () => {};

// ✅ This is also a valid Remix route export
export default function SuperCool() {}

// ❌ This isn't a Remix-specific route export, just something I made up
export const mySuperCoolThing = () => {};
```

One option is to colocate your route and related utilities in the same directory if your routing convention allows it.
For example, with the default route convention in v2:

```ts filename=app/routes/super-cool/route.tsx
export const loader = () => {};

export default function SuperCool() {}
```

```ts filename=app/routes/super-cool/utils.ts
// If this was server-only code, I'd rename this file to "utils.server.ts"
export const mySuperCoolThing = () => {};
```

## Troubleshooting

Check out the [known issues with the Remix Vite plugin on GitHub][issues-vite] before filing a new bug report!
Expand Down
77 changes: 65 additions & 12 deletions integration/helpers/vite.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import path from "node:path";
import fs from "node:fs/promises";
import type { Readable } from "node:stream";
import url from "node:url";
import execa from "execa";
Expand All @@ -8,10 +9,15 @@ import resolveBin from "resolve-bin";
import stripIndent from "strip-indent";
import waitOn from "wait-on";
import getPort from "get-port";
import shell from "shelljs";
import glob from "glob";

const __dirname = url.fileURLToPath(new URL(".", import.meta.url));

export const VITE_CONFIG = async (args: { port: number }) => {
export const VITE_CONFIG = async (args: {
port: number;
vitePlugins?: string;
}) => {
let hmrPort = await getPort();
return String.raw`
import { defineConfig } from "vite";
Expand All @@ -25,7 +31,7 @@ export const VITE_CONFIG = async (args: { port: number }) => {
port: ${hmrPort}
}
},
plugins: [remix()],
plugins: [remix(),${args.vitePlugins ?? ""}],
});
`;
};
Expand Down Expand Up @@ -115,28 +121,53 @@ const createDev =
(nodeArgs: string[]) =>
async ({ cwd, port }: ServerArgs): Promise<() => Promise<void>> => {
let proc = node(nodeArgs, { cwd });
await waitForServer(proc, { port: port });
await waitForServer(proc, { port });
return async () => await kill(proc.pid!);
};

export const viteBuild = (args: { cwd: string }) => {
let vite = resolveBin.sync("vite");
export const viteBuild = ({ cwd }: { cwd: string }) => {
let nodeBin = process.argv[0];
let viteBin = resolveBin.sync("vite");
let commands = [
[vite, "build"],
[vite, "build", "--ssr"],
[viteBin, "build"],
[viteBin, "build", "--ssr"],
];
let results = [];
for (let command of commands) {
let result = spawnSync("node", command, {
cwd: args.cwd,
env: {
...process.env,
},
let result = spawnSync(nodeBin, command, {
cwd,
env: { ...process.env },
});
results.push(result);
}
return results;
};

export const viteRemixServe = async ({
cwd,
port,
}: {
cwd: string;
port: number;
}) => {
let nodeBin = process.argv[0];
let serveProc = spawn(
nodeBin,
["node_modules/@remix-run/serve/dist/cli.js", "build/server/index.js"],
{
cwd,
stdio: "pipe",
env: { NODE_ENV: "production", PORT: port.toFixed(0) },
}
);

await waitForServer(serveProc, { port });

return () => {
serveProc.kill();
};
};

export const viteDev = createDev([resolveBin.sync("vite"), "dev"]);
export const customDev = createDev(["./server.mjs"]);

Expand Down Expand Up @@ -212,3 +243,25 @@ function bufferize(stream: Readable): () => string {
stream.on("data", (data) => (buffer += data.toString()));
return () => buffer;
}

export function createEditor(projectDir: string) {
return async (file: string, transform: (contents: string) => string) => {
let filepath = path.join(projectDir, file);
let contents = await fs.readFile(filepath, "utf8");
await fs.writeFile(filepath, transform(contents), "utf8");
};
}

export function grep(cwd: string, pattern: RegExp): string[] {
let assetFiles = glob.sync("**/*.@(js|jsx|ts|tsx)", {
cwd,
absolute: true,
});

let lines = shell
.grep("-l", pattern, assetFiles)
.stdout.trim()
.split("\n")
.filter((line) => line.length > 0);
return lines;
}
Loading

0 comments on commit 404de6a

Please sign in to comment.