Skip to content

Commit 1f8695d

Browse files
committed
fix(plugin-vite): use Vite's pre-bundled dependency ids when
optimizeDeps is enabled (#3895)
1 parent d4f6d13 commit 1f8695d

4 files changed

Lines changed: 173 additions & 14 deletions

File tree

packages/plugin-vite/src/plugins/deno.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import * as path from "@std/path";
1010
import * as babel from "@babel/core";
1111
import { httpAbsolute } from "./patches/http_absolute.ts";
1212
import { cleanId, JS_REG, JSX_REG } from "../utils.ts";
13-
import { depsOptimizerOf, ensureVersionQuery } from "./version_query.ts";
13+
import {
14+
depsOptimizerOf,
15+
ensureVersionQuery,
16+
tryOptimizedResolve,
17+
} from "./version_query.ts";
1418
import { builtinModules } from "node:module";
1519

1620
// @ts-ignore Workaround for https://github.com/denoland/deno/issues/30850
@@ -158,6 +162,17 @@ export function deno(): Plugin {
158162

159163
const depsOptimizer = depsOptimizerOf(this.environment);
160164
if (depsOptimizer !== undefined) {
165+
// A pre-bundled dependency is served from Vite's cache, so hand back
166+
// the cache id rather than the file we just resolved to.
167+
const optimized = await tryOptimizedResolve(
168+
original,
169+
resolved,
170+
depsOptimizer,
171+
);
172+
if (optimized !== undefined) {
173+
return { id: optimized };
174+
}
175+
161176
// When `optimizeDeps` is enabled, ensure resolved URLs include versions to match Vite.
162177
resolved = ensureVersionQuery(resolved, depsOptimizer);
163178
}

packages/plugin-vite/src/plugins/version_query.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,53 @@
1+
import type { DevEnvironment } from "vite";
12
import { cleanId } from "../utils.ts";
23

3-
/** The parts of Vite's `DepsOptimizer` we rely on. */
4-
export interface DepsOptimizerLike {
5-
metadata: { browserHash: string };
6-
options: { extensions?: string[] };
4+
/**
5+
* Vite does not export `DepsOptimizer`, but it is reachable through the
6+
* `DevEnvironment` type, so we can use Vite's own definition rather than
7+
* describing the shape ourselves.
8+
*/
9+
export type DepsOptimizer = NonNullable<DevEnvironment["depsOptimizer"]>;
10+
11+
/**
12+
* Mirrors `tryOptimizedResolve()` from Vite's `vite:resolve` plugin.
13+
*
14+
* When a dependency has been pre-bundled, Vite serves it from its own cache
15+
* (`/.vite/deps/…`) rather than from the package on disk. We have to return
16+
* the same id, otherwise the specifiers we resolve keep pointing at the
17+
* original files and the dependency is loaded a second time alongside the
18+
* bundle.
19+
*
20+
* Vite looks the dependency up by specifier, which only works for the bare
21+
* names it uses itself. Deno rewrites imports inside jsr modules to `npm:`
22+
* specifiers (`npm:@preact/signals@^2.0.0`), so we additionally match on the
23+
* file the dependency was pre-bundled from, which is independent of how the
24+
* specifier was written.
25+
*/
26+
export async function tryOptimizedResolve(
27+
specifier: string,
28+
resolved: string,
29+
depsOptimizer: DepsOptimizer,
30+
): Promise<string | undefined> {
31+
// Metadata is incomplete until dependency scanning has settled.
32+
await depsOptimizer.scanProcessing;
33+
34+
const { optimized, discovered, chunks, depInfoList } = depsOptimizer.metadata;
35+
36+
const bySpecifier = optimized[specifier] ?? discovered[specifier] ??
37+
chunks[specifier];
38+
if (bySpecifier !== undefined) {
39+
return depsOptimizer.getOptimizedDepId(bySpecifier);
40+
}
41+
42+
const file = cleanId(resolved);
43+
const bySource = depInfoList.find((info) =>
44+
info.src !== undefined && cleanId(info.src) === file
45+
);
46+
if (bySource !== undefined) {
47+
return depsOptimizer.getOptimizedDepId(bySource);
48+
}
49+
50+
return undefined;
751
}
852

953
const DEP_VERSION_REG = /[?&]v=/;
@@ -16,8 +60,8 @@ const OPTIMIZABLE_REG = /\.[cm]?[jt]s$/;
1660
*/
1761
export function depsOptimizerOf(
1862
environment: unknown,
19-
): DepsOptimizerLike | undefined {
20-
return (environment as { depsOptimizer?: DepsOptimizerLike }).depsOptimizer;
63+
): DepsOptimizer | undefined {
64+
return (environment as { depsOptimizer?: DepsOptimizer }).depsOptimizer;
2165
}
2266

2367
/**
@@ -31,7 +75,7 @@ export function depsOptimizerOf(
3175
*/
3276
export function ensureVersionQuery(
3377
resolved: string,
34-
depsOptimizer: DepsOptimizerLike,
78+
depsOptimizer: DepsOptimizer,
3579
): string {
3680
// Only dependencies are versioned; app source uses `?t=` for HMR instead.
3781
if (!resolved.includes("node_modules")) return resolved;

packages/plugin-vite/src/plugins/version_query_test.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,40 @@
11
import { expect } from "@std/expect/expect";
22
import {
3-
type DepsOptimizerLike,
3+
type DepsOptimizer,
44
depsOptimizerOf,
55
ensureVersionQuery,
6+
tryOptimizedResolve,
67
} from "./version_query.ts";
78

89
const DEP = "/app/node_modules/preact/dist/preact.mjs";
910

11+
interface FakeDepInfo {
12+
file: string;
13+
src?: string;
14+
browserHash?: string;
15+
}
16+
1017
function optimizer(
11-
options: { browserHash?: string; extensions?: string[] } = {},
12-
): DepsOptimizerLike {
18+
options: {
19+
browserHash?: string;
20+
extensions?: string[];
21+
optimized?: Record<string, FakeDepInfo>;
22+
depInfoList?: FakeDepInfo[];
23+
} = {},
24+
): DepsOptimizer {
1325
return {
14-
metadata: { browserHash: options.browserHash ?? "abc123" },
26+
metadata: {
27+
browserHash: options.browserHash ?? "abc123",
28+
optimized: options.optimized ?? {},
29+
discovered: {},
30+
chunks: {},
31+
depInfoList: options.depInfoList ?? [],
32+
},
1533
options: { extensions: options.extensions },
16-
};
34+
getOptimizedDepId: (info: FakeDepInfo) =>
35+
`${info.file}?v=${info.browserHash ?? "abc123"}`,
36+
// The rest of Vite's `DepsOptimizer` is not exercised here.
37+
} as unknown as DepsOptimizer;
1738
}
1839

1940
Deno.test("version query - versions a dependency", () => {
@@ -55,3 +76,29 @@ Deno.test("version query - depsOptimizerOf tolerates a missing optimizer", () =>
5576
expect(depsOptimizerOf({})).toBeUndefined();
5677
expect(depsOptimizerOf({ depsOptimizer: optimizer() })).toBeDefined();
5778
});
79+
80+
Deno.test("optimized resolve - substitutes a pre-bundled dependency", async () => {
81+
const deps = optimizer({
82+
optimized: { preact: { file: "/app/.vite/deps/preact.js" } },
83+
});
84+
85+
expect(await tryOptimizedResolve("preact", DEP, deps))
86+
.toEqual("/app/.vite/deps/preact.js?v=abc123");
87+
});
88+
89+
Deno.test("optimized resolve - matches by source for `npm:` specifiers", async () => {
90+
// Deno rewrites imports inside jsr modules, so the specifier never matches
91+
// the optimizer's keys and only the source file identifies the dependency.
92+
const deps = optimizer({
93+
optimized: { preact: { file: "/app/.vite/deps/preact.js", src: DEP } },
94+
depInfoList: [{ file: "/app/.vite/deps/preact.js", src: DEP }],
95+
});
96+
97+
expect(await tryOptimizedResolve("npm:preact@^10.0.0", DEP, deps))
98+
.toEqual("/app/.vite/deps/preact.js?v=abc123");
99+
});
100+
101+
Deno.test("optimized resolve - leaves dependencies that were not pre-bundled", async () => {
102+
expect(await tryOptimizedResolve("preact", DEP, optimizer()))
103+
.toBeUndefined();
104+
});

packages/plugin-vite/tests/dev_server_test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,10 @@ export default defineConfig({
628628
// module are resolved by the Deno plugin and emitted unhashed. The browser
629629
// keys modules on the url it fetched, so one file under two urls becomes two
630630
// instances. This is a resolve-time problem, not a load-time one.
631+
//
632+
// `@preact/signals` is deliberately left out of `include` here: pre-bundling
633+
// it would serve it from Vite's cache instead, so the clash this covers could
634+
// no longer happen. The test below pre-bundles it on purpose.
631635
integrationTest(
632636
"vite dev - optimizeDeps does not duplicate dependency instances",
633637
async () => {
@@ -647,7 +651,6 @@ export default defineConfig({
647651
await withBrowser(async (page) => {
648652
await page.goto(address, { waitUntil: "networkidle2" });
649653

650-
// A path fetched under more than one query string is loaded twice.
651654
const urls: string[] = await page.evaluate(() =>
652655
performance.getEntriesByType("resource").map((entry) => entry.name)
653656
);
@@ -665,6 +668,7 @@ export default defineConfig({
665668
queries.add(parsed.search);
666669
}
667670

671+
// A path fetched under more than one query string is loaded twice.
668672
const duplicated = Array.from(queriesByPath)
669673
.filter(([, queries]) => queries.size > 1)
670674
.map(([pathname]) => pathname);
@@ -681,6 +685,55 @@ export default defineConfig({
681685
},
682686
);
683687

688+
// A pre-bundled dependency is served from Vite's own cache, so anything that
689+
// still resolves to the package on disk loads it alongside the bundle. Deno
690+
// rewrites imports inside jsr modules to `npm:` specifiers, which do not match
691+
// the optimizer's keys, so the lookup has to fall back to the pre-bundled
692+
// source to recognise them.
693+
integrationTest(
694+
"vite dev - optimizeDeps serves pre-bundled dependencies once",
695+
async () => {
696+
const fixture = path.join(FIXTURE_DIR, "remote_island");
697+
await using tmp = await prepareDevServer(fixture, {
698+
config: `import { defineConfig } from "vite";
699+
import { fresh } from "@fresh/plugin-vite";
700+
701+
export default defineConfig({
702+
plugins: [fresh({ islandSpecifiers: ["@marvinh-test/fresh-island"] })],
703+
optimizeDeps: { include: ["preact", "@preact/signals"] },
704+
});
705+
`,
706+
});
707+
708+
await launchDevServer(tmp.dir, async (address) => {
709+
await withBrowser(async (page) => {
710+
await page.goto(address, { waitUntil: "networkidle2" });
711+
712+
const paths: string[] = await page.evaluate(() =>
713+
performance.getEntriesByType("resource").map((entry) =>
714+
new URL(entry.name).pathname
715+
)
716+
);
717+
718+
// Sanity check that the optimizer actually ran.
719+
expect(paths.some((p) => p.includes("/.vite/deps/"))).toEqual(true);
720+
721+
// The remote island imports both as `npm:` specifiers. They are
722+
// pre-bundled, so neither may also be fetched from node_modules.
723+
const rawCopies = paths.filter((p) =>
724+
/node_modules\/.*\/(preact\/dist\/|@preact\/signals\/dist\/)/.test(p)
725+
);
726+
727+
expect(rawCopies).toEqual([]);
728+
729+
await page.locator(".remote-island").wait();
730+
await page.locator(".increment").click();
731+
await waitForText(page, ".result", "Count: 1");
732+
});
733+
});
734+
},
735+
);
736+
684737
// issue: https://github.com/denoland/fresh/issues/3666
685738
integrationTest(
686739
"vite dev - basePath does not intercept Vite URLs",

0 commit comments

Comments
 (0)