Skip to content

Commit 083e6b4

Browse files
committed
Merge branch 'dev' into feature/client-data
2 parents 637b023 + 999b565 commit 083e6b4

File tree

9 files changed

+213
-57
lines changed

9 files changed

+213
-57
lines changed

.changeset/rude-keys-heal.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@remix-run/dev": patch
3+
---
4+
5+
Vite: Preserve names for exports from .client imports
6+
7+
Unlike `.server` modules, the main idea is not to prevent code from leaking into the server build
8+
since the client build is already public. Rather, the goal is to isolate the SSR render from client-only code.
9+
Routes need to import code from `.client` modules without compilation failing and then rely on runtime checks
10+
to determine if the code is running on the server or client.
11+
12+
Replacing `.client` modules with empty modules would cause the build to fail as ESM named imports are statically analyzed.
13+
So instead, we preserve the named export but replace each exported value with an empty object.
14+
That way, the import is valid at build time and the standard runtime checks can be used to determine if then
15+
code is running on the server or client.

.changeset/sour-stingrays-roll.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@remix-run/serve": patch
3+
---
4+
5+
Fix source map loading when file has `?t=timestamp` suffix (rebuilds)

docs/future/vite.md

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -641,13 +641,37 @@ With Vite, Remix gets stricter about which exports are allowed from your route m
641641
Previously, Remix allowed user-defined exports from routes.
642642
The Remix compiler would then rely on treeshaking to remove any code only intended for use on the server from the client bundle.
643643

644-
In contrast, Vite processes each module in isolation during development, so cross-module treeshaking is not possible.
645-
You should already be separating server-only code into `.server` files or directories, so treeshaking isn't needed for those modules.
644+
```ts filename=app/routes/super-cool.tsx
645+
// `loader`: always server-only, remove from client bundle 👍
646+
export const loader = () => {};
647+
648+
// `default`: always client-safe, keep `default` in client bundle 👍
649+
export default function SuperCool() {}
650+
651+
// User-defined export
652+
export const mySuperCoolThing = () => {
653+
/*
654+
Client-safe or server-only? Depends on what code is in here... 🤷
655+
Rely on treeshaking to remove from client bundle if it depends on server-only code.
656+
*/
657+
};
658+
```
659+
660+
In contrast, Vite processes each module in isolation during development, so relying on cross-module treeshaking is not an option.
661+
For most modules, you should already be using `.server` files or directories to isolate server-only code.
646662
But routes are a special case since they intentionally blend client and server code.
647663
Remix knows that exports like `loader`, `action`, `headers`, etc. are server-only, so it can safely remove them from the client bundle.
648664
But there's no way to know when looking at a single route module in isolation whether user-defined exports are server-only.
649665
That's why Remix's Vite plugin is stricter about which exports are allowed from your route modules.
650666

667+
```ts filename=app/routes/super-cool.tsx
668+
export const loader = () => {}; // server-only 👍
669+
export default function SuperCool() {} // client-safe 👍
670+
671+
// Need to decide whether this is client-safe or server-only without any other information 😬
672+
export const mySuperCoolThing = () => {};
673+
```
674+
651675
In fact, we'd rather not rely on treeshaking for correctness at all.
652676
If tomorrow you or your coworker accidentally imports something you _thought_ was client-safe,
653677
treeshaking will no longer exclude that from your client bundle and you might end up with server code in your app!
@@ -676,15 +700,14 @@ In short, Vite made us eat our veggies, but turns out they were delicious all al
676700
For example, here's a route with a user-defined export called `mySuperCoolThing`:
677701

678702
```ts filename=app/routes/super-cool.tsx
679-
// ❌ This isn't a Remix-specific route export, just something I made up
680-
export const mySuperCoolThing =
681-
"Some value I wanted to colocate with my route!";
682-
683703
// ✅ This is a valid Remix route export, so it's fine
684704
export const loader = () => {};
685705

686706
// ✅ This is also a valid Remix route export
687707
export default function SuperCool() {}
708+
709+
// ❌ This isn't a Remix-specific route export, just something I made up
710+
export const mySuperCoolThing = () => {};
688711
```
689712

690713
One option is to colocate your route and related utilities in the same directory if your routing convention allows it.
@@ -698,8 +721,7 @@ export default function SuperCool() {}
698721

699722
```ts filename=app/routes/super-cool/utils.ts
700723
// If this was server-only code, I'd rename this file to "utils.server.ts"
701-
export const mySuperCoolThing =
702-
"Some value I wanted to colocate with my route!";
724+
export const mySuperCoolThing = () => {};
703725
```
704726

705727
## Troubleshooting

integration/helpers/vite.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import resolveBin from "resolve-bin";
99
import stripIndent from "strip-indent";
1010
import waitOn from "wait-on";
1111
import getPort from "get-port";
12+
import shell from "shelljs";
13+
import glob from "glob";
1214

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

@@ -249,3 +251,17 @@ export function createEditor(projectDir: string) {
249251
await fs.writeFile(filepath, transform(contents), "utf8");
250252
};
251253
}
254+
255+
export function grep(cwd: string, pattern: RegExp): string[] {
256+
let assetFiles = glob.sync("**/*.@(js|jsx|ts|tsx)", {
257+
cwd,
258+
absolute: true,
259+
});
260+
261+
let lines = shell
262+
.grep("-l", pattern, assetFiles)
263+
.stdout.trim()
264+
.split("\n")
265+
.filter((line) => line.length > 0);
266+
return lines;
267+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import * as path from "node:path";
2+
import { test, expect } from "@playwright/test";
3+
4+
import { createProject, grep, viteBuild } from "./helpers/vite.js";
5+
6+
let files = {
7+
"app/utils.client.ts": String.raw`
8+
export const dotClientFile = "CLIENT_ONLY_FILE";
9+
export default dotClientFile;
10+
`,
11+
"app/.client/utils.ts": String.raw`
12+
export const dotClientDir = "CLIENT_ONLY_DIR";
13+
export default dotClientDir;
14+
`,
15+
};
16+
17+
test("Vite / client code excluded from server bundle", async () => {
18+
let cwd = await createProject({
19+
...files,
20+
"app/routes/dot-client-imports.tsx": String.raw`
21+
import { dotClientFile } from "../utils.client";
22+
import { dotClientDir } from "../.client/utils";
23+
24+
export default function() {
25+
const [mounted, setMounted] = useState(false);
26+
27+
useEffect(() => {
28+
setMounted(true);
29+
}, []);
30+
31+
return (
32+
<>
33+
<h2>Index</h2>
34+
<p>{mounted ? dotClientFile + dotClientDir : ""}</p>
35+
</>
36+
);
37+
}
38+
`,
39+
});
40+
let [client, server] = viteBuild({ cwd });
41+
expect(client.status).toBe(0);
42+
expect(server.status).toBe(0);
43+
let lines = grep(
44+
path.join(cwd, "build/server"),
45+
/CLIENT_ONLY_FILE|CLIENT_ONLY_DIR/
46+
);
47+
expect(lines).toHaveLength(0);
48+
});

integration/vite-dot-server-test.ts

Lines changed: 63 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import * as path from "node:path";
22
import { test, expect } from "@playwright/test";
3-
import shell from "shelljs";
4-
import glob from "glob";
53

6-
import { createProject, viteBuild } from "./helpers/vite.js";
4+
import { createProject, grep, viteBuild } from "./helpers/vite.js";
75

86
let files = {
97
"app/utils.server.ts": String.raw`
@@ -16,7 +14,7 @@ let files = {
1614
`,
1715
};
1816

19-
test("Vite / .server file / named import in client fails with expected error", async () => {
17+
test("Vite / .server file / named import in client fails with expected error", async () => {
2018
let cwd = await createProject({
2119
...files,
2220
"app/routes/fail-server-file-in-client.tsx": String.raw`
@@ -35,7 +33,7 @@ test("Vite / .server file / named import in client fails with expected error",
3533
);
3634
});
3735

38-
test("Vite / .server file / namespace import in client fails with expected error", async () => {
36+
test("Vite / .server file / namespace import in client fails with expected error", async () => {
3937
let cwd = await createProject({
4038
...files,
4139
"app/routes/fail-server-file-in-client.tsx": String.raw`
@@ -126,54 +124,75 @@ test("Vite / .server dir / default import in client fails with expected error",
126124
expect(stderr).toMatch(`"default" is not exported by "app/.server/utils.ts"`);
127125
});
128126

127+
test("Vite / `handle` with dynamic imports as an escape hatch for server-only code", async () => {
128+
let cwd = await createProject({
129+
...files,
130+
"app/routes/handle-server-only.tsx": String.raw`
131+
export const handle = {
132+
// Sharp knife alert: you probably should avoid doing this, but you can!
133+
serverOnlyEscapeHatch: async () => {
134+
let { dotServerFile } = await import("~/utils.server");
135+
let dotServerDir = await import("~/.server/utils");
136+
return { dotServerFile, dotServerDir };
137+
}
138+
}
139+
140+
export default function() {
141+
return <h1>This should work</h1>
142+
}
143+
`,
144+
});
145+
let [client, server] = viteBuild({ cwd });
146+
expect(client.status).toBe(0);
147+
expect(server.status).toBe(0);
148+
149+
let lines = grep(
150+
path.join(cwd, "build/client"),
151+
/SERVER_ONLY_FILE|SERVER_ONLY_DIR/
152+
);
153+
expect(lines).toHaveLength(0);
154+
});
155+
129156
test("Vite / dead-code elimination for server exports", async () => {
130157
let cwd = await createProject({
131158
...files,
132159
"app/routes/remove-server-exports-and-dce.tsx": String.raw`
133-
import fs from "node:fs";
134-
import { json } from "@remix-run/node";
135-
import { useLoaderData } from "@remix-run/react";
160+
import fs from "node:fs";
161+
import { json } from "@remix-run/node";
162+
import { useLoaderData } from "@remix-run/react";
136163
137-
import { dotServerFile } from "../utils.server";
138-
import { dotServerDir } from "../.server/utils";
164+
import { dotServerFile } from "../utils.server";
165+
import { dotServerDir } from "../.server/utils";
139166
140-
export const loader = () => {
141-
let contents = fs.readFileSync("blah");
142-
let data = dotServerFile + dotServerDir + serverOnly + contents;
143-
return json({ data });
144-
}
167+
export const loader = () => {
168+
let contents = fs.readFileSync("blah");
169+
let data = dotServerFile + dotServerDir + serverOnly + contents;
170+
return json({ data });
171+
}
145172
146-
export const action = () => {
147-
console.log(dotServerFile, dotServerDir, serverOnly);
148-
return null;
149-
}
173+
export const action = () => {
174+
console.log(dotServerFile, dotServerDir, serverOnly);
175+
return null;
176+
}
150177
151-
export default function() {
152-
let { data } = useLoaderData<typeof loader>();
153-
return (
154-
<>
155-
<h2>Index</h2>
156-
<p>{data}</p>
157-
</>
158-
);
159-
}
160-
`,
178+
export default function() {
179+
let { data } = useLoaderData<typeof loader>();
180+
return (
181+
<>
182+
<h2>Index</h2>
183+
<p>{data}</p>
184+
</>
185+
);
186+
}
187+
`,
161188
});
162-
let client = viteBuild({ cwd })[0];
189+
let [client, server] = viteBuild({ cwd });
163190
expect(client.status).toBe(0);
191+
expect(server.status).toBe(0);
164192

165-
// detect client asset files
166-
let assetFiles = glob.sync("**/*.@(js|jsx|ts|tsx)", {
167-
cwd: path.join(cwd, "build/client"),
168-
absolute: true,
169-
});
170-
171-
// grep for server-only values in client assets
172-
let result = shell
173-
.grep("-l", /SERVER_ONLY_FILE|SERVER_ONLY_DIR|node:fs/, assetFiles)
174-
.stdout.trim()
175-
.split("\n")
176-
.filter((line) => line.length > 0);
177-
178-
expect(result).toHaveLength(0);
193+
let lines = grep(
194+
path.join(cwd, "build/client"),
195+
/SERVER_ONLY_FILE|SERVER_ONLY_DIR|node:fs/
196+
);
197+
expect(lines).toHaveLength(0);
179198
});

packages/remix-dev/vite/plugin.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -945,14 +945,21 @@ export const remixVitePlugin: RemixVitePlugin = (options = {}) => {
945945
},
946946
{
947947
name: "remix-empty-client-modules",
948-
enforce: "pre",
949-
async transform(_code, id, options) {
948+
enforce: "post",
949+
async transform(code, id, options) {
950950
if (!options?.ssr) return;
951951
let clientFileRE = /\.client(\.[cm]?[jt]sx?)?$/;
952952
let clientDirRE = /\/\.client\//;
953953
if (clientFileRE.test(id) || clientDirRE.test(id)) {
954+
let exports = esModuleLexer(code)[1];
954955
return {
955-
code: "export {}",
956+
code: exports
957+
.map(({ n: name }) =>
958+
name === "default"
959+
? "export default {};"
960+
: `export const ${name} = {};`
961+
)
962+
.join("\n"),
956963
map: null,
957964
};
958965
}

packages/remix-serve/cli.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,19 @@ import getPort from "get-port";
1818

1919
process.env.NODE_ENV = process.env.NODE_ENV ?? "production";
2020

21-
sourceMapSupport.install();
21+
sourceMapSupport.install({
22+
retrieveSourceMap: function (source) {
23+
// get source file without the `file://` prefix or `?t=...` suffix
24+
let match = source.match(/^file:\/\/(.*)\?t=[.\d]+$/);
25+
if (match) {
26+
return {
27+
url: source,
28+
map: fs.readFileSync(`${match[1]}.map`, "utf8"),
29+
};
30+
}
31+
return null;
32+
},
33+
});
2234
installGlobals();
2335

2436
run();

templates/express/server.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,19 @@ import express from "express";
99
import morgan from "morgan";
1010
import sourceMapSupport from "source-map-support";
1111

12-
sourceMapSupport.install();
12+
sourceMapSupport.install({
13+
retrieveSourceMap: function (source) {
14+
// get source file without the `file://` prefix or `?t=...` suffix
15+
const match = source.match(/^file:\/\/(.*)\?t=[.\d]+$/);
16+
if (match) {
17+
return {
18+
url: source,
19+
map: fs.readFileSync(`${match[1]}.map`, "utf8"),
20+
};
21+
}
22+
return null;
23+
},
24+
});
1325
installGlobals();
1426

1527
/** @typedef {import('@remix-run/node').ServerBuild} ServerBuild */

0 commit comments

Comments
 (0)