decisions/0010-splitting-up-client-and-server-code-in-vite.md
Date: 2024-02-01
Status: accepted
Before adopting Vite, Remix used to rely on ESbuild's treeshaking to implicitly separate client and server code. Even though Vite provides equivalent treeshaking (via Rollup) for builds, it does not perform cross-module treeshaking when running the dev server. In any case, we think its a bad idea to rely on treeshaking for correctness.
Goals:
Remix already provides .server modules to explicitly separate client and server code at the module level (Goal 1 ✅).
However, Remix's previous compiler replaced .server modules with empty modules.
While this ensured that code from .server modules never leaks into the client,
it also meant that any accidental references to imports from .server in the client
would result in runtime errors, not compile-time errors (Goal 2 ❌).
TypeScript does not understand that imports from .server modules may not exist on the client
so typechecking does not catch these runtime errors (Goal 3 ❌).
For example:
import { getFortune } from "~/db.server.ts";
export default function Route() {
const [fortune, setFortune] = useState(null);
return (
<>
{user ? (
<h1>Your fortune of the day: {fortune}</h1>
) : (
<button onClick={() => setFortune(getFortune())}>
Open fortune cookie 🥠
</button>
)}
</>
);
}
Your editor would not show any red squigglies, typechecking in CI would pass, and Remix would build your app without warnings or errors. But you've just shipped a bug that will crash your app anytime a user clicks the "Get user" button.
In development, Vite's dev server compiles requested JavaScript modules on the fly. As a result, Vite must decide how to transform each module without knowing the entire module graph. The Plugin API makes this apparent:1
resolveId only provides the current importerload and transform do not receive any information about the module graphThis approach lets Vite load and transform each module once and cache the result2 which is a keystone for its speed.
While .server modules are a great way to separate client and server code in most cases,
there will always be a need to stitch together modules that mix client and server code.
For example, you may want to migrate from the previous compiler to Vite without needing to manually split up mixed modules.
But supporting mixed modules directly in Remix would require compile-time magic which would add substantial complexity.
Not only would it degrade performance for all users (Goal 4 ❌),
but writing compile-time transforms that manipulate the AST is much more error-prone than throwing a compile-time error when .server modules are imported by client code.
Depending on how its implemented, bugs in that compile-time magic could open the door to leaking server code into the client (Goal 1 ❌).
.server modules (including new .server directories) in Remix to split client and server code at the module-level (Goal 1 ✅)loader, action, headers) and then explicitly run dead-code eliminate.server modules remained after dead-code elimination (Goal 2 ✅)Users are encouraged to primarily use .server modules but can always opt for more powerful, expression-level separation with vite-env-only.
Since Remix now throws when .server imports remain in the built client code, there are no remaining runtime errors to catch with typechecking for module-level separation (Goal 3 ✅).
For expression-level separation, vite-env-only provides optional types (<T>(_: T) => T | undefined) which lets TypeScript prevent any runtime errors.
Checking for .server modules only requires checking the module's path and does not require AST parsing or transformations, so it's extremely fast (Goal 4 ✅).
vite-env-only does require AST parsing and transformations so it will always be slower than .server modules.